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

Building an MCP Server

From an empty folder to a working, tested MCP server with the official TypeScript SDK: one tool over stdio, tools a model can actually use, resources and prompts, tests that talk real MCP, the config files that connect it to VS Code, Claude Desktop and Claude Code, and long-running tools with progress, cancellation and logs.

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

#A first server with the TypeScript SDK

Added

An MCP server is an ordinary program that describes what it can do and then answers requests about it. You don't write a protocol parser, a socket server or a JSON Schema by hand: the official TypeScript SDK, @modelcontextprotocol/sdk, does all of that. Your part is to create an McpServer, register tools (each with a name, a description, an input schema written in zod and an async handler), and connect the server to a transport. For a local server the transport is stdio: the host (VS Code, Claude Desktop, Claude Code) starts your program as a child process, writes JSON-RPC requests to its stdin and reads the answers from its stdout. MCP Fundamentals explains those messages; this chapter is about producing them.

The mental model: your server is a specialist sitting behind a hatch. The host passes slips of paper through the hatch (one JSON message per line); the SDK is the clerk who reads each slip, checks it's filled in correctly, finds the right specialist (your handler) and passes the answer back out. You only write the specialist's part: "given a room id, here is the room's status". Everything the clerk does (the handshake, listing your tools, validating arguments, turning exceptions into error results) comes free, which is also why the first server is about thirty lines.

Two facts to keep in your head for the whole chapter. First, the SDK version matters: this chapter uses @modelcontextprotocol/sdk 1.32.2 (published 23 October 2026), the v1 maintenance line. Its README says it implements the MCP specification up to revision 2025-11-25; the current revision, 2026-07-28, is implemented by SDK v2, which ships as separate @modelcontextprotocol/server and @modelcontextprotocol/client packages (released 2026-07-27), and v1 gets fixes for at least six months after that. Every runnable model below speaks 2025-11-25 for real; where 2026-07-28 changes something you'd build, the text says so and labels it as not run. Second, on stdio, stdout belongs to the protocol: a stray console.log corrupts the message stream, so all logging goes to stderr (MCP Fundamentals shows the failure).

Host VS Code, Claude Desktop, Claude Code, Inspector MCP client starts the server as a child process command + args stdin stdout stderr → log node server.mjs StdioServerTransport (SDK) one JSON message per line in and out McpServer (SDK) initialize, tools/list, zod validation, thrown errors → isError results Your handler: the only part you write async ({ roomId }) => result Room data a fake object now; the device REST API later
What runs where. The host owns the process; the SDK owns the protocol; you own the handler and the data behind it.

