Reference

Build an MCP server

One file, plain JavaScript, a working server any MCP client can use. You write it, run it, read what it publishes, break it once, and register it with Claude Code. Module 10 teaches what MCP is and how to consume a server; this page teaches building one. If you have never run a program, start here first.

Two kinds of MCP server, and which one this is

Module 19 gave an agent tools with createSdkMcpServer: functions living inside your app's own process, reachable by that app alone. The standalone kind built here is a separate program. A client starts it as a child process and talks to it over stdio, one JSON message per line. Any MCP-speaking harness can use it, and that reach is bought with everything a process boundary adds: a serialization step, a working directory the client chooses, a child that can die on its own, and a registration step. The example implements the same two tools as module 19's in-process pair, saveNote and a note reader, so you can put the two kinds side by side.

Write this

The whole server is examples/mcp-server/server.mjs in the course repository, 230 lines with its comments, built from three parts. Registration is the heart of it:

server.registerTool(
  "saveNote",
  {
    title: "Save note",
    description: "Append a note to notes.jsonl. One JSON object per line.",
    inputSchema: {
      text: z.string().min(1).describe("The note body. Must be 1-280 characters."),
    },
  },
  async ({ text }) => {
    if (text.length > 280) {
      return {
        isError: true,
        content: [{ type: "text",
          text: `Note rejected: ${text.length} characters, limit is 280. Shorten it and call saveNote again.` }],
      };
    }
    // append {time, text} to the notes file, then confirm
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);

Three choices in that excerpt are the lesson. The length cap lives in the handler, not in the schema, so a too-long note comes back as a sentence the model can act on. The notes file path is resolved once from the server's own working directory, never taken from a tool argument. And StdioServerTransport is the last line's whole job: stdout now belongs to the protocol, which the break below makes concrete.

Do this

The example ships a certification script that starts the server as a child, drives it with the SDK's own client, and asserts on everything it prints. Run it in a container so nothing installs on your machine:

docker run --rm -v "$PWD/examples/mcp-server:/app" -w /app node:22-bookworm-slim \
  sh -c "npm install && node certify.mjs"

The run ends passed=29 failed=0. Its transcript, quoted in full in the example's README, is where the rest of this page's facts come from: every claim below was observed on @modelcontextprotocol/sdk 1.30.0 with zod 4.5.4 on Node v22.23.2, on 2026-08-30, and the version pins are exact in the example's package.json.

Here is what the model actually sees

Your source is not the contract. The published schema from tools/list is, because that is the only thing a client receives. The certify run prints it verbatim:

{
  "name": "saveNote",
  "title": "Save note",
  "inputSchema": {
    "type": "object",
    "properties": {
      "text": { "type": "string", "minLength": 1,
                "description": "The note body. Must be 1-280 characters." }
    },
    "required": ["text"]
  },
  "annotations": { "readOnlyHint": false, "destructiveHint": false, "idempotentHint": false },
  "execution": { "taskSupport": "forbidden" }
}

Notice what you did not write: the SDK added execution.taskSupport to every tool, and advertises listChanged in its capabilities though the source declares neither. A hand-written expectation that omits them will not match the wire.

Why reading the published schema matters is best shown by a corner this course hit twice, with opposite results. On the Agent SDK's tool() path, writing .describe() before .optional() in a zod chain silently drops the description from the published schema; module 19's trap walks it. On this package's registerTool path, the same chain keeps the description in both orders. Same source line, two converters, two different wires, and no error from either. The habit that survives both: put .describe() last, and certify the schema your server publishes rather than the source that generated it.

Two failure channels, and who writes the text

A tool result can carry isError: true, and the wire also has a JSON-RPC error channel. The obvious guess is that your handler's failures use the first and schema violations use the second. Certified against SDK 1.30.0, the guess is wrong: the SDK converts a schema violation, and even a call to a tool that does not exist, into an isError: true result. Only a request for an unregistered method produced a real JSON-RPC error frame. The distinction that holds is who writes the text. The validator's diagnostic says what shape the argument should have been; your handler's message can say what to do next. Compare the two the run produced:

validator: MCP error -32602: Invalid input: expected number, received string at count
handler:   Note rejected: 400 characters, limit is 280. Shorten it and call saveNote again.

That second line is the argument for enforcing policy in the handler. A different SDK or a hand-written server may route these differently, so treat this as certified behavior of these pins, never as how MCP works everywhere.

Break it

Add console.log("starting up") as the first line of server.mjs and re-run the certification. The client's parse fails on the first non-JSON line, before any tool exists. Stdout belongs to the protocol; the greeting corrupted it. The fix the example uses is in front of you in the transcript: the server's [notes-server] ready lines went to stderr, interleave harmlessly through the whole run, and the connection survived them. Diagnostics go to stderr, always.

The transcript also shows the wire's smallest grammar: the handshake is one request/response pair followed by a notifications/initialized message carrying no id. No id means no reply is coming, and that is the entire difference between a request and a notification on this wire.

Wire it into Claude Code

Registration is one command, stdio being the default transport, with -- separating the command the server runs as:

claude mcp add notes -- node /absolute/path/to/examples/mcp-server/server.mjs

Or check a project-scoped .mcp.json into the repository so the whole team gets it, which is the form module 10 walks through along with the approval flow a project server triggers. Where the notes file lands depends on the working directory the client starts the server in; that is the process boundary again, and the first thing to check when notes seem to vanish.

From here: module 14 if you want the loop itself inside your own program, or module 19 for when a tool should live in-process instead of behind a wire.

Back to the modules · Reference tables