#A first server with the TypeScript SDK
AddedAn 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).
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):
mkdir room-tools && cd room-toolsgives you an empty project folder.npm init -ywrites a defaultpackage.json.npm pkg set type=modulemakes.jsfiles ES modules (this chapter names files.mjs, which are ES modules either way).npm install @modelcontextprotocol/sdk zodinstalls the SDK and its required peer dependency. On 27 October 2026 that resolved to@modelcontextprotocol/sdk@1.32.2andzod@4.6.7; npm'slatesttag for the SDK package is still the v1 line.
Then one file, server.mjs, with one tool:
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- Imports.
McpServeris the SDK's high-level server (server/mcp.js),StdioServerTransportis the stdio transport (server/stdio.js), andzis zod. The.jssuffixes in the import paths are part of the SDK's package exports; leave them in. new McpServer({ name, version })sets theserverInfothe host sees in the handshake. Nothing has been sent yet; registering tools only fills in a table.registerTool(name, config, handler)is the current API (the olderserver.tool(...)overloads are marked@deprecatedin 1.32.2'smcp.d.ts). The config holds atitlefor the host's UI, thedescriptionthe model reads to decide when to call it, aninputSchemawritten as a zod shape (an object of zod types, notz.object(...); both work, the shape is the common form), andannotationsthat tell the host this tool only reads.- The handler receives arguments that the SDK has already validated against the schema, so
roomIdis guaranteed to be a string. It returns a result:contentis a list of blocks, here onetextblock with the room as JSON. For an unknown room it returns a normal result withisError: trueand a message that tells the model what to do instead; it doesn't throw (more on that in Designing good tools). 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. Theawaitis top-level, which ES modules allow.console.error, notconsole.log, announces that it started. stderr is the log channel; stdout carries only protocol messages. Swap inconsole.logand every client that starts this server sees an invalid line before the handshake.
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.
{"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"}}}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}- 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. initialize(id 1) got the server'sprotocolVersion(2025-11-25, the one requested), its capabilities andserverInfo. The SDK declaredtools: { listChanged: true }by itself because a tool was registered.- The
notifications/initializedline got no answer, because notifications never do. tools/callfor huddle-2 (id 2) returned the handler's text block. The room JSON is a string insidetext, so its quotes are escaped.tools/callfor lobby (id 3) returned a result, not a JSON-RPC error, withisError: trueand the message listing valid rooms. When stdin ended (the pipe closed after the fourth line), the process exited with status 0.
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.
// 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));
}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"StdioClientTransportgets the same three things a host config file holds: acommand(process.execPath, the absolute path of the runningnode),args(this file plusserve) 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.client.connect(transport)spawns the child process, sendsinitialize, checks the answer and sendsnotifications/initialized. After it resolves,getServerVersion()andgetServerCapabilities()hold what the server declared.listTools()andcallTool()are typed wrappers aroundtools/listandtools/call. The second call'sisError=trueis the handler's own error result, delivered like any other result.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.- This is the shape of every integration test and every host: spawn, handshake, list, call, close. Testing a server builds on it.
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:
// 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; },
};
}// 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;
}#!/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");src/rooms.mjsis the data layer: an object withlist()andget(roomId). It's a fake here; chapter 07 swaps in real HTTP calls with the same two methods, and nothing else changes.src/server.mjsexportscreateServer({ rooms }), a factory that builds and returns a configuredMcpServerwithout connecting it. A second tool,list_rooms, gives the model a way to discover ids, and both tools now declare anoutputSchemaand returnstructuredContentas well as text.src/stdio.mjsis the entry point a host runs: build the server with real dependencies, connect it to stdio, log to stderr. The#!/usr/bin/env nodeline andchmod +xlet it be run directly and published as a packagebin.- Because nothing in
createServertouches stdin or stdout, a test can connect the same server to an in-memory transport, and the Inspector or a host can startsrc/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):
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.0npm 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:
{
"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"
}
}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.
- On stdio, the host reads the server's stdout as newline-delimited JSON-RPC. Every line must be one valid message.
console.logwrites to stdout, soloaded 2 rooms\nbecomes a line the client tries to parse. It isn't JSON, so the SDK client raises a parse error through itsonerror(MCP Fundamentals' stdio-logging model shows the exact message).- 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.
console.errorwrites 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).