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.