From an empty folder (Node 18 or newer is the SDK's minimum; these commands were run on Node 26.7 with npm 11.19 on 27 October 2026):

  1. mkdir room-tools && cd room-tools gives you an empty project folder.
  2. npm init -y writes a default package.json.
  3. npm pkg set type=module makes .js files ES modules (this chapter names files .mjs, which are ES modules either way).
  4. npm install @modelcontextprotocol/sdk zod installs the SDK and its required peer dependency. On 27 October 2026 that resolved to @modelcontextprotocol/sdk@1.32.2 and zod@4.6.7; npm's latest tag for the SDK package is still the v1 line.

Then one file, server.mjs, with one tool:

server.mjs
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

// Stand-in for the device REST API; chapter 07 swaps in real HTTP calls.
const rooms = {
  "huddle-2": { inCall: true, camera: "on", mic: "unmuted", firmware: "4.3.1" },
  "board-1": { inCall: false, camera: "off", mic: "muted", firmware: "4.2.0" },
};

const server = new McpServer({ name: "room-tools", version: "0.1.0" });

server.registerTool(
  "get_room_status",
  {
    title: "Get room status",
    description:
      "Live status of one meeting-room device: in a call or not, camera, mic and firmware version. " +
      "Read-only. Room ids look like huddle-2 or board-1.",
    inputSchema: { roomId: z.string().describe("Room id, for example huddle-2") },
    annotations: { readOnlyHint: true },
  },
  async ({ roomId }) => {
    const room = rooms[roomId];
    if (!room) {
      return {
        isError: true,
        content: [{ type: "text", text: `No room called ${roomId}. Known rooms: ${Object.keys(rooms).join(", ")}.` }],
      };
    }
    return { content: [{ type: "text", text: JSON.stringify({ roomId, ...room }) }] };
  },
);

await server.connect(new StdioServerTransport());
console.error("room-tools 0.1.0 ready on stdio"); // stderr: stdout belongs to the protocol
What’s happening
  1. Imports. McpServer is the SDK's high-level server (server/mcp.js), StdioServerTransport is the stdio transport (server/stdio.js), and z is zod. The .js suffixes in the import paths are part of the SDK's package exports; leave them in.
  2. new McpServer({ name, version }) sets the serverInfo the host sees in the handshake. Nothing has been sent yet; registering tools only fills in a table.
  3. registerTool(name, config, handler) is the current API (the older server.tool(...) overloads are marked @deprecated in 1.32.2's mcp.d.ts). The config holds a title for the host's UI, the description the model reads to decide when to call it, an inputSchema written as a zod shape (an object of zod types, not z.object(...); both work, the shape is the common form), and annotations that tell the host this tool only reads.
  4. The handler receives arguments that the SDK has already validated against the schema, so roomId is guaranteed to be a string. It returns a result: content is a list of blocks, here one text block with the room as JSON. For an unknown room it returns a normal result with isError: true and a message that tells the model what to do instead; it doesn't throw (more on that in Designing good tools).
  5. server.connect(new StdioServerTransport()) starts reading stdin. From now on the process stays alive because stdin is open, and it exits when the host closes stdin. The await is top-level, which ES modules allow.
  6. console.error, not console.log, announces that it started. stderr is the log channel; stdout carries only protocol messages. Swap in console.log and every client that starts this server sees an invalid line before the handshake.
AdvancedRunning it by hand

A stdio server needs nobody but a terminal. Running node server.mjs and pasting JSON-RPC lines works; here the same four lines were saved to requests.jsonl and piped in, so you can see exactly what a host sends first: the initialize request, the initialized notification, and two tool calls.

requests.jsonl
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"terminal","version":"0.0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_room_status","arguments":{"roomId":"huddle-2"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_room_status","arguments":{"roomId":"lobby"}}}
node server.mjs < requests.jsonl (run 27 October 2026)
room-tools 0.1.0 ready on stdio
{"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"room-tools","version":"0.1.0"}},"jsonrpc":"2.0","id":1}
{"result":{"content":[{"type":"text","text":"{\"roomId\":\"huddle-2\",\"inCall\":true,\"camera\":\"on\",\"mic\":\"unmuted\",\"firmware\":\"4.3.1\"}"}]},"jsonrpc":"2.0","id":2}
{"result":{"content":[{"type":"text","text":"No room called lobby. Known rooms: huddle-2, board-1."}],"isError":true},"jsonrpc":"2.0","id":3}
What’s happening
  1. The first line, room-tools 0.1.0 ready on stdio, came from stderr (the terminal shows both streams mixed). A host would put it in its log, not in the protocol.
  2. initialize (id 1) got the server's protocolVersion (2025-11-25, the one requested), its capabilities and serverInfo. The SDK declared tools: { listChanged: true } by itself because a tool was registered.
  3. The notifications/initialized line got no answer, because notifications never do.
  4. tools/call for huddle-2 (id 2) returned the handler's text block. The room JSON is a string inside text, so its quotes are escaped.
  5. tools/call for lobby (id 3) returned a result, not a JSON-RPC error, with isError: true and the message listing valid rooms. When stdin ended (the pipe closed after the fourth line), the process exited with status 0.
AdvancedThe host's view, in one file

A real host uses an MCP client: the SDK's Client plus StdioClientTransport, which spawns the server, runs the handshake and gives you listTools() and callTool(). This model holds the server code above unchanged (under serve) and a host that starts the file again as its own server, so it runs as one file.

first-client.mjs
// The host's side of the first server: spawn it, list its tools, call one. One file, so it runs anywhere.
import { fileURLToPath } from "node:url";

if (process.argv[2] === "serve") {
  // ---- server.mjs, unchanged ----
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const { z } = await import("zod");
  const rooms = {
    "huddle-2": { inCall: true, camera: "on", mic: "unmuted", firmware: "4.3.1" },
    "board-1": { inCall: false, camera: "off", mic: "muted", firmware: "4.2.0" },
  };
  const server = new McpServer({ name: "room-tools", version: "0.1.0" });
  server.registerTool("get_room_status", {
    title: "Get room status",
    description: "Live status of one meeting-room device: in a call or not, camera, mic and firmware version. " +
      "Read-only. Room ids look like huddle-2 or board-1.",
    inputSchema: { roomId: z.string().describe("Room id, for example huddle-2") },
    annotations: { readOnlyHint: true },
  }, async ({ roomId }) => {
    const room = rooms[roomId];
    if (!room) return { isError: true, content: [{ type: "text", text: `No room called ${roomId}. Known rooms: ${Object.keys(rooms).join(", ")}.` }] };
    return { content: [{ type: "text", text: JSON.stringify({ roomId, ...room }) }] };
  });
  await server.connect(new StdioServerTransport());
  console.error("room-tools 0.1.0 ready on stdio");
} else {
  // ---- what a host does with it ----
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
  const transport = new StdioClientTransport({
    command: process.execPath, // the same "node" that runs this file
    args: [fileURLToPath(import.meta.url), "serve"],
    stderr: "pipe", // keep the server's logs apart from the protocol
  });
  let serverLog = "";
  transport.stderr.on("data", (d) => (serverLog += d));
  const client = new Client({ name: "tiny-host", version: "1.0.0" });
  await client.connect(transport); // spawns the process and runs initialize
  console.log("connected to", client.getServerVersion().name, client.getServerVersion().version);
  console.log("capabilities:", JSON.stringify(client.getServerCapabilities()));

  const { tools } = await client.listTools();
  console.log("tools:", tools.map((t) => t.name).join(", "));

  for (const roomId of ["huddle-2", "lobby"]) {
    const result = await client.callTool({ name: "get_room_status", arguments: { roomId } });
    console.log(`${roomId}: isError=${result.isError ?? false} ${result.content[0].text}`);
  }
  await client.close(); // closes stdin; the server process exits
  console.log("server's stderr:", JSON.stringify(serverLog));
}
Result
connected to room-tools 0.1.0
capabilities: {"tools":{"listChanged":true}}
tools: get_room_status
huddle-2: isError=false {"roomId":"huddle-2","inCall":true,"camera":"on","mic":"unmuted","firmware":"4.3.1"}
lobby: isError=true No room called lobby. Known rooms: huddle-2, board-1.
server's stderr: "room-tools 0.1.0 ready on stdio\n"
What’s happening
  1. StdioClientTransport gets the same three things a host config file holds: a command (process.execPath, the absolute path of the running node), args (this file plus serve) and options. stderr: "pipe" keeps the server's stderr apart so the host can log it; the default, "inherit", would print it to the host's own stderr.
  2. client.connect(transport) spawns the child process, sends initialize, checks the answer and sends notifications/initialized. After it resolves, getServerVersion() and getServerCapabilities() hold what the server declared.
  3. listTools() and callTool() are typed wrappers around tools/list and tools/call. The second call's isError=true is the handler's own error result, delivered like any other result.
  4. client.close() ends the child's stdin; the server's stdin closes and the process exits. The server's one stderr line was collected and printed last so the output is in a stable order.
  5. This is the shape of every integration test and every host: spawn, handshake, list, call, close. Testing a server builds on it.
AdvancedTurning it into a project

A single file is fine for learning. For a server you'll test and ship, split what the server offers from how it's started, and keep the data behind a small interface so tests can swap it. This is the layout the rest of the chapter uses, in the same room-tools folder (each caption is the file's path; the tests later go in test/ and the smoke script in scripts/). The files were run as shown:

src/rooms.mjs
// The data layer. Here it's an in-memory fake; in production it would call the device REST API.
export function createFakeRooms(seed = {
  "huddle-2": { inCall: true, camera: "on", mic: "unmuted", firmware: "4.3.1" },
  "board-1": { inCall: false, camera: "off", mic: "muted", firmware: "4.2.0" },
}) {
  const rooms = structuredClone(seed);
  return {
    async list() { return Object.keys(rooms); },
    async get(roomId) { return rooms[roomId] ? { roomId, ...rooms[roomId] } : undefined; },
  };
}
src/server.mjs
// What the server offers. No transport and no process wiring here, so tests can use it directly.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function createServer({ rooms }) {
  const server = new McpServer({ name: "room-tools", version: "0.2.0" });

  server.registerTool(
    "list_rooms",
    {
      title: "List rooms",
      description: "Ids of every meeting room this server knows. Call this first when the user names a room you don't have an id for.",
      outputSchema: { roomIds: z.array(z.string()) },
      annotations: { readOnlyHint: true },
    },
    async () => {
      const roomIds = await rooms.list();
      return { content: [{ type: "text", text: roomIds.join(", ") }], structuredContent: { roomIds } };
    },
  );

  server.registerTool(
    "get_room_status",
    {
      title: "Get room status",
      description:
        "Live status of one meeting-room device: in a call or not, camera, mic and firmware version. " +
        "Read-only. Room ids look like huddle-2 or board-1.",
      inputSchema: { roomId: z.string().describe("Room id, for example huddle-2") },
      outputSchema: {
        roomId: z.string(), inCall: z.boolean(), camera: z.enum(["on", "off"]),
        mic: z.enum(["muted", "unmuted"]), firmware: z.string(),
      },
      annotations: { readOnlyHint: true },
    },
    async ({ roomId }) => {
      const room = await rooms.get(roomId);
      if (!room) {
        const known = await rooms.list();
        return { isError: true, content: [{ type: "text", text: `No room called ${roomId}. Known rooms: ${known.join(", ")}.` }] };
      }
      return { content: [{ type: "text", text: JSON.stringify(room) }], structuredContent: room };
    },
  );

  return server;
}
src/stdio.mjs
#!/usr/bin/env node
// The entry point a host launches. It only wires the server to stdin/stdout.
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.mjs";
import { createFakeRooms } from "./rooms.mjs";

const server = createServer({ rooms: createFakeRooms() });
await server.connect(new StdioServerTransport());
console.error("room-tools 0.2.0 ready on stdio");
What’s happening
  1. src/rooms.mjs is the data layer: an object with list() and get(roomId). It's a fake here; chapter 07 swaps in real HTTP calls with the same two methods, and nothing else changes.
  2. src/server.mjs exports createServer({ rooms }), a factory that builds and returns a configured McpServer without connecting it. A second tool, list_rooms, gives the model a way to discover ids, and both tools now declare an outputSchema and return structuredContent as well as text.
  3. src/stdio.mjs is the entry point a host runs: build the server with real dependencies, connect it to stdio, log to stderr. The #!/usr/bin/env node line and chmod +x let it be run directly and published as a package bin.
  4. Because nothing in createServer touches stdin or stdout, a test can connect the same server to an in-memory transport, and the Inspector or a host can start src/stdio.mjs. One server, several front doors.

Then, in the project folder, add the bin entry and the scripts, make the entry point executable, and install the Inspector as a dev dependency (it's used in Testing a server):

Finishing the project (run 27 October 2026)
npm pkg set bin.room-tools=src/stdio.mjs scripts.test="node --test" scripts.start="node src/stdio.mjs" scripts.smoke="sh scripts/smoke.sh"
chmod +x src/stdio.mjs
npm install -D @modelcontextprotocol/inspector@2.12.0

npm 11.19 warned that it skipped the Inspector's postinstall script (node scripts/install-clients.mjs). That script only installs the Inspector's own clients in a checkout of its repository and exits early when the package is installed as a dependency, so the warning is harmless here. The resulting package.json:

package.json
{
  "name": "room-tools",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "node --test",
    "start": "node src/stdio.mjs",
    "smoke": "sh scripts/smoke.sh"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.32.2",
    "zod": "^4.6.7"
  },
  "bin": {
    "room-tools": "src/stdio.mjs"
  },
  "devDependencies": {
    "@modelcontextprotocol/inspector": "^2.12.0"
  }
}
Where did the output go?

You add console.log("loaded", Object.keys(rooms).length, "rooms") right after the rooms object in server.mjs and start it from a host. What happens, and what's the one-word fix?

Show answer

The line goes to stdout, the protocol channel, before the handshake. The client reads loaded 2 rooms as a message, fails to parse it as JSON and reports a transport error; depending on timing and the host, the server may still connect afterwards or the host may show it as failed. The fix is console.error.

What’s happening
  1. On stdio, the host reads the server's stdout as newline-delimited JSON-RPC. Every line must be one valid message.
  2. console.log writes to stdout, so loaded 2 rooms\n becomes a line the client tries to parse. It isn't JSON, so the SDK client raises a parse error through its onerror (MCP Fundamentals' stdio-logging model shows the exact message).
  3. Worse cases: a library that writes to stdout without a newline glues its text onto the front of the next real response, and that response is lost.
  4. console.error writes to stderr, which the stdio transport spec lets servers use for logging; hosts capture it (Claude Desktop writes it to a per-server log file).

#Designing good tools

Added

A tool definition is the entire user interface your server has, and its user is a language model that sees nothing else: not your README, not your code, not the conversation that led you to write it. At each step the host sends the model the tool's name, description and input schema (as JSON Schema) alongside the conversation, and the model decides whether to call it and with what arguments. A vague name, a description that doesn't say when to use the tool, or a schema that accepts any string means the model guesses, and guesses are wrong in ways that look plausible. MCP Fundamentals lists the fields; this topic is about choosing their contents.

The mental model is writing the help text for an on-call engineer who can only read the help text. Name the action plainly, say what it returns, say when to use it and when not to, constrain every argument so a wrong value is impossible or rejected with a fix, and make every error tell them their next step. Then keep the set small: the model reads every tool's definition on every turn, so twelve thin wrappers around REST endpoints cost context and make the choice harder than three tools shaped like the tasks people actually ask for.

PartDoAvoid
Nameverb_noun in snake_case: get_room_status, list_rooms, set_volume. The 2025-11-25 spec says names should be 1–128 characters of A–Z a–z 0–9 _ - ., unique within the server, case-sensitiveroom, doStuff, GetRoomStatusV2, spaces. Claude Code calls MCP tools mcp__<server>__<tool> and leaves out any tool whose full name passes 128 characters (its MCP docs, read 27 October 2026), so keep names short
DescriptionWhat it does, what it returns, when to use it, how it relates to other tools ("call list_rooms first if you don't know the id"), whether it changes anythingRestating the name, internal jargon, describing the implementation
Input schemaEnums for fixed choices, min/max for numbers, patterns for formats, .describe() on every field, defaults for optional knobsz.any(), a free-text query that the handler parses, a JSON string argument
OutputShort text the model can read, plus structuredContent matching an outputSchema when the data has a shapeDumping a whole API response or a 5,000-line log
ErrorsisError: true with a message that names the fix ("Known rooms: huddle-2, board-1")Raw exception text, stack traces, internal addresses
AnnotationsHonest readOnlyHint, destructiveHint, idempotentHintMarking a write tool read-only to skip a confirmation

Annotations aren't decoration in real hosts. The VS Code MCP developer guide (read 27 October 2026) says it shows a confirmation dialog for every MCP tool not marked readOnlyHint, lets the user edit the model's arguments in that dialog, and runs read-only tools without asking. That makes a dishonest annotation a security bug, not a style issue.

Here is what a careful zod schema becomes on the wire, and how the SDK enforces it before your handler runs.

schema-to-wire.mjs
// What a zod schema turns into on the wire, and what the handler receives after validation.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";

const server = new McpServer({ name: "room-tools", version: "0.3.0" });
server.registerTool("find_rooms", {
  description: "Find meeting rooms by state or firmware. Returns at most `limit` room ids. " +
    "Use it to answer questions like 'which rooms are offline?' or 'which rooms need the 4.3 update?'.",
  inputSchema: {
    state: z.enum(["in_call", "idle", "offline"]).optional().describe("Only rooms in this state"),
    firmwareBelow: z.string().regex(/^\d+\.\d+\.\d+$/).optional().describe("Only rooms older than this version, e.g. 4.3.0"),
    limit: z.number().int().min(1).max(50).default(10).describe("Maximum number of ids to return"),
  },
  annotations: { readOnlyHint: true },
}, async (args) => ({ content: [{ type: "text", text: `handler got ${JSON.stringify(args)}` }] }));

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

const [tool] = (await client.listTools()).tools;
console.log("the model sees these properties:");
for (const [name, schema] of Object.entries(tool.inputSchema.properties)) console.log(`  ${name}: ${JSON.stringify(schema)}`);
console.log("required:", JSON.stringify(tool.inputSchema.required ?? []));

for (const args of [{}, { state: "offline", limit: 3 }, { limit: 500 }, { state: "busy" }, { firmwareBelow: "latest" }]) {
  const r = await client.callTool({ name: "find_rooms", arguments: args });
  console.log(`${JSON.stringify(args).padEnd(30)} → ${r.isError ? "isError: " : ""}${r.content[0].text}`);
}
await client.close();
Result
the model sees these properties:
  state: {"description":"Only rooms in this state","type":"string","enum":["in_call","idle","offline"]}
  firmwareBelow: {"description":"Only rooms older than this version, e.g. 4.3.0","type":"string","pattern":"^\\d+\\.\\d+\\.\\d+$"}
  limit: {"default":10,"description":"Maximum number of ids to return","type":"integer","minimum":1,"maximum":50}
required: []
{}                             → handler got {"limit":10}
{"state":"offline","limit":3}  → handler got {"state":"offline","limit":3}
{"limit":500}                  → isError: MCP error -32602: Input validation error: Invalid arguments for tool find_rooms: Too big: expected number to be <=50 at limit
{"state":"busy"}               → isError: MCP error -32602: Input validation error: Invalid arguments for tool find_rooms: Invalid option: expected one of "in_call"|"idle"|"offline" at state
{"firmwareBelow":"latest"}     → isError: MCP error -32602: Input validation error: Invalid arguments for tool find_rooms: Invalid string: must match pattern /^\d+\.\d+\.\d+$/ at firmwareBelow
What’s happening
  1. The zod shape becomes JSON Schema in tools/list: z.enum([...]) became "enum", .regex(...) became "pattern", .int().min(1).max(50) became "type":"integer","minimum":1,"maximum":50, and every .describe(...) became a "description" the model reads next to the field. That's why describing each field is worth the keystrokes.
  2. required is empty: .optional() fields aren't required, and limit has a .default(10), so it isn't either. A field with a default is the friendliest kind of optional: the model can leave it out and the behaviour is still defined.
  3. {} reached the handler as {"limit":10}: the SDK parses the arguments with zod, so defaults are filled in and the handler never sees a missing limit.
  4. {"limit":500}, {"state":"busy"} and {"firmwareBelow":"latest"} never reached the handler. Each came back as isError: true with zod's message naming the field and the rule (<=50, the allowed options, the pattern), which is exactly what the model needs to correct its call on the next step.
  5. What this demonstrates: every constraint you put in the schema is enforced for free and explained to the model for free. A rule that lives only in the handler ("limit can't exceed 50") has to be checked and worded by you, and the model can't see it in advance.
AdvancedErrors that help the model recover

Most tool failures aren't bugs. The room doesn't exist, the device is in a call, the user isn't allowed: the model can do something useful with each of those if the message says what. Unexpected failures are different: the model can't fix a refused connection, and the raw exception text (an internal IP, a stack frame) shouldn't reach it at all. A small wrapper keeps the two apart for every tool. MCP Fundamentals shows what SDK 1.32.2 returns for each kind of failure; this is how to shape them.

helpful-errors.mjs
// One wrapper for every tool: expected failures become guidance for the model,
// unexpected ones become a short reference while the details go to the server's log.
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";

class ToolFailure extends Error {} // "the model can act on this message"
const log = []; // stands in for console.error / your log pipeline
let incidents = 0;

function safe(handler) {
  return async (args, extra) => {
    try {
      return await handler(args, extra);
    } catch (err) {
      if (err instanceof ToolFailure) return { isError: true, content: [{ type: "text", text: err.message }] };
      const ref = `E${++incidents}`;
      log.push(`${ref} request ${extra.requestId}: ${err.stack.split("\n")[0]}`);
      return { isError: true, content: [{ type: "text", text: `Internal error (ref ${ref}). Don't retry; tell the user the room service failed.` }] };
    }
  };
}

const speakers = { "huddle-2": 40, "board-1": 55 };
const inCall = new Set(["huddle-2"]);
const server = new McpServer({ name: "room-tools", version: "0.3.0" });
server.registerTool("set_volume", {
  description: "Set a room's speaker volume (0-100). Changes the device.",
  inputSchema: {
    roomId: z.string(),
    level: z.number().int().min(0).max(100),
    duringCall: z.boolean().default(false).describe("Set true only after the user confirms changing a live call"),
  },
  annotations: { destructiveHint: false, idempotentHint: true },
}, safe(async ({ roomId, level, duringCall }) => {
  if (roomId === "board-1") throw new Error("connect ECONNREFUSED 10.0.4.17:443"); // the device API is down
  if (!(roomId in speakers))
    throw new ToolFailure(`${roomId} has no speaker. Rooms with speakers: ${Object.keys(speakers).join(", ")}.`);
  if (inCall.has(roomId) && !duringCall)
    throw new ToolFailure(`${roomId} is in a call. Ask the user to confirm, then call again with duringCall: true.`);
  speakers[roomId] = level;
  return { content: [{ type: "text", text: `${roomId} volume is now ${level}.` }] };
}));

const client = new Client({ name: "host", version: "1.0.0" });
const [c, s] = InMemoryTransport.createLinkedPair();
await server.connect(s);
await client.connect(c);
for (const args of [
  { roomId: "lobby", level: 30 },
  { roomId: "huddle-2", level: 30 },
  { roomId: "huddle-2", level: 30, duringCall: true },
  { roomId: "board-1", level: 30 },
]) {
  const r = await client.callTool({ name: "set_volume", arguments: args });
  console.log(`${JSON.stringify(args).padEnd(52)} ${r.isError ? "isError" : "ok"} | ${r.content[0].text}`);
}
console.log("server log:", log);
await client.close();
Result
{"roomId":"lobby","level":30}                        isError | lobby has no speaker. Rooms with speakers: huddle-2, board-1.
{"roomId":"huddle-2","level":30}                     isError | huddle-2 is in a call. Ask the user to confirm, then call again with duringCall: true.
{"roomId":"huddle-2","level":30,"duringCall":true}   ok | huddle-2 volume is now 30.
{"roomId":"board-1","level":30}                      isError | Internal error (ref E1). Don't retry; tell the user the room service failed.
server log: [ 'E1 request 4: Error: connect ECONNREFUSED 10.0.4.17:443' ]
What’s happening
  1. ToolFailure marks an error as "written for the model". safe(handler) turns it into isError: true with the message unchanged. Any other exception becomes a generic message with a reference (E1), and the real detail goes to the server's log with the JSON-RPC request id, so a human can find it.
  2. lobby: the room has no speaker, and the message lists the rooms that do. The model's next call can be right.
  3. huddle-2 without duringCall: a guardrail. Changing volume during a live call needs the user's say-so, so the tool refuses and says exactly how to proceed: ask, then call again with duringCall: true. The flag is in the schema with a description, so the model knows it exists before it ever hits the error.
  4. huddle-2 with duringCall: true succeeds. The host may also show its own confirmation (this tool isn't read-only), so a human sees the change twice: once in the conversation, once in the host's dialog.
  5. board-1: the device API refused the connection. The model got Internal error (ref E1). Don't retry; tell the user the room service failed., with no address in it, and the log line kept ECONNREFUSED 10.0.4.17:443. Without safe(), SDK 1.32.2 would have caught the exception itself and sent its message, address included, to the model.
AdvancedKeep results small

Whatever a tool returns goes into the model's context window. Claude Code's MCP documentation (read 27 October 2026) says it warns when an MCP tool's output passes 10,000 tokens and limits it to 25,000 tokens by default (MAX_MCP_OUTPUT_TOKENS changes the limit). Other hosts have their own rules, and none of them make a huge result useful. Two habits keep results small: page long lists with a cursor, and link big content with a resource_link block instead of pasting it.

small-results.mjs
// Keep results small: page long lists with a cursor, and link big content instead of pasting it.
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 roomIds = Array.from({ length: 23 }, (_, i) => `room-${String(i + 1).padStart(2, "0")}`);
const logLines = Array.from({ length: 5000 }, (_, i) => `2026-10-27T09:${String(i % 60).padStart(2, "0")} audio: frame ${i}`);
const encode = (offset) => Buffer.from(JSON.stringify({ offset })).toString("base64url");
const decode = (cursor) => JSON.parse(Buffer.from(cursor, "base64url").toString()).offset;

const server = new McpServer({ name: "room-tools", version: "0.3.0" });
server.registerTool("list_rooms", {
  description: "Room ids, 10 per page. If nextCursor is set, call again with it to get the next page.",
  inputSchema: { cursor: z.string().optional().describe("nextCursor from the previous page") },
  outputSchema: { roomIds: z.array(z.string()), nextCursor: z.string().optional() },
  annotations: { readOnlyHint: true },
}, async ({ cursor }) => {
  const offset = cursor ? decode(cursor) : 0;
  const page = { roomIds: roomIds.slice(offset, offset + 10) };
  if (offset + 10 < roomIds.length) page.nextCursor = encode(offset + 10);
  return { content: [{ type: "text", text: JSON.stringify(page) }], structuredContent: page };
});
server.registerTool("get_room_logs", {
  description: "The last lines of a room's device log, plus a link to the full log.",
  inputSchema: { roomId: z.string(), lines: z.number().int().min(1).max(50).default(3) },
  annotations: { readOnlyHint: true },
}, async ({ roomId, lines }) => ({
  content: [
    { type: "text", text: logLines.slice(-lines).join("\n") },
    { type: "resource_link", uri: `rooms://${roomId}/logs`, name: `${roomId} full log`, mimeType: "text/plain",
      description: `${logLines.length} lines; read it only if the last lines don't explain the problem` },
  ],
}));

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

let cursor;
do {
  const r = await client.callTool({ name: "list_rooms", arguments: cursor ? { cursor } : {} });
  const { roomIds: ids, nextCursor } = r.structuredContent;
  console.log(`page: ${ids[0]}…${ids.at(-1)} (${ids.length}) nextCursor=${nextCursor ?? "none"}`);
  cursor = nextCursor;
} while (cursor);

const logs = await client.callTool({ name: "get_room_logs", arguments: { roomId: "huddle-2" } });
for (const block of logs.content) console.log(block.type, "→", block.type === "text" ? block.text.split("\n").length + " lines" : block.uri);
console.log("characters returned:", JSON.stringify(logs).length, "instead of", logLines.join("\n").length);
await client.close();
Result
page: room-01…room-10 (10) nextCursor=eyJvZmZzZXQiOjEwfQ
page: room-11…room-20 (10) nextCursor=eyJvZmZzZXQiOjIwfQ
page: room-21…room-23 (3) nextCursor=none
text → 3 lines
resource_link → rooms://huddle-2/logs
characters returned: 336 instead of 173889
What’s happening
  1. list_rooms pages: 23 rooms come back 10 at a time. Each page includes nextCursor while there's more; the description tells the model to call again with it. The cursor is opaque (base64url of {"offset":10}), so you can change its contents later without breaking callers.
  2. The loop follows nextCursor until it's absent: pages room-01…room-10, room-11…room-20, then room-21…room-23 with no cursor. A model does the same, but only if it needs more than the first page, which is the point.
  3. get_room_logs returns two content blocks: the last 3 lines as text, and a resource_link to rooms://huddle-2/logs with a description saying when it's worth reading. Resource links in tool results were added in the 2025-06-18 revision; the host or the model can fetch the full log with resources/read if the server serves that URI as a resource.
  4. 336 characters instead of 173,889. The full log would have filled a large part of a context window with noise. lines has a default of 3 and a maximum of 50, so even a greedy call stays bounded.
AdvancedHow many tools, and how big

There's no protocol limit on the number of tools, but every definition is sent to the model, so the cost is context and choice. Some rules of thumb (judgement, not measurements):

  • Shape tools around tasks, not endpoints. If support engineers ask "is huddle-2 healthy?", one get_room_status that combines three API calls beats three tools the model has to chain correctly.
  • Separate reads from writes. A read tool can be readOnlyHint: true and run without a confirmation; a write tool can't. Mixing them (manage_room with an action argument) forces a confirmation on every read and makes the annotation meaningless.
  • Make dangerous tools narrow. reboot_room(roomId) is reviewable in a confirmation dialog; run_device_command(command) isn't. Least privilege, confirmations and sandboxing goes further.
  • Return structured content when the data has a shape, and keep the text block too: the 2025-11-25 spec says a tool returning structuredContent should also return it serialised in a text block for hosts that only pass text to the model.
Fix this tool

A teammate's tool: name device, description "Device operations", input { action: z.string(), payload: z.string() } where payload is JSON, annotations { readOnlyHint: true }. Actions are status, reboot and set_volume. List what's wrong and how you'd redesign it.

Show answer

Split it into three tools: get_room_status({ roomId }) (read-only), reboot_room({ roomId }) (destructive, no read-only hint) and set_volume({ roomId, level: 0–100, duringCall }), each with a when-to-use description and typed fields.

What’s happening
  1. One tool for three jobs forces the model to learn a mini-language (action strings, a JSON payload format) from a two-word description. It will guess field names inside payload.
  2. payload: z.string() holding JSON means no schema validation and no field descriptions: the SDK sees "a string" and the model sees nothing about what goes in it.
  3. readOnlyHint: true is false for reboot. In VS Code that would skip the confirmation dialog for a call that drops a meeting. That's the most dangerous problem in the list.
  4. The name and description say nothing about when to use it or what it returns.
  5. The redesign gives each tool a precise schema (the SDK validates level <= 100), an honest annotation (the host can auto-run status and confirm reboots) and a description the model can act on. Three small definitions cost less context than one confusing one, because the model doesn't need retries to figure them out.

#Adding resources and prompts

Added

Tools are what the model calls. The other two server primitives cover the other two people in the room. Resources are data the application (and through it, the user) chooses to put in context: runbooks, a room's configuration, recent logs, each at a URI. Prompts are templates the user picks, typically as a slash command, that expand into ready-made opening messages. MCP Fundamentals explains both; this topic adds them to room-tools and shows the SDK calls.

The mental model is a support desk's three shelves. The tool shelf has instruments the assistant picks up by itself (check status, set volume). The reference shelf has documents someone hands it on purpose (the "no audio" runbook, this room's status sheet). The forms shelf has pre-filled request forms a person starts from ("triage a room": fill in the room id, and the form arrives with the status sheet and the runbook already clipped on). Deciding which shelf something belongs on is a design decision: who should decide when it's used?

You want…UseWhy
The model to fetch a room's status when it decides it needs ita tool (get_room_status)the model controls when; tools run in the agent loop
The user to attach "huddle-2's status" or a runbook to a questiona resource (rooms://huddle-2/status, docs://runbooks/no-audio)the app or user controls when; hosts show resources in an attach-context picker
Everyone on the team to start a triage the same good waya prompt (triage_room)the user controls when; it encodes the team's know-how
Both the model and the user to reach the same dataa tool and a resource over the same functionhosts differ in what they support; tools are the most widely supported

How the hosts in this course surface them, from their docs (read 27 October 2026): VS Code lists resources in MCP: Browse Resources and Add Context → MCP Resource, asks for template parameters in a Quick Pick (with your completions), and runs prompts as /mcp.servername.promptname. Claude Code references resources with @server:protocol://resource/path and lists prompts as /servername:promptname (MCP) commands. Not every MCP host supports every primitive, which is the reason for the last row.

resources-and-prompts.mjs
// Adding a runbook resource, a per-room resource template and a triage prompt to room-tools.
import { McpServer, ResourceTemplate } 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": { inCall: true, camera: "on", mic: "muted", firmware: "4.3.1" },
  "board-1": { inCall: false, camera: "off", mic: "unmuted", firmware: "4.2.0" },
};
const runbook = "# No audio\n1. Is the mic muted on the device?\n2. Is the USB speaker connected?\n3. Reboot only if 1 and 2 are fine.";
const statusJson = (roomId) => JSON.stringify({ roomId, ...rooms[roomId] });

const server = new McpServer({ name: "room-tools", version: "0.4.0" });

// 1. A fixed document at a fixed URI.
server.registerResource("no-audio-runbook", "docs://runbooks/no-audio",
  { title: "Runbook: no audio", description: "Support's checklist when a room has no audio", mimeType: "text/markdown" },
  async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: runbook }] }));

// 2. One URI per room, listed and auto-completed from the data.
server.registerResource("room-status",
  new ResourceTemplate("rooms://{roomId}/status", {
    list: async () => ({ resources: Object.keys(rooms).map((id) => ({ uri: `rooms://${id}/status`, name: `${id} status` })) }),
    complete: { roomId: (value) => Object.keys(rooms).filter((id) => id.startsWith(value)) },
  }),
  { title: "Room status", mimeType: "application/json" },
  async (uri, { roomId }) => {
    if (!rooms[roomId]) throw new Error(`No room called ${roomId}`);
    return { contents: [{ uri: uri.href, mimeType: "application/json", text: statusJson(roomId) }] };
  });

// 3. A prompt the user picks: it packs the status and the runbook into the opening message.
server.registerPrompt("triage_room", {
  title: "Triage a room",
  description: "Start a troubleshooting conversation about one room",
  argsSchema: {
    roomId: completable(z.string(), (value) => Object.keys(rooms).filter((id) => id.startsWith(value))),
    symptom: z.string().optional(),
  },
}, async ({ roomId, symptom }) => ({
  messages: [
    { role: "user", content: { type: "text", text: `Help me troubleshoot ${roomId}. Symptom: ${symptom ?? "not given"}. Follow the runbook; don't reboot without asking me.` } },
    { role: "user", content: { type: "resource", resource: { uri: `rooms://${roomId}/status`, mimeType: "application/json", text: statusJson(roomId) } } },
    { role: "user", content: { type: "resource", resource: { uri: "docs://runbooks/no-audio", mimeType: "text/markdown", text: runbook } } },
  ],
}));

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

console.log("capabilities:", JSON.stringify(client.getServerCapabilities()));
console.log("resources/list:", (await client.listResources()).resources.map((r) => r.uri).join(", "));
console.log("templates/list:", (await client.listResourceTemplates()).resourceTemplates.map((t) => t.uriTemplate).join(", "));
const read = await client.readResource({ uri: "rooms://huddle-2/status" });
console.log("read huddle-2:", read.contents[0].text);
const done = await client.complete({ ref: { type: "ref/prompt", name: "triage_room" }, argument: { name: "roomId", value: "hu" } });
console.log("complete 'hu':", JSON.stringify(done.completion.values));
const prompt = await client.getPrompt({ name: "triage_room", arguments: { roomId: "huddle-2", symptom: "no audio" } });
for (const m of prompt.messages) console.log(`prompt ${m.role}/${m.content.type}:`, m.content.text ?? m.content.resource.uri);
await client.close();
Result
capabilities: {"completions":{},"prompts":{"listChanged":true},"resources":{"listChanged":true}}
resources/list: docs://runbooks/no-audio, rooms://huddle-2/status, rooms://board-1/status
templates/list: rooms://{roomId}/status
read huddle-2: {"roomId":"huddle-2","inCall":true,"camera":"on","mic":"muted","firmware":"4.3.1"}
complete 'hu': ["huddle-2"]
prompt user/text: Help me troubleshoot huddle-2. Symptom: no audio. Follow the runbook; don't reboot without asking me.
prompt user/resource: rooms://huddle-2/status
prompt user/resource: docs://runbooks/no-audio
What’s happening
  1. registerResource(name, uri, metadata, read) with a fixed URI registers one document. The read callback gets the URI as a URL and returns contents: each item has the uri, a mimeType and either text or base64 blob.
  2. registerResource(name, new ResourceTemplate(...), metadata, read) registers a family. The template rooms://{roomId}/status is RFC 6570; list makes each concrete room appear in resources/list (that's why huddle-2 and board-1 are listed after the runbook), and complete answers auto-completion for roomId. The read callback receives the parsed variables, { roomId }. The constructor requires you to pass list, even as undefined, so you decide deliberately whether a template's members are listed.
  3. registerPrompt(name, { argsSchema }, callback) takes zod for arguments too, but prompt arguments are strings on the wire, so keep them z.string(). Wrapping one in completable(schema, fn) makes the SDK answer completion/complete for it: typing hu gave ["huddle-2"]. The callback returns messages; here the text instruction plus two embedded resources (the room's status and the runbook), so the conversation starts with the facts already in it.
  4. Capabilities followed the registrations: resources, prompts and completions appeared because you registered a resource, a prompt and a completable argument. There's no tools capability in this model because it registers no tool; in room-tools they all sit in the same createServer.
  5. What this demonstrates: the three primitives are three register* calls on the same server object, sharing the same data functions. The design work is choosing who triggers each, not the code.
AdvancedChanging what you offer at runtime

Registrations aren't fixed at startup. Each register* call returns a handle with enable(), disable(), update() and remove(), and the SDK tells connected clients that the list changed. A good use: switch off a risky tool while something else makes it unsafe.

list-changed.mjs
// Tools can appear and disappear at runtime; the client is told and lists 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 { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

const server = new McpServer({ name: "room-tools", version: "0.4.0" });
server.registerTool("get_room_status", { description: "Live status of one room", inputSchema: { roomId: z.string() } },
  async ({ roomId }) => ({ content: [{ type: "text", text: `${roomId}: idle` }] }));
const reboot = server.registerTool("reboot_room", {
  description: "Reboot a room's device. Drops any call in progress. Ask the user first.",
  inputSchema: { roomId: z.string() },
  annotations: { destructiveHint: true },
}, async ({ roomId }) => ({ content: [{ type: "text", text: `${roomId} rebooting` }] }));

const client = new Client({ name: "host", version: "1.0.0" });
const names = async () => (await client.listTools()).tools.map((t) => t.name).join(", ");
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => console.log("  ← notifications/tools/list_changed; host re-lists:", await names()));
const [c, s] = InMemoryTransport.createLinkedPair();
await server.connect(s);
await client.connect(c);

console.log("at start:", await names());
console.log("firmware rollout begins: reboot_room.disable()");
reboot.disable();
await new Promise((r) => setTimeout(r, 10)); // let the notification arrive
const r = await client.callTool({ name: "reboot_room", arguments: { roomId: "huddle-2" } });
console.log("calling it anyway:", r.isError, r.content[0].text);
console.log("rollout done: reboot_room.enable()");
reboot.enable();
await new Promise((r) => setTimeout(r, 10));
await client.close();
Result
at start: get_room_status, reboot_room
firmware rollout begins: reboot_room.disable()
  ← notifications/tools/list_changed; host re-lists: get_room_status
calling it anyway: true MCP error -32602: Tool reboot_room disabled
rollout done: reboot_room.enable()
  ← notifications/tools/list_changed; host re-lists: get_room_status, reboot_room
What’s happening
  1. registerTool returned reboot, a RegisteredTool handle. At start both tools are listed.
  2. reboot.disable() hides the tool and makes the SDK send notifications/tools/list_changed (the server declared tools.listChanged: true in its capabilities). The client's handler for that notification calls tools/list again and now sees only get_room_status. Hosts that support list changes refresh their tool list the same way; VS Code's MCP guide says it supports tools registered at runtime.
  3. A call to the disabled tool anyway (from a model that remembered it) gets MCP error -32602: Tool reboot_room disabled as an isError result.
  4. reboot.enable() restores it, with another notification. The same mechanism exists for prompts and resources (sendPromptListChanged, sendResourceListChanged).
  5. One caution for later: the 2026-07-28 revision says tools/list must not vary per connection or as a side effect of earlier requests (it may vary with the caller's authorization), and list-change notifications arrive on a subscriptions/listen stream there. A global switch like this one is fine; "this tool appears after you call that tool" isn't. That rule wasn't run here; SDK 1.32.2 speaks 2025-11-25.
Which primitive?

For each, pick tool, resource or prompt: (a) "Show the last hour of huddle-2's device log so I can attach it to my question." (b) "Reboot board-1." (c) "Start our standard post-incident write-up for room X." (d) "Is any room still on firmware older than 4.3?"

Show answer

(a) Resource (a template such as rooms://{roomId}/logs, possibly also a read tool). (b) Tool, destructive, with confirmation. (c) Prompt with a roomId argument. (d) Tool (find_rooms with firmwareBelow).

What’s happening
  1. (a) The user wants to attach data on purpose: that's application-controlled context, a resource. If the model should also fetch logs by itself while troubleshooting, add a read-only tool that calls the same function.
  2. (b) An action with side effects that the model may decide to take after diagnosing: a tool, annotated destructive so hosts confirm it.
  3. (c) The user picks a ready-made template that encodes the team's format: a prompt. It can embed the room's status as a resource in the first message.
  4. (d) A query the model decides to run as part of answering, with parameters: a tool, like find_rooms in Designing good tools.
  5. The test each time is "who decides when this is used?": the model (tool), the app or user attaching context (resource), or the user starting a workflow (prompt).

#Testing a server

Added

An MCP server has three things worth testing, and each has its own cheapest tool. Behaviour: given these arguments and this data, does the tool return the right result or the right error? Test that in-process: connect a real SDK Client to your createServer() through the SDK's InMemoryTransport, so every test speaks genuine MCP (handshake, tools/list, validation, error wrapping) in a few milliseconds, with fake data injected. Startup: does the real entry point start, keep stdout clean and answer over stdio? Test that with one end-to-end test that spawns the process the way a host does. The surface the model sees: did someone change a name, a description or a schema? Snapshot the tool list so that change shows up in review. On top of those, the MCP Inspector is how you poke at a server by hand, and its CLI mode makes a quick smoke test for CI.

The mental model is testing a shop through its front doors. The shop (createServer) has two doors: a staff door (the in-memory transport) your tests walk through hundreds of times a day, and the street door (src/stdio.mjs over stdio) that customers use, which you check once per build to be sure it opens. You don't test the shop by reading its floor plan (calling handlers directly): you walk in and ask, because the SDK's validation and error handling are part of what customers get.

Protocol tests node --test many, milliseconds Snapshot test the tool surface the model sees e2e test one, spawns the process Inspector CLI smoke, web UI by hand Hosts VS Code, Claude via config files InMemoryTransport staff door: same process, no stdio src/stdio.mjs + stdio street door: a child process createServer({ rooms }) tools, resources, prompts rooms: fake in tests, real in production
One server, two doors. Most tests use the in-memory door with fake data; one test, the Inspector and real hosts use the stdio door.

The protocol tests for the room-tools project from the first topic, using Node's built-in test runner:

test/server.test.mjs
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../src/server.mjs";
import { createFakeRooms } from "../src/rooms.mjs";

describe("room-tools over MCP", () => {
  let client;
  before(async () => {
    const server = createServer({ rooms: createFakeRooms() });
    const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
    await server.connect(serverSide);
    client = new Client({ name: "test", version: "0.0.0" });
    await client.connect(clientSide);
  });
  after(() => client.close());

  it("lists exactly the tools we mean to publish", async () => {
    const { tools } = await client.listTools();
    assert.deepEqual(tools.map((t) => t.name).sort(), ["get_room_status", "list_rooms"]);
    for (const t of tools) assert.ok(t.description.length > 40, `${t.name} needs a real description`);
  });

  it("returns structured status for a known room", async () => {
    const r = await client.callTool({ name: "get_room_status", arguments: { roomId: "huddle-2" } });
    assert.equal(r.isError, undefined);
    assert.deepEqual(r.structuredContent, { roomId: "huddle-2", inCall: true, camera: "on", mic: "unmuted", firmware: "4.3.1" });
  });

  it("answers an unknown room with a tool error that names the valid ids", async () => {
    const r = await client.callTool({ name: "get_room_status", arguments: { roomId: "lobby" } });
    assert.equal(r.isError, true);
    assert.match(r.content[0].text, /Known rooms: huddle-2, board-1/);
  });

  it("rejects a missing argument before the handler runs", async () => {
    const r = await client.callTool({ name: "get_room_status", arguments: {} });
    assert.equal(r.isError, true);
    assert.match(r.content[0].text, /Input validation error/);
  });
});
What’s happening
  1. before builds a fresh server with fake rooms and links it to a client with InMemoryTransport.createLinkedPair(), which returns two connected transports: whatever one side sends, the other receives. client.connect() runs the real initialize handshake over it.
  2. "lists exactly the tools we mean to publish" pins the set of names, so adding a tool is a deliberate test change, and checks that every description has some substance (a cheap guard against description: "TODO").
  3. "returns structured status for a known room" asserts on structuredContent, the typed data, rather than parsing the text block. isError is undefined on success, not false, so the test says exactly that.
  4. "answers an unknown room…" asserts that the error is a result with isError: true and that its message tells the model the valid ids: the error's wording is behaviour, so it's tested.
  5. "rejects a missing argument…" proves the SDK's input validation is in front of the handler, without the handler doing anything.
AdvancedRunning the tests with npm test

Running the tests

npm test runs node --test, which finds the test files under test/ by itself. This is the real run of the project with all three test files (the e2e and snapshot tests are further down). The snapshot test needs its snapshot file first: run node --test --test-update-snapshots test/tools.test.mjs once ("Snapshot the tool surface" below explains it). Without that file, tool surface fails with Error [ERR_INVALID_STATE]: Invalid state: Cannot read snapshot file …, Node's hint to rerun with --test-update-snapshots, and npm test exits 1.

npm test (run 27 October 2026; timings vary from run to run)
$ npm test

> room-tools@1.0.0 test
> node --test

▶ room-tools over MCP
  ✔ lists exactly the tools we mean to publish (8.809042ms)
  ✔ returns structured status for a known room (1.937834ms)
  ✔ answers an unknown room with a tool error that names the valid ids (0.361208ms)
  ✔ rejects a missing argument before the handler runs (0.542875ms)
✔ room-tools over MCP (23.439083ms)
✔ starts as a host would start it, and answers over stdio (172.330292ms)
✔ tool surface (21.4785ms)
ℹ tests 6
ℹ suites 1
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 326.912875
What’s happening
  1. The four in-memory tests ran inside the room-tools over MCP suite in a few milliseconds each, including the handshake in before.
  2. starts as a host would start it… is the end-to-end test: about 170 ms, because it spawns a real Node process.
  3. tool surface is the snapshot test. All 6 passed, so npm test exited 0; one failure would make it exit 1 and fail CI.
AdvancedThe same tests as a runnable model

The page can't run a project folder, so this model is the same idea in one file: a trimmed createServer, the fake data, and a test suite, including one test that fails on purpose so you can see what a failure reports. The last few lines aren't part of a normal test file: they run the suite through node:test's run() API and print results without timings, so the output is stable.

server-tests.mjs
// room-tools' protocol tests in one file. In the project they live in test/server.test.mjs and run with `node --test`.
import { describe, it, before, after, run } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
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";

// ---- src/server.mjs (trimmed): the factory under test ----
function createServer({ rooms }) {
  const server = new McpServer({ name: "room-tools", version: "0.2.0" });
  server.registerTool("get_room_status", {
    description: "Live status of one meeting-room device: in a call or not, camera, mic and firmware version.",
    inputSchema: { roomId: z.string() },
    outputSchema: { roomId: z.string(), inCall: z.boolean(), firmware: z.string() },
    annotations: { readOnlyHint: true },
  }, async ({ roomId }) => {
    const room = await rooms.get(roomId);
    if (!room) return { isError: true, content: [{ type: "text", text: `No room called ${roomId}. Known rooms: ${(await rooms.list()).join(", ")}.` }] };
    return { content: [{ type: "text", text: JSON.stringify(room) }], structuredContent: room };
  });
  return server;
}
const fakeRooms = (data) => ({ list: async () => Object.keys(data), get: async (id) => (data[id] ? { roomId: id, ...data[id] } : undefined) });

// ---- test/server.test.mjs ----
async function connect(rooms) {
  const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
  await createServer({ rooms }).connect(serverSide);
  const client = new Client({ name: "test", version: "0.0.0" });
  await client.connect(clientSide);
  return client;
}

if (process.env.NODE_TEST_CONTEXT) describe("room-tools over MCP", () => {
  let client;
  before(async () => (client = await connect(fakeRooms({ "huddle-2": { inCall: true, firmware: "4.3.1" } }))));
  after(() => client.close());

  it("publishes get_room_status as read-only", async () => {
    const [tool] = (await client.listTools()).tools;
    assert.equal(tool.name, "get_room_status");
    assert.equal(tool.annotations.readOnlyHint, true);
  });
  it("returns structured status", async () => {
    const r = await client.callTool({ name: "get_room_status", arguments: { roomId: "huddle-2" } });
    assert.deepEqual(r.structuredContent, { roomId: "huddle-2", inCall: true, firmware: "4.3.1" });
  });
  it("tells the model which rooms exist", async () => {
    const r = await client.callTool({ name: "get_room_status", arguments: { roomId: "lobby" } });
    assert.equal(r.isError, true);
    assert.match(r.content[0].text, /Known rooms: huddle-2/);
  });
  it("catches a bad shape from the data layer", async () => {
    const broken = await connect(fakeRooms({ "huddle-2": { inCall: "yes", firmware: "4.3.1" } }));
    const r = await broken.callTool({ name: "get_room_status", arguments: { roomId: "huddle-2" } });
    assert.equal(r.isError, true, "outputSchema should catch inCall: 'yes'");
    await broken.close();
  });
  it("deliberately failing: board-1 is not in this fake", async () => {
    const r = await client.callTool({ name: "get_room_status", arguments: { roomId: "board-1" } });
    assert.equal(r.isError, undefined, r.content[0].text);
  });
});
else {
  // Not part of a real test file: print node:test's results without its timings, so this page's output is stable.
  for await (const { type, data } of run({ files: [fileURLToPath(import.meta.url)] })) {
    if (type === "test:pass" && data.details.type !== "suite") console.log(`✔ ${data.name}`);
    if (type === "test:fail" && data.details.type !== "suite") console.log(`✖ ${data.name}\n    ${data.details.error.cause.message.split("\n")[0]}`);
  }
}
Result
✔ publishes get_room_status as read-only
✔ returns structured status
✔ tells the model which rooms exist
✔ catches a bad shape from the data layer
✖ deliberately failing: board-1 is not in this fake
    No room called board-1. Known rooms: huddle-2.
What’s happening
  1. connect(rooms) is the test helper worth copying: build the server with whatever data a test needs, link it in memory, return a connected client. Each test that needs different data (the broken shape) gets its own server.
  2. "publishes get_room_status as read-only" tests the annotation. Hosts use readOnlyHint to skip confirmations, so flipping it is a behaviour change and deserves a failing test.
  3. "catches a bad shape from the data layer" feeds inCall: "yes" through the fake. The handler passes it on, the SDK checks the result against outputSchema (inCall: z.boolean()), and the client gets isError: true instead of bad typed data. The output schema doubles as a contract test on your data layer.
  4. "deliberately failing" asks for board-1, which this fake doesn't have. The assertion's third argument, r.content[0].text, becomes the failure message, so the report shows the tool's own error (No room called board-1. Known rooms: huddle-2.): pass the result's text as the message and failures explain themselves.
  5. The runner part: run({ files: [this file] }) starts the file again as a child test process; in that child NODE_TEST_CONTEXT is set, so the describe block runs there, and the parent prints one line per test from the test:pass and test:fail events. The parent still exits 0 here, which is fine for a page model; in a real project you'd run node --test and let it fail the build.
AdvancedOne end-to-end test over stdio

In-memory tests skip the one thing a host depends on: the real entry point, launched as a process, with stdout carrying nothing but protocol. One test covers it.

test/stdio.test.mjs
import { it } from "node:test";
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const entry = fileURLToPath(new URL("../src/stdio.mjs", import.meta.url));

it("starts as a host would start it, and answers over stdio", async () => {
  const transport = new StdioClientTransport({ command: process.execPath, args: [entry], stderr: "pipe" });
  let stderr = "";
  transport.stderr.on("data", (d) => (stderr += d));
  const client = new Client({ name: "e2e", version: "0.0.0" });
  await client.connect(transport);
  let result;
  try {
    result = await client.callTool({ name: "list_rooms", arguments: {} });
  } finally {
    await client.close(); // ends stdin; waits for the process to exit
  }
  assert.deepEqual(result.structuredContent, { roomIds: ["huddle-2", "board-1"] });
  assert.match(stderr, /ready on stdio/); // the log went to stderr, not into the protocol
});
What’s happening
  1. entry resolves src/stdio.mjs relative to the test file, so the test works from any working directory.
  2. StdioClientTransport spawns node src/stdio.mjs exactly as a host would, with stderr piped and collected.
  3. The call goes through the real process: the fake rooms in src/stdio.mjs answer, and structuredContent comes back as { roomIds: ["huddle-2", "board-1"] }.
  4. client.close() runs in finally, so a failed call doesn't leave a server process behind; it ends stdin and waits for the process to exit, which also guarantees all of stderr has arrived before the ready on stdio assertion. (An earlier version asserted on stderr before closing and only passed by luck of timing.)
  5. In the npm test run above it took about 170 ms, versus a few milliseconds per in-memory test, which is why there's one of these and many of the others.
AdvancedSnapshot the tool surface

The tool list is the model's whole view of the server, and it changes silently in refactors ("tidied the descriptions"). Node 26's test runner has snapshot assertions built in (t.assert.snapshot, with --test-update-snapshots to write or refresh the snapshot file), which turns any change into a reviewable diff.

test/tools.test.mjs
import { test } from "node:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../src/server.mjs";
import { createFakeRooms } from "../src/rooms.mjs";

// The tool list is everything the model knows about this server. Changing it should be a reviewed decision.
test("tool surface", async (t) => {
  const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
  await createServer({ rooms: createFakeRooms() }).connect(serverSide);
  const client = new Client({ name: "snapshot", version: "0.0.0" });
  await client.connect(clientSide);
  const { tools } = await client.listTools();
  await client.close();
  t.assert.snapshot(tools);
});
What’s happening
  1. The test lists the tools over MCP (in memory) and passes the whole array to t.assert.snapshot.
  2. The first run, node --test --test-update-snapshots test/tools.test.mjs, wrote test/tools.test.mjs.snapshot with every name, title, description, schema and annotation as JSON. That file is committed with the code.
  3. As a check, the field description "Room id, for example huddle-2" was changed to "The id" in src/server.mjs and the test run again. It failed with AssertionError [ERR_ASSERTION]: Expected values to be strictly equal, and the diff showed exactly + "description": "The id" against - "description": "Room id, for example huddle-2". The change was then reverted.
  4. If a change is intended, rerun with --test-update-snapshots and the snapshot diff goes into the pull request, where a reviewer sees what the model will see.
AdvancedMCP Inspector, smoke test and CI

The MCP Inspector

The MCP Inspector is the protocol project's own developer tool for poking at a server: connect, list tools, resources and prompts, call them with arguments you type, and watch the messages. Version 2.12.0 (npm, published 21 October 2026) is one package, @modelcontextprotocol/inspector, with several front ends according to its README: a web UI (the default: npx @modelcontextprotocol/inspector), a CLI (--cli) for scripts and CI, and a TUI (--tui) in the terminal. It needs Node 22.19 or newer. Version 2 is built on SDK v2 packages and, by its CLI help, can negotiate either protocol era: --protocol-era legacy (the default for ad-hoc runs), auto or modern.

Use 2.12.0 or later. On 21 October 2026 the Inspector project published security advisories against older versions (GHSA-vcc9-3xgm-p9v4 and GHSA-wmjf-2m46-2352, an incomplete fix for CVE-2025-49596; GHSA-4fc4-wcmh-4g86, remote code execution through an OAuth callback, rated 9.6; and others), fixed in 2.12.0 for the 2.x line and 1.0.3 for 1.x, per the advisories' affected ranges. Never expose the web UI beyond localhost, and never set DANGEROUSLY_OMIT_AUTH, which switches its API token off; Remote servers over Streamable HTTP explains the attack behind these bugs.

The CLI was run headless against server.mjs from the first topic, with the Inspector installed as a dev dependency (npm install -D @modelcontextprotocol/inspector@2.12.0). A second run with HOME pointed at an empty scratch folder showed that these one-off runs write nothing under ~/.mcp-inspector (the CLI README says its catalog file is only used when you don't name a server command):

Inspector CLI (run 27 October 2026)
$ npx mcp-inspector --cli node server.mjs --method tools/list
room-tools 0.1.0 ready on stdio
{
  "tools": [
    {
      "name": "get_room_status",
      "title": "Get room status",
      "description": "Live status of one meeting-room device: in a call or not, camera, mic and firmware version. Read-only. Room ids look like huddle-2 or board-1.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "roomId": {
            "type": "string",
            "description": "Room id, for example huddle-2"
          }
        },
        "required": [
          "roomId"
        ],
        "$schema": "http://json-schema.org/draft-07/schema#"
      },
      "annotations": {
        "readOnlyHint": true
      },
      "execution": {
        "taskSupport": "forbidden"
      }
    }
  ]
}
$ npx mcp-inspector --cli node server.mjs --method tools/call --tool-name get_room_status --tool-arg roomId=lobby
room-tools 0.1.0 ready on stdio
{
  "content": [
    {
      "type": "text",
      "text": "No room called lobby. Known rooms: huddle-2, board-1."
    }
  ],
  "isError": true
}
{"error":{"code":"tool_is_error","message":"Tool 'get_room_status' returned isError:true."}}
$ echo $?
5
What’s happening
  1. --cli node server.mjs --method tools/list: everything after the options that isn't a flag is the server's command, so the Inspector spawned node server.mjs over stdio, printed the server's stderr line, and pretty-printed the tools/list result: the same JSON Schema and annotations the SDK generated, plus execution.taskSupport: "forbidden" (the SDK's marker that this tool can't run as a 2025-11-25 experimental task).
  2. --method tools/call --tool-name get_room_status --tool-arg roomId=lobby called the tool with a key=value argument (--tool-args-json '{"roomId":"lobby"}' takes JSON instead). The result is the server's isError: true message.
  3. Because the result had isError: true, the CLI also printed a one-line error envelope (tool_is_error) on stderr and exited with status 5. The Inspector's CLI README (2.12.0) documents the exit codes: 0 success, 1 usage or unexpected error, 2 no MCP App on the tool (--app-info), 3 authentication required, 4 server unreachable, 5 tool error (isError: true or tool not found), 6 a --strict schema-portability error, and 7 to 9 for its skills checks (--verify).
  4. Two more runs, not shown in full: --protocol-era modern failed against this 1.32.2 server with Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) (exit 1), and --protocol-era auto fell back and worked. That's the two-era situation from MCP Fundamentals, seen from a real 2026 client.
  5. The web UI wasn't run for this page. Its README says it serves on 127.0.0.1:6274, protects the page with a per-launch API token, and proxies the connection to your server; you'd start it with npx @modelcontextprotocol/inspector node src/stdio.mjs and click through Tools, Resources and Prompts. Its README also warns that it stores secrets (OAuth tokens and stdio env values) in the OS keychain, or in ~/.mcp-inspector/secrets.json where there is none.

Those exit codes make a smoke test that runs in seconds. It uses jq to check the JSON (macOS 26 ships it as /usr/bin/jq; on other systems install it first). This script was run against the project (npm run smoke printed smoke: ok), and then a copy expecting firmware 4.4.0 was run to check that it fails (exit 1):

scripts/smoke.sh
#!/bin/sh
# Smoke test: start the real entry point over stdio, check the tool list and one call.
set -eu
inspect() { npx --no-install mcp-inspector --cli -q --format json node src/stdio.mjs "$@"; }

inspect --method tools/list | jq -e '[.result.tools[].name] | sort == ["get_room_status","list_rooms"]' >/dev/null
inspect --method tools/call --tool-name get_room_status --tool-arg roomId=huddle-2 \
  | jq -e '.result.structuredContent.firmware == "4.3.1"' >/dev/null

status=0
inspect --method tools/call --tool-name get_room_status --tool-arg roomId=lobby >/dev/null 2>&1 || status=$?
[ "$status" -eq 5 ] || { echo "expected exit 5 (tool error) for an unknown room, got $status"; exit 1; }
echo "smoke: ok"
What’s happening
  1. inspect wraps the CLI with -q (no banners or server stderr) and --format json (one JSON object on stdout), running the project's own entry point, node src/stdio.mjs.
  2. The first check asserts the exact tool names with jq -e, which exits non-zero when the expression is false, so set -e stops the script.
  3. The second calls get_room_status for huddle-2 and checks a field of structuredContent.
  4. The third checks a failure: an unknown room must exit 5 (tool error), not 0 and not 4 (unreachable). It captures the status with || status=$? so set -e doesn't end the script first.
  5. What this adds over the unit tests: it exercises the published entry point through an independent client implementation (SDK v2 inside the Inspector), which catches things like a stray stdout write or a broken bin path.

And a CI workflow that runs both. It was checked with actionlint, not run on GitHub; jq is in GitHub's Ubuntu 24.04 runner image (its readme for image 20261023):

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: npm
      - run: npm ci
      - name: Protocol and stdio tests
        run: npm test
      - name: Smoke test with the MCP Inspector CLI
        run: npm run smoke
What’s happening
  1. It runs on pushes to main and on pull requests, with read-only repository permissions and a 10-minute limit.
  2. setup-node installs Node 24 (the Inspector needs 22.19 or newer) and caches npm; npm ci installs exactly what package-lock.json pins, including the Inspector dev dependency.
  3. npm test runs the in-memory, e2e and snapshot tests; a changed tool surface fails here until the snapshot is updated in the same pull request.
  4. npm run smoke runs the Inspector checks against node src/stdio.mjs. They need no Inspector configuration: a run that names the server command doesn't read the Inspector's catalog file.
Why did the e2e test pass?

The first version of the stdio test asserted assert.match(stderr, /ready on stdio/) right after callTool returned, before client.close(). It passed every time on a laptop. Why is it still wrong, and why does moving the assertion after close() fix it?

Show answer

It depends on timing between two pipes: stdout (the protocol) and stderr (logs) are separate streams, so the tool result can arrive before the ready line has been read from stderr. Waiting for close() fixes it because close resolves only after the child process has exited and its streams have closed.

What’s happening
  1. src/stdio.mjs writes ready on stdio to stderr right after connect(). The handshake and the tool call travel over stdin and stdout.
  2. The parent reads the two pipes independently. Nothing orders "stderr data arrived" before "the callTool promise resolved"; on a fast idle machine it happens to, on a busy CI runner it may not.
  3. StdioClientTransport.close() ends the child's stdin and waits for the process's close event (forcing it with SIGTERM after 2 s if needed). Node emits close after the child's stdio streams have closed, so by then every stderr byte has been delivered.
  4. General rule: when a test asserts on output from another process, wait for the process to finish (or for an explicit signal) instead of relying on order across streams.

#Connecting a server to a host

Added

A host finds local MCP servers in a config file, and every format says the same three things: a name for the server, the command to start it with its arguments, and optional environment variables (where secrets go for stdio servers). At startup, or the first time a chat needs tools, the host reads the file, launches each server as a child process, runs the handshake, fetches the tool list and shows the tools to you and the model. There is no registration, no port and no install step in the protocol itself; if the command runs and speaks MCP on stdio, the host can use it.

The mental model is a speed-dial list. Each entry is a name and the exact number to call (command, args, env). The host dials every number when it starts; if one doesn't answer you get an error next to that name and the others still work. Most connection problems are wrong-number problems: a relative path, a node that isn't on the host's PATH, a missing environment variable, a file that moved.

Config file Host + client Server process 1 read command, args, env 2 trust / approval workspace trust, approve .mcp.json 3 spawn(command, args, env, cwd) 4 initialize capabilities, serverInfo 5 notifications/initialized 6 tools/list names, descriptions, schemas 7 tools in the picker and in the model's context 8 confirm? then tools/call stderr → host log on exit: close stdin, then signal the process
A host starting one stdio server. Steps 4 to 6 are the 2025-11-25 handshake that SDK 1.32.2 speaks; a host on the 2026-07-28 revision skips the handshake and may send server/discover instead.

The three hosts this course uses, side by side (each from its own documentation, read 27 October 2026; details and sources below):

VS CodeClaude DesktopClaude Code
File.vscode/mcp.json or user mcp.json; portable .mcp.jsonclaude_desktop_config.json.mcp.json (project); ~/.claude.json (local, user)
Top-level keyservers (portable: mcpServers)mcpServersmcpServers
Secrets${input:id} prompts, envFileliteral env values${VAR} expansion from your environment
Applies changesrestarts changed servers (autostart), or MCP: List Serversfull restart of the app/mcp reconnect, or a new session
LogsOutput view per server~/Library/Logs/Claude/mcp-server-<name>.log/mcp status, claude mcp get

VS Code with GitHub Copilot. VS Code's own format is .vscode/mcp.json in the workspace (or mcp.json in your user profile, opened with MCP: Open User Configuration), with servers under a top-level servers key. This is from VS Code's "MCP configuration reference" (updated 21 October 2026, read 27 October 2026); the file below wasn't loaded into VS Code for this page, but it was checked to be valid JSON and its command and args were run by hand.

.vscode/mcp.json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "rooms-api-token",
      "description": "Rooms API token",
      "password": true
    }
  ],
  "servers": {
    "roomTools": {
      "type": "stdio",
      "command": "node",
      "args": ["${workspaceFolder}/src/stdio.mjs"],
      "env": {
        "ROOMS_API_URL": "https://rooms.example.test",
        "ROOMS_API_TOKEN": "${input:rooms-api-token}"
      },
      "dev": {
        "watch": "src/**/*.mjs",
        "debug": { "type": "node" }
      }
    }
  }
}
What’s happening
  1. servers.roomTools is the name shown in VS Code; the reference suggests camelCase names without spaces or special characters. type: "stdio" and command are required; command must be on the PATH or a full path.
  2. args uses the predefined variable ${workspaceFolder}, so the path is absolute without hard-coding your home directory. (cwd is optional and defaults to the workspace folder.)
  3. inputs declares a promptString with password: true. Referencing it as ${input:rooms-api-token} in env makes VS Code ask for the value the first time the server starts and store it securely, so the token is never written in the file. Plain values like ROOMS_API_URL can sit in env directly, and envFile can load a .env file instead.
  4. dev is VS Code's development mode: watch restarts the server when files matching the glob change, and debug: { "type": "node" } lets you attach the Node debugger to a stdio server. Useful while building; remove it when you share the file.
  5. What happens on start: workspace .vscode/mcp.json servers inherit Workspace Trust (a restricted-mode workspace won't start them), the chat.mcp.autostart setting (default newAndOutdated) starts new or changed servers when you send a chat message, and MCP: List Servers → Show Output shows the server's log. Agent mode then lists its tools in the tools picker and confirms each call to a tool not marked read-only.
AdvancedVS Code's portable format

VS Code also reads a portable format: .mcp.json at the workspace root (and ~/.copilot/mcp-config.json for the user), with servers under mcpServers, which Copilot's agent harness reads directly. The same reference says the portable files don't support ${input:…} variables, envFile or dev (and the workspace .mcp.json has no cwd, though the user file does), use ${NAME} or ${NAME:-default} environment-variable references instead, and that servers needing interactive inputs aren't forwarded to Copilot harness sessions. GitHub Copilot covers how agent mode and the cloud agent use these servers.

AdvancedClaude Desktop: claude_desktop_config.json

Claude Desktop

The file is claude_desktop_config.json, opened from Settings → Developer → Edit Config, at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS or %APPDATA%\Claude\claude_desktop_config.json on Windows, with servers under mcpServers ("Connect to local MCP servers" on modelcontextprotocol.io, read 27 October 2026). Not loaded into Claude Desktop for this page; checked to be valid JSON:

claude_desktop_config.json
{
  "mcpServers": {
    "room-tools": {
      "command": "/usr/local/bin/node",
      "args": ["/Users/you/code/room-tools/src/stdio.mjs"],
      "env": {
        "ROOMS_API_URL": "https://rooms.example.test"
      }
    }
  }
}
What’s happening
  1. Absolute paths everywhere. The guide's troubleshooting list says paths in this file must be absolute, not relative: Claude Desktop isn't started from your project folder. An absolute path to node also avoids depending on the PATH the app gets: a GUI app isn't started from your shell, so it may not see what your shell profile adds (such as a version manager's node). The guide doesn't spell out Claude Desktop's PATH, and that wasn't checked here; run which node in your terminal to find the path to use.
  2. No type field: entries with command are local stdio servers.
  3. env passes configuration and secrets to the server process. Anything not listed shouldn't be assumed to reach it (the model below shows what the SDK's own client passes by default).
  4. Restart to apply. The guide says to quit Claude Desktop completely and start it again after editing; the server's tools then appear under the "Add files, connectors, and more" menu in the chat box.
  5. Logs: on macOS ~/Library/Logs/Claude/mcp.log has connection events and mcp-server-room-tools.log has the server's stderr (on Windows, %APPDATA%\Claude\logs). tail -n 20 -f ~/Library/Logs/Claude/mcp*.log is the first thing to run when a server doesn't show up.
AdvancedClaude Code: .mcp.json and claude mcp add

Claude Code

Servers are added with claude mcp add and stored per scope: local (the default, private to you, for this project, in ~/.claude.json), project (a .mcp.json at the project root, meant to be committed) or user (all your projects, in ~/.claude.json), per Claude Code's MCP docs (read 27 October 2026). A project .mcp.json for room-tools, checked to be valid JSON; it wasn't used in a Claude Code session, and its entry was only health-checked (below):

.mcp.json
{
  "mcpServers": {
    "room-tools": {
      "command": "node",
      "args": ["${CLAUDE_PROJECT_DIR:-.}/src/stdio.mjs"],
      "env": {
        "ROOMS_API_URL": "${ROOMS_API_URL:-https://rooms.example.test}",
        "ROOMS_API_TOKEN": "${ROOMS_API_TOKEN}"
      }
    }
  }
}
What’s happening
  1. mcpServers.room-tools has the same command, args and env shape as Claude Desktop's file. The docs say Claude Code reads an entry with no type as a stdio server (their own examples also write "type": "stdio").
  2. ${VAR} and ${VAR:-default} are expanded from your environment in command, args, env, url and headers, so the token stays out of the committed file and each developer sets ROOMS_API_TOKEN locally. An unset variable without a default is left as literal text and flagged as a warning in claude mcp list.
  3. CLAUDE_PROJECT_DIR is set by Claude Code in the server's environment, not in its own, which is why the docs say a ${CLAUDE_PROJECT_DIR} reference in .mcp.json args needs a default such as ${CLAUDE_PROJECT_DIR:-.}.
  4. Claude Code asks for approval before using project-scoped servers from .mcp.json in an interactive session (claude mcp reset-project-choices resets those answers), and a cloned repository can't approve its own servers through committed settings: the folder must be trusted first.
  5. Inside a session, /mcp shows each server's status and tool count; tools are named mcp__room-tools__get_room_status for permission rules and hooks; prompts appear as /room-tools:triage_room (MCP) commands; resources are referenced as @room-tools:rooms://huddle-2/status.

Or let Claude Code write that file for you. These commands were run with Claude Code 2.1.310 in a scratch configuration (HOME and CLAUDE_CONFIG_DIR pointed at an empty folder, in a copy of the project), so nothing was added to a real setup:

Adding room-tools to Claude Code
# Everything after -- is the server's own command line.
claude mcp add --env ROOMS_API_URL=https://rooms.example.test --transport stdio --scope project \
  room-tools -- node src/stdio.mjs

claude mcp list            # status of every configured server
claude mcp get room-tools  # one server's details and any connection issue
What they printed (run 27 October 2026; the project path shortened to …)
$ claude mcp add --env ROOMS_API_URL=https://rooms.example.test --transport stdio --scope project room-tools -- node src/stdio.mjs
Added stdio MCP server room-tools with command: node src/stdio.mjs to project config
File modified: …/room-tools/.mcp.json
$ claude mcp list
Checking MCP server health…

room-tools: node src/stdio.mjs - ⏸ Pending approval (run `claude` to approve)
$ claude mcp get room-tools
room-tools:
  Scope: Project config (shared via .mcp.json)
  Status: ⏸ Pending approval (run `claude` to approve)
  Type: stdio
  Command: node
  Args: src/stdio.mjs
  Environment:
    ROOMS_API_URL=https://rooms.example.test

To remove this server, run: claude mcp remove room-tools -s project
What’s happening
  1. -- separates Claude Code's options (--env, --transport, --scope) from the server's command; everything after it is passed through untouched. Put --env before the other options: it accepts several KEY=value pairs, so a server name straight after it is read as another pair. With --env ROOMS_API_URL=… room-tools at the end of the options, Claude Code refused: Invalid environment variable format: room-tools, environment variables should be added as: -e KEY1=value1 -e KEY2=value2.
  2. --scope project wrote .mcp.json with the same entry as the hand-written file plus "type": "stdio", and with the relative path src/stdio.mjs (the hand-written file uses ${CLAUDE_PROJECT_DIR:-.} so the path doesn't depend on where the server is started). The default scope, local, keeps the entry private to you in ~/.claude.json.
  3. claude mcp list and claude mcp get check each server without starting a session. A project server stays ⏸ Pending approval until you run claude in the folder and approve it. Added at local scope instead, the same server showed ✔ Connected; the hand-written entry above (added with claude mcp add-json) also showed ✔ Connected, with the warning Missing environment variables: ROOMS_API_TOKEN; and an entry pointing at a missing file showed ✘ Failed to connect — CONNECTION_CLOSED: Connection closed.
  4. Those are health checks only: no Claude Code session listed or called the tools for this page.
AdvancedWhat a host's startup looks like from the inside

This model plays a host: it reads an mcpServers object with three entries, starts each with the SDK's StdioClientTransport, keeps each server's stderr like a per-server log, and reports what happened. One entry is right, one has a typo in its path and one names a command that isn't installed.

host-startup.mjs
// What a host does with an "mcpServers" config at startup, using the SDK's own stdio client.
import { fileURLToPath } from "node:url";

if (process.argv[2] === "serve") {
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const server = new McpServer({ name: "room-tools", version: "0.2.0" });
  server.registerTool("list_rooms", { description: "Ids of every meeting room" }, async () => ({ content: [{ type: "text", text: "huddle-2, board-1" }] }));
  await server.connect(new StdioServerTransport());
  console.error(`cwd=${process.cwd()} ROOMS_API_URL=${process.env.ROOMS_API_URL ?? "(unset)"} ROOMS_API_TOKEN=${process.env.ROOMS_API_TOKEN ? "(set)" : "(unset)"}`);
} else {
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
  process.env.ROOMS_API_TOKEN = "secret-in-the-host-env"; // the host has it; will the server?

  const self = fileURLToPath(import.meta.url);
  const config = {
    mcpServers: {
      "room-tools": { command: process.execPath, args: [self, "serve"], env: { ROOMS_API_URL: "https://rooms.example.test" }, cwd: "/" },
      "typo-in-path": { command: process.execPath, args: ["/Users/me/room-tool/src/stdio.mjs"] },
      "not-installed": { command: "room-tools-cli" },
    },
  };

  for (const [name, entry] of Object.entries(config.mcpServers)) {
    const transport = new StdioClientTransport({ ...entry, stderr: "pipe" });
    let log = ""; // what Claude Desktop keeps in mcp-server-<name>.log
    transport.stderr.on("data", (d) => (log += d));
    const client = new Client({ name: "toy-host", version: "1.0.0" });
    try {
      await client.connect(transport, { timeout: 5000 });
      const { tools } = await client.listTools();
      console.log(`${name}: connected, ${tools.length} tool(s): ${tools.map((t) => t.name).join(", ")}`);
    } catch (e) {
      console.log(`${name}: FAILED: ${e.message}`);
    } finally {
      await client.close();
    }
    const lines = log.trim().split("\n");
    const shown = lines.find((l) => l.startsWith("Error")) ?? lines[0];
    if (shown) console.log(`  log | ${shown}`);
  }
}
Result
room-tools: connected, 1 tool(s): list_rooms
  log | cwd=/ ROOMS_API_URL=https://rooms.example.test ROOMS_API_TOKEN=(unset)
typo-in-path: FAILED: MCP error -32000: Connection closed
  log | Error: Cannot find module '/Users/me/room-tool/src/stdio.mjs'
not-installed: FAILED: spawn room-tools-cli ENOENT
What’s happening
  1. room-tools connected and listed its tool. Its log line shows cwd=/ (the entry's cwd was honoured) and ROOMS_API_URL from the entry's env.
  2. The same log line shows ROOMS_API_TOKEN=(unset) even though the host process had it set. The SDK's StdioClientTransport doesn't pass the parent's whole environment: it passes a short safe list (HOME, LOGNAME, PATH, SHELL, TERM, USER on macOS and Linux; DEFAULT_INHERITED_ENV_VARS in client/stdio.js) plus the entry's env. Real hosts make their own choices, so the safe habit is the same everywhere: name every variable the server needs in env.
  3. typo-in-path: node started, couldn't find /Users/me/room-tool/src/stdio.mjs, printed the error to stderr and exited. The client saw only that the stream closed: MCP error -32000: Connection closed. The cause is only in the log (Error: Cannot find module…), which is why every host's first troubleshooting step is "read the server's log".
  4. not-installed: the operating system couldn't find room-tools-cli at all, so the spawn itself failed with ENOENT and there's no log. In a GUI host this is often a PATH difference: the command exists in your terminal, not in the app's environment.
  5. What this demonstrates: a host can't tell much from the protocol side when startup fails; the evidence is in the server's stderr and the spawn error. Make your server print a clear startup line and clear fatal errors to stderr.
AdvancedShutting down cleanly

The host also decides when your server stops. The SDK's client ends stdin, waits up to 2 seconds, then sends SIGTERM and finally SIGKILL (close() in client/stdio.js). Claude Code's docs describe its own sequence: on macOS and Linux SIGINT, then SIGTERM 100 ms later, then SIGKILL after a further 400 ms; on Windows it closes stdin and ends the process tree 300 ms later. A server with a background timer won't exit when stdin closes unless you make it.

shutdown.mjs
// When the host disconnects, does the server exit by itself? A background timer can keep it alive.
import { fileURLToPath } from "node:url";

if (process.argv[2] === "serve") {
  const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
  const server = new McpServer({ name: "room-tools", version: "0.2.0" });
  const poll = setInterval(() => {}, 1000); // e.g. refresh a device cache every second
  const shutdown = async (why) => {
    console.error(`shutting down: ${why}`);
    clearInterval(poll);
    await server.close();
    process.exit(0);
  };
  if (process.argv[3] === "handles-stdin-end") process.stdin.on("end", () => shutdown("stdin closed"));
  process.on("SIGTERM", () => shutdown("SIGTERM"));
  process.on("SIGINT", () => shutdown("SIGINT"));
  await server.connect(new StdioServerTransport());
} else {
  const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
  const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
  for (const variant of ["timer-only", "handles-stdin-end"]) {
    const transport = new StdioClientTransport({ command: process.execPath, args: [fileURLToPath(import.meta.url), "serve", variant], stderr: "pipe" });
    let log = "";
    transport.stderr.on("data", (d) => (log += d));
    const client = new Client({ name: "host", version: "1.0.0" });
    await client.connect(transport);
    const started = performance.now();
    await client.close(); // SDK client: end stdin, wait up to 2 s, then SIGTERM, then SIGKILL
    const seconds = (performance.now() - started) / 1000;
    console.log(`${variant.padEnd(18)} ${log.trim()} (${seconds < 1 ? "under 1 s" : "about 2 s"})`);
  }
}
Result
timer-only         shutting down: SIGTERM (about 2 s)
handles-stdin-end  shutting down: stdin closed (under 1 s)
What’s happening
  1. Both variants start a setInterval (think "refresh a device cache every second"), which keeps Node's event loop alive.
  2. timer-only: when the client closed stdin, nothing in the server noticed (the SDK's StdioServerTransport doesn't end the process on end of input), so the timer kept it running. The client waited its 2 seconds and sent SIGTERM; the server's SIGTERM handler logged shutting down: SIGTERM and exited.
  3. handles-stdin-end: process.stdin.on("end", …) ran the same shutdown as soon as stdin closed: cleared the timer, closed the server, exited, all well under a second.
  4. Handle all three: end of stdin, SIGTERM and SIGINT, with one idempotent shutdown function, and finish quickly (Claude Code allows about half a second before SIGKILL). If your server writes files or holds locks, this is where you release them.
It works in the terminal but not in Claude Desktop

node /Users/you/code/room-tools/src/stdio.mjs works in your terminal. Claude Desktop's config has "command": "node", "args": ["src/stdio.mjs"], and the server never appears. Name the two likely causes and where you'd confirm each.

Show answer

(1) The relative path src/stdio.mjs: Claude Desktop isn't running in your project folder, so Node can't find the file (the log shows Cannot find module). (2) node not found in the app's environment if it comes from a version manager in your shell profile (a spawn ENOENT, logged in mcp.log). Fix both with absolute paths, then restart Claude Desktop.

What’s happening
  1. Read ~/Library/Logs/Claude/mcp.log for connection events and mcp-server-room-tools.log for the server's stderr.
  2. A Cannot find module '…/src/stdio.mjs' error means node started but resolved the relative path against a different working directory. That's the typo-in-path case in the model: the client only sees "connection closed", the log shows the cause.
  3. A spawn ENOENT for node means the app's PATH doesn't contain the directory your shell adds. That's the not-installed case. which node in the terminal gives the absolute path to put in command.
  4. The guide's own checklist covers both: restart completely, check the JSON syntax, use absolute paths, read the logs, and run the command by hand.

#Logging, progress and long-running tools

Added

Most tools answer in milliseconds. Some don't: a firmware rollout across twenty rooms, a diagnostics bundle, a log search. For those, the protocol gives you three things on top of the plain request and response. Progress notifications: if the client put a progressToken in the request's _meta, the server may send notifications/progress with a rising progress, an optional total and a message while it works. Cancellation: the client sends notifications/cancelled with the request id, and the server should stop and not answer. Logging: in 2025-11-25 a server that declares the logging capability can send notifications/message with a syslog-style level, and the client can set a minimum level with logging/setLevel. Separately from all three, every client has timeouts, so a tool that takes longer than the host is willing to wait needs a different shape: start a job and return its id.

The mental model is a courier with a parcel tracker. The request is the delivery order; progress notifications are the tracker pinging "2 of 4 stops done"; cancellation is you calling to say "stop, don't deliver"; the courier's log is their notebook (stderr, or MCP log messages, kept for whoever runs the depot); and the timeout is how long you'll stand at the door. If a delivery takes days, you don't stand at the door: you take a tracking number (a job id) and check it later.

Host + client Tool handler Devices tools/call update_firmware _meta.progressToken: 1 room 1: push image notifications/progress 1/4 room 2: push image notifications/progress 2/4 user presses Stop notifications/cancelled id 1 pending call rejected signal.aborted stop before room 3; no reply stderr → host log, all along 2026-07-28 over HTTP: closing the response stream is the cancellation; log level is asked for per request in _meta (logging itself is deprecated)
Progress and cancellation as SDK 1.32.2 does them. Notifications (dashed) never get replies; a cancelled request gets no response at all.

Here is a slow tool that reports progress, called once with a progress callback and once without.

progress.mjs
// A long-running tool that reports progress while it works.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const server = new McpServer({ name: "room-tools", version: "0.5.0" });

server.registerTool("update_firmware", {
  description: "Install a firmware version on several rooms, one at a time. Takes about a minute per room on real devices.",
  inputSchema: { roomIds: z.array(z.string()).min(1).max(20), version: z.string() },
  annotations: { destructiveHint: false, idempotentHint: true },
}, async ({ roomIds, version }, extra) => {
  const token = extra._meta?.progressToken; // present only if the client asked for progress
  console.log(`  server: progressToken=${JSON.stringify(token)}`);
  for (const [i, roomId] of roomIds.entries()) {
    await sleep(20); // stands in for "push image, wait for reboot"
    if (token !== undefined)
      await extra.sendNotification({
        method: "notifications/progress",
        params: { progressToken: token, progress: i + 1, total: roomIds.length, message: `${roomId} on ${version}` },
      });
  }
  return { content: [{ type: "text", text: `Updated ${roomIds.length} rooms to ${version}.` }] };
});

const client = new Client({ name: "host", version: "1.0.0" });
const [c, s] = InMemoryTransport.createLinkedPair();
await server.connect(s);
await client.connect(c);
const args = { roomIds: ["huddle-2", "board-1", "lobby-3"], version: "4.3.1" };

console.log("call 1, with onprogress:");
const r1 = await client.callTool({ name: "update_firmware", arguments: args }, undefined, {
  onprogress: ({ progress, total, message }) => console.log(`  progress ${progress}/${total}: ${message}`),
});
console.log("  result:", r1.content[0].text);

console.log("call 2, without:");
const r2 = await client.callTool({ name: "update_firmware", arguments: args });
console.log("  result:", r2.content[0].text);
await client.close();
Result
call 1, with onprogress:
  server: progressToken=1
  progress 1/3: huddle-2 on 4.3.1
  progress 2/3: board-1 on 4.3.1
  progress 3/3: lobby-3 on 4.3.1
  result: Updated 3 rooms to 4.3.1.
call 2, without:
  server: progressToken=undefined
  result: Updated 3 rooms to 4.3.1.
What’s happening
  1. On the first call the client passed onprogress, so the SDK added _meta: { progressToken: 1 } to the request. SDK 1.32.2 uses the request's own JSON-RPC id as the token, which is why it's 1. The handler reads it from extra._meta.progressToken.
  2. After each room the handler sends notifications/progress through extra.sendNotification, which ties the notification to this request (on Streamable HTTP that puts it on this request's response stream). progress goes 1 → 2 → 3, always increasing as the spec requires, with total: 3 and a human-readable message.
  3. The client's onprogress printed each one as it arrived, before the result: notifications for a request come in order ahead of its response.
  4. On the second call there was no onprogress, so no token was sent, extra._meta?.progressToken was undefined, and the handler sent nothing. The spec says progress notifications must only reference tokens from an active request, so a server must not invent one.
  5. What this demonstrates: progress is opt-in from the client and cheap for the server. Send it for anything a human might wait on; hosts can show it, and clients can use it to keep a timeout from firing (next).
AdvancedCancelling a long tool call

Cancellation

cancel.mjs
// Cancelling a long tool call: the client aborts, the SDK sends notifications/cancelled, the handler stops.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const done = [];
let handlerFinished;
const finished = new Promise((r) => (handlerFinished = r));

const server = new McpServer({ name: "room-tools", version: "0.5.0" });
server.registerTool("update_firmware", {
  description: "Install firmware on several rooms, one at a time.",
  inputSchema: { roomIds: z.array(z.string()), version: z.string() },
}, async ({ roomIds, version }, extra) => {
  try {
    for (const [i, roomId] of roomIds.entries()) {
      if (extra.signal.aborted) {
        console.log(`  server: aborted before ${roomId} (reason: ${extra.signal.reason})`);
        return { content: [{ type: "text", text: "cancelled" }] }; // the SDK won't send this
      }
      await sleep(20);
      done.push(roomId);
      await extra.sendNotification({ method: "notifications/progress", params: { progressToken: extra._meta.progressToken, progress: i + 1, total: roomIds.length } });
    }
    return { content: [{ type: "text", text: `Updated ${roomIds.length} rooms to ${version}.` }] };
  } finally {
    handlerFinished();
  }
});

// Watch what the client sends, to see the cancellation on the wire.
const [c, s] = InMemoryTransport.createLinkedPair();
const send = c.send.bind(c);
c.send = (msg, opts) => { if (msg.method === "notifications/cancelled") console.log("  wire →", JSON.stringify(msg)); return send(msg, opts); };

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

const stop = new AbortController();
try {
  await client.callTool({ name: "update_firmware", arguments: { roomIds: ["huddle-2", "board-1", "lobby-3", "atrium"], version: "4.3.1" } }, undefined, {
    signal: stop.signal,
    onprogress: ({ progress, total }) => {
      console.log(`  progress ${progress}/${total}`);
      if (progress === 2) stop.abort("user pressed Stop");
    },
  });
} catch (e) {
  console.log(`  client: callTool rejected: ${e.name}: ${e.message}`);
}
await finished;
console.log("  rooms actually updated:", done.join(", "));
await client.close();
Result
  progress 1/4
  progress 2/4
  wire → {"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":"user pressed Stop"}}
  client: callTool rejected: McpError: MCP error -32001: user pressed Stop
  server: aborted before lobby-3 (reason: user pressed Stop)
  rooms actually updated: huddle-2, board-1
What’s happening
  1. The client passed an AbortController's signal with the call. After the second progress update the callback called stop.abort("user pressed Stop").
  2. The SDK immediately sent notifications/cancelled with requestId: 1 and the reason (the wire → line, captured by wrapping the client transport's send), forgot the pending request, and rejected callTool locally. Note the rejection: McpError with code -32001, the same code as a timeout, because 1.32.2 wraps any abort reason that isn't already an McpError as RequestTimeout. If you need to tell "the user cancelled" from "it timed out", check your own signal.aborted rather than the code.
  3. On the server, the SDK looked up the request id and aborted that handler's extra.signal. The handler checks it before each room, so it stopped before lobby-3 and logged the reason. Only huddle-2 and board-1 were updated.
  4. The handler's return after aborting goes nowhere: the SDK doesn't send a response for a cancelled request, matching the spec ("not send a response for the cancelled request").
  5. Cancellation is cooperative: nothing stops your code except your own checks. Check extra.signal.aborted between steps, and pass extra.signal to fetch() so an in-flight HTTP call to a device is abandoned too.
AdvancedTimeouts, and keeping them alive with progress

Every request has a client-side timeout. In SDK 1.32.2 it's DEFAULT_REQUEST_TIMEOUT_MSEC = 60000 (60 seconds) unless the call sets timeout, and there are two more options: resetTimeoutOnProgress restarts the timer on each progress notification, and maxTotalTimeout caps the whole call regardless. Hosts set their own values; Claude Code's docs (read 27 October 2026) describe a per-server timeout field, an MCP_TOOL_TIMEOUT variable, and an idle timeout (30 minutes for stdio servers by default) that aborts a call with no response and no progress for that long.

timeouts.mjs
// Client timeouts for a slow tool: a fixed timeout, a timeout that progress resets, and a hard ceiling.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const server = new McpServer({ name: "room-tools", version: "0.5.0" });
server.registerTool("collect_diagnostics", { description: "Gather a diagnostics bundle from a room (slow)." }, async (extra) => {
  for (let step = 1; step <= 6; step++) { // 6 steps of 100 ms: about 600 ms in all
    await sleep(100);
    if (extra.signal.aborted) return { content: [] };
    await extra.sendNotification({ method: "notifications/progress", params: { progressToken: extra._meta.progressToken, progress: step, total: 6 } });
  }
  return { content: [{ type: "text", text: "bundle ready" }] };
});

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

const cases = [
  ["timeout 250 ms", { timeout: 250 }],
  ["timeout 250 ms, reset on progress", { timeout: 250, resetTimeoutOnProgress: true }],
  ["same, but at most 350 ms in total", { timeout: 250, resetTimeoutOnProgress: true, maxTotalTimeout: 350 }],
];
for (const [label, options] of cases) {
  let seen = 0;
  try {
    const r = await client.callTool({ name: "collect_diagnostics", arguments: {} }, undefined, { ...options, onprogress: () => seen++ });
    console.log(`${label.padEnd(36)} ok: ${r.content[0].text} after ${seen} progress updates`);
  } catch (e) {
    console.log(`${label.padEnd(36)} ${e.message} after ${seen} progress updates`);
  }
  await sleep(700); // let the abandoned call wind down before the next case
}
await client.close();
Result
timeout 250 ms                       MCP error -32001: Request timed out after 2 progress updates
timeout 250 ms, reset on progress    ok: bundle ready after 6 progress updates
same, but at most 350 ms in total    MCP error -32001: Maximum total timeout exceeded after 3 progress updates
What’s happening
  1. The tool takes about 600 ms: six steps of 100 ms, with a progress notification after each.
  2. timeout: 250 alone: progress arrived at about 100 and 200 ms, but the timer doesn't care, so the call failed at 250 ms with MCP error -32001: Request timed out after 2 updates. The SDK also sent notifications/cancelled, so the handler saw signal.aborted and stopped.
  3. resetTimeoutOnProgress: true: each update restarted the 250 ms timer, and updates came every 100 ms, so the call ran its full 600 ms and returned bundle ready after all 6. This is how a long tool stays alive: report progress more often than the timeout.
  4. Plus maxTotalTimeout: 350: the timer kept being reset, but the total ceiling stopped the call at 350 ms (Maximum total timeout exceeded) after 3 updates. The spec's lifecycle section (Timeouts, 2025-11-25) recommends exactly this: resetting on progress is allowed, but implementations should always enforce a maximum so a misbehaving server can't hold a request forever.
  5. The await sleep(700) between cases lets an abandoned handler finish its current step before the next case starts. The timings were chosen with 50 ms or more of margin so the counts are the same on every run.
AdvancedLogging: stderr first, MCP log messages if you need them

For a stdio server, stderr is the log: hosts capture it (Claude Desktop per server in a file, VS Code in the Output view), and it costs nothing. The protocol also has its own logging, which sends log entries to the client as notifications so a host can show them in its UI. In SDK 1.32.2 you enable it by declaring the capability.

mcp-logging.mjs
// MCP's own logging (2025-11-25): the server sends notifications/message, the client picks the minimum level.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { LoggingMessageNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

async function run(label, serverOptions, minLevel) {
  const server = new McpServer({ name: "room-tools", version: "0.5.0" }, serverOptions);
  server.registerTool("reboot_room", { description: "Reboot one room's device", inputSchema: { roomId: z.string() } }, async ({ roomId }) => {
    await server.sendLoggingMessage({ level: "debug", logger: "devices", data: `POST /rooms/${roomId}/reboot` });
    await server.sendLoggingMessage({ level: "info", logger: "devices", data: `${roomId} accepted the reboot` });
    await server.sendLoggingMessage({ level: "warning", logger: "devices", data: { roomId, note: "call in progress was dropped" } });
    return { content: [{ type: "text", text: `${roomId} rebooting` }] };
  });
  const client = new Client({ name: "host", version: "1.0.0" });
  const received = [];
  client.setNotificationHandler(LoggingMessageNotificationSchema, (n) => received.push(`${n.params.level}: ${JSON.stringify(n.params.data)}`));
  const [c, s] = InMemoryTransport.createLinkedPair();
  await server.connect(s);
  await client.connect(c);
  if (minLevel) await client.setLoggingLevel(minLevel);
  await client.callTool({ name: "reboot_room", arguments: { roomId: "huddle-2" } });
  console.log(`${label}: capabilities ${JSON.stringify(client.getServerCapabilities())}`);
  for (const line of received.length ? received : ["(no log notifications)"]) console.log(`  ${line}`);
  await client.close();
}

await run("no logging capability", {}, undefined);
await run("logging, no level set", { capabilities: { logging: {} } }, undefined);
await run("logging, level warning", { capabilities: { logging: {} } }, "warning");
Result
no logging capability: capabilities {"tools":{"listChanged":true}}
  (no log notifications)
logging, no level set: capabilities {"logging":{},"tools":{"listChanged":true}}
  debug: "POST /rooms/huddle-2/reboot"
  info: "huddle-2 accepted the reboot"
  warning: {"roomId":"huddle-2","note":"call in progress was dropped"}
logging, level warning: capabilities {"logging":{},"tools":{"listChanged":true}}
  warning: {"roomId":"huddle-2","note":"call in progress was dropped"}
What’s happening
  1. No logging capability: server.sendLoggingMessage(...) silently did nothing. The SDK checks the capability before sending, because a server must declare logging to emit notifications/message.
  2. With { capabilities: { logging: {} } } in the McpServer options, the capability appears in the handshake, and all three messages reached the client's notifications/message handler, each with a level, a logger name and data (a string or any JSON value).
  3. After client.setLoggingLevel("warning") (the 2025-11-25 logging/setLevel request), the SDK dropped debug and info on the server side and sent only the warning. Levels follow syslog (RFC 5424) order: debug, info, notice, warning, error, critical, alert, emergency.
  4. The spec's security rules apply to every line: log messages must not contain credentials, personal data or internal details that could help an attacker, and servers should rate-limit them.
  5. The catch for new code: the 2026-07-28 revision deprecates Logging (SEP-2577). It removes logging/setLevel, lets a client ask for a level per request in _meta (io.modelcontextprotocol/logLevel), forbids notifications/message for requests that didn't ask, and tells new implementations not to adopt it: log to stderr for stdio, or use OpenTelemetry. This model shows the 2025-11-25 behaviour that SDK 1.32.2 implements.
AdvancedWhen the work outlives the call: return a job id

A rollout to forty rooms can take hours, longer than any reasonable timeout, and the user may close the chat before it's done. Don't hold a request open for that. Make one tool start the work and return an id at once, and another report on it; the model carries the id between calls. It's the same "explicit handle" pattern the 2026-07-28 tools guidance recommends for state across calls.

job-handle.mjs
// For work that outlives a tool call: start a job, return its id at once, let the model check on it.
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";

let now = 0; // fake clock in minutes; a real server uses Date.now()
const jobs = new Map();
const server = new McpServer({ name: "room-tools", version: "0.5.0" });

server.registerTool("start_firmware_rollout", {
  description: "Start installing firmware on rooms in the background (about 5 minutes per room). " +
    "Returns a jobId at once; call get_rollout_status with it to follow progress. Jobs are kept for 24 hours.",
  inputSchema: { roomIds: z.array(z.string()).min(1), version: z.string() },
  outputSchema: { jobId: z.string() },
}, async ({ roomIds, version }) => {
  const jobId = `job-${jobs.size + 1}`;
  jobs.set(jobId, { roomIds, version, startedAt: now });
  return { content: [{ type: "text", text: `Started ${jobId}.` }], structuredContent: { jobId } };
});

server.registerTool("get_rollout_status", {
  description: "Progress of a rollout started with start_firmware_rollout.",
  inputSchema: { jobId: z.string() },
  outputSchema: { state: z.enum(["running", "done"]), updated: z.array(z.string()), pending: z.array(z.string()) },
  annotations: { readOnlyHint: true },
}, async ({ jobId }) => {
  const job = jobs.get(jobId);
  if (!job) return { isError: true, content: [{ type: "text", text: `Unknown or expired job ${jobId}. Start a new rollout.` }] };
  const count = Math.min(job.roomIds.length, Math.floor((now - job.startedAt) / 5));
  const status = { state: count === job.roomIds.length ? "done" : "running", updated: job.roomIds.slice(0, count), pending: job.roomIds.slice(count) };
  return { content: [{ type: "text", text: JSON.stringify(status) }], structuredContent: status };
});

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

const started = await client.callTool({ name: "start_firmware_rollout", arguments: { roomIds: ["huddle-2", "board-1", "lobby-3"], version: "4.3.1" } });
const { jobId } = started.structuredContent;
console.log(`t=${String(now).padStart(2)} min start  → ${jobId}`);
for (const minutes of [6, 6, 6]) {
  now += minutes;
  const r = await client.callTool({ name: "get_rollout_status", arguments: { jobId } });
  console.log(`t=${String(now).padStart(2)} min status → ${r.content[0].text}`);
}
const bad = await client.callTool({ name: "get_rollout_status", arguments: { jobId: "job-9" } });
console.log(`job-9 → isError=${bad.isError}: ${bad.content[0].text}`);
await client.close();
Result
t= 0 min start  → job-1
t= 6 min status → {"state":"running","updated":["huddle-2"],"pending":["board-1","lobby-3"]}
t=12 min status → {"state":"running","updated":["huddle-2","board-1"],"pending":["lobby-3"]}
t=18 min status → {"state":"done","updated":["huddle-2","board-1","lobby-3"],"pending":[]}
job-9 → isError=true: Unknown or expired job job-9. Start a new rollout.
What’s happening
  1. start_firmware_rollout records the job and returns { jobId: "job-1" } in structuredContent immediately. Its description says how long the work takes, which tool to call next and how long jobs are kept, so the model knows the protocol of your tools.
  2. The model (here, the loop) calls get_rollout_status whenever it wants. A fake clock stands in for real time: at 6 minutes one room is done, at 12 two, at 18 all three and the state is done.
  3. The status result is structured (state, updated, pending), so the model can answer "which rooms are still waiting?" without parsing prose.
  4. An unknown or expired id gets a tool error that says what to do: start a new rollout.
  5. In a real server the work runs outside the request (a queue, a worker, the device platform's own job API), state lives somewhere that survives a restart, and every status call checks the caller may see that job. MCP's own experimental tasks feature (2025-11-25, server.experimental.tasks in SDK 1.32.2) standardises a similar call-now, fetch-later pattern; the 2026-07-28 revision moved tasks into an official extension, so check host support before depending on it.
What does the client see?

A client calls update_firmware for 4 rooms with timeout: 5000 and onprogress, but without resetTimeoutOnProgress. The server takes 2 seconds per room and sends progress after each room. What does the client get, which rooms are updated, and what's the smallest change to make the call succeed?

Show answer

The call fails at 5 seconds with MCP error -32001: Request timed out after 2 progress updates. If the handler checks extra.signal, it stops after the room in progress, so 3 rooms are updated (two reported, the third finishing at about 6 s before the check), not 4. Passing resetTimeoutOnProgress: true makes it succeed, because updates arrive every 2 s, well inside 5 s.

What’s happening
  1. Progress arrives at about 2 s and 4 s. Without resetTimeoutOnProgress the 5-second timer keeps running, so at 5 s the SDK rejects the call and sends notifications/cancelled (the same path as the 250 ms case in the timeouts model).
  2. The server aborts the handler's signal at about 5 s, in the middle of room 3. A handler that checks the signal between rooms finishes room 3 (it can't un-push an image) and stops before room 4.
  3. So the client believes the call failed while 3 of 4 rooms were changed: exactly the confusing state that makes long tools dangerous. The tool should report partial results somewhere the model can query, such as a job status.
  4. With resetTimeoutOnProgress: true, each update restarts the 5 s timer, the gaps are 2 s, and the call completes at about 8 s with all 4 rooms.
  5. A maxTotalTimeout (say 60 s) should still be set, so a server that keeps sending progress forever can't hold the call open.

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

RohitDownloads · All chapters

Sync progress across devices

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

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

Sync is on in this browser.

Sync ID

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