Loading video…
Sign in to react
The protocolDeepCode at lesson-02

JSON-RPC on the wire

Tom is an engineer at a small company that sells booking software to gyms and yoga studios. He is building the company's first MCP server, our helpdesk server, and he has started it in a terminal:

Shell
node handwritten/server.ts

The server prints one line and waits:

Text
helpdesk: listening on stdin

Tom wants to ask the server who it is. He types this line and presses Enter:

JSON
{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"terminal","version":"0.1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}

Nothing happens. No answer, no error. The server is still running.

The next day he has a second surprise. He wants to see the moment the server starts, so he adds a line that runs before anything else: process.stdout.write("ready "). He tests the server with the official TypeScript MCP client, the library that Claude Code's MCP runtime is built on. The client can no longer connect. Its connect call fails with SdkError: Request timed out. A teammate had tried console.log("ready") in the same place the week before, and the same client connected without a problem.

Here is the puzzle for this lesson. Why did the server ignore Tom's first line? And why does one way of printing "ready" break the client while the other does not?

By the end of this lesson you will be able to answer both, write a valid MCP request by hand, read any response or error a server sends back, and drive a stdio server from a terminal.

ThingValue
Tomengineer at the company; he is building our helpdesk server
our helpdesk serverhandwritten/server.ts, written by hand with no SDK, 90 lines at tag lesson-02
how the client reaches itstdio: the client starts the server as a process and talks to its standard input and output
protocol revision2026-07-28
the client in this lessonTom, typing in a terminal; the messages name it terminal, version 0.1.0
the client in the stray-output test@modelcontextprotocol/client 2.3.1, the official TypeScript client

Every message and every response in this lesson was recorded from that server on 2026-10-15. The companion repository has the code at tag lesson-02, and the recorded sessions in transcripts/lesson-02/.

The Line Tom Should Have Typed

Start with a line that works. Here it is, exactly as it travels to the server, on one line:

JSON
{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"terminal","version":"0.1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}

The same message, spread over several lines so you can read it:

JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "server/discover",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "terminal",
        "version": "0.1.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

The server answers at once, on its own line. Formatted for reading:

JSON
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": [
      "2026-07-28"
    ],
    "capabilities": {},
    "instructions": "The company's helpdesk: read support tickets and customer records.",
    "_meta": {
      "io.modelcontextprotocol/serverInfo": {
        "name": "helpdesk",
        "version": "0.1.0"
      }
    },
    "ttlMs": 3600000,
    "cacheScope": "public"
  }
}

Read the request field by field.

  • "jsonrpc": "2.0" says which format this is. MCP messages follow JSON-RPC 2.0, a small, older standard for one program to ask another to run a named operation, written in JSON. The MCP specification says: "All messages between MCP clients and servers MUST follow the JSON-RPC 2.0 specification." Every message carries this field, with exactly this value.
  • "id": 1 is a label the client chose for this request. The response carries the same label.
  • "method": "server/discover" names the operation. A method is the name of what the client is asking for. MCP method names have a slash: server/discover, tools/list, tools/call.
  • "params" holds the input. This method takes no input of its own, so params holds only _meta, which the section after next explains.

Now the response.

  • "jsonrpc": "2.0" again.
  • "id": 1, the same label as the request. This is how the client knows which question it answers.
  • "result" holds the answer. What server/discover puts inside it is the subject of a later section.

These are two of the three kinds of message. A request asks for something and expects an answer. A response is that answer. Each JSON-RPC object, of any kind, is called a message.

The Third Kind: A Message Nobody Answers

Put Tom's line next to the working one. Here are both again, cut to the fields that differ:

Text
Tom's line:      {"jsonrpc":"2.0",         "method":"server/discover","params":{...}}
the working one: {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{...}}

Tom's line has no id. What does a server send back for a message with no id?

...

Nothing, and that is correct. A message with a method but no id is the third kind: a notification. It is one-way. The base protocol page of the specification is direct about it: "The receiver MUST NOT send a response", and notifications "MUST NOT include an ID".

The server was not broken. It read a notification called server/discover, which is not a notification it knows, and it said nothing, as the rule requires. Here is the line in our server that makes that decision. You will see it again, in place, later in the lesson:

TypeScript
if (id === undefined) return; // a notification: the receiver never answers

Why does the protocol have a message that nobody answers? Because some messages need no answer. Suppose a client asked for something slow, and the user gave up waiting. The client tells the server to stop with a notification called notifications/cancelled, naming the request's id. The stdio part of the specification says the server then "MUST NOT send any further messages for it." An answer to the cancel would be a message about a request the client has already let go.

Here are the three kinds side by side:

KindHas methodHas idHas result or errorGets an answer
requestyesyesnoyes, exactly one response
responsenoyes, the request's idexactly one of the twono
notificationyesnononever

The id is what turns a message into a question. Without it, the server has nowhere to send an answer, so it sends none.

The Id Pairs Each Response with Its Request

The client chooses each id. The specification puts three rules on it:

  • "Requests MUST include a string or integer ID."
  • "Unlike base JSON-RPC, the ID MUST NOT be null."
  • "The request ID MUST NOT match the ID of any other request the sender has issued and not yet received a response for."

A string works as well as a number. When Claude Code connects to our server in lesson 3, its first request uses the id "server-discover-probe-1", and its next one uses 0. The server does not care what the id means. It copies the id into the response.

Why a label at all? Why not match each answer to the oldest open question? Because answers can arrive in a different order from the questions. The stdio part of the specification says the server's responses are "correlated by JSON-RPC id", and that all messages share one channel. A server that works on several requests at once can finish a fast one before a slow one:

Text
client sends    id 2   a request that needs a slow database query
client sends    id 3   a request the server can answer from memory
server answers  id 3   first, because it finished first
server answers  id 2   a moment later

The client keeps a list of the ids it is still waiting for. When a response arrives, it looks up the id and hands the answer to whoever asked. Position in the stream means nothing.

That also explains the third rule. If two open requests had the same id, a response with that id could belong to either one.

Our helpdesk server reads one line, answers it, and then reads the next, so its answers happen to come back in order. A client must not depend on that. Another server, or a later version of ours, may answer in any order.

A request carries a label the client chose, and its response carries the same label. That label, not the order of the messages, is what pairs them.

A Result or an Error

A response holds exactly one of two things. A result means the server did what was asked. An error means it did not. The error has a fixed shape: an integer code, a message for humans, and an optional data field with details.

There are four ways a line typed by hand can go wrong. All four are in one recorded session, transcripts/lesson-02/03-errors. Here is the first.

A method the server does not know. Tom sends a well-formed request for a method called tickets/get:

JSON
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tickets/get",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "terminal",
        "version": "0.1.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

The server answers with an error:

JSON
{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32601,
    "message": "Method not found: tickets/get"
  }
}

The id is 2, so the client knows which request failed. The code -32601 is JSON-RPC's own code for "method not found". Any JSON-RPC program knows it, not only MCP programs. The message adds the method's name, for a person reading a log.

A protocol version the server does not speak. The second request is the working server/discover from before, with one change: its _meta says the client speaks a version from the year 1900.

JSON
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "server/discover",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "1900-01-01",
      "io.modelcontextprotocol/clientInfo": {
        "name": "terminal",
        "version": "0.1.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

The client has sent this. What comes back?

...

An error, with a code from MCP's own range:

JSON
{
  "jsonrpc": "2.0",
  "id": 3,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": [
        "2026-07-28"
      ],
      "requested": "1900-01-01"
    }
  }
}

-32022 is UnsupportedProtocolVersion. The data field carries the useful part: the versions this server does support, and the one it was asked for. The specification requires that list: a server that does not implement the requested version "MUST respond with an UnsupportedProtocolVersionError listing the versions it does support". The client's next step is to pick a version from supported that it also speaks, and send the request again with that version in its _meta. There is no separate step for agreeing on a version. The versioning page says it in two sentences: "There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently."

A request without _meta. The third failure belongs to the next section, because it is the most important of the four.

A line that is not JSON. Tom types hello server. The server answers in one line: {"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}}. This error has no id, because the server could not read one. The specification allows exactly this case: error responses carry the request's id "except in error cases where the ID could not be read due a malformed request".

Here are the codes you will meet:

CodeNameDefined byWhen
-32700parse errorJSON-RPCthe line is not JSON
-32600invalid requestJSON-RPCJSON, but not a valid JSON-RPC message
-32601method not foundJSON-RPCthe server does not know the method
-32602invalid paramsJSON-RPCthe method is known, its input is wrong
-32603internal errorJSON-RPCthe server failed while handling it
-32020HeaderMismatchMCPan HTTP header disagrees with the body (lesson 8)
-32021MissingRequiredClientCapabilityMCPthe request needs something the client did not say it can do
-32022UnsupportedProtocolVersionMCPthe server does not speak the requested version

The specification reserves -32020 to -32099 for MCP's own codes. Codes from -32000 to -32019 were used by implementations before that rule existed. New code should not use them.

A response holds a result or an error, never both. The error's code says what kind of failure it was; its message is for people; its data, when present, tells the client what to do next.

Every Request Carries Its Own _meta

Look again at the params of every request so far. Each one carries the same block:

Text
"_meta": {
  "io.modelcontextprotocol/protocolVersion": "2026-07-28",
  "io.modelcontextprotocol/clientInfo": {
    "name": "terminal",
    "version": "0.1.0"
  },
  "io.modelcontextprotocol/clientCapabilities": {}
}

_meta is a field the specification reserves for information about the request itself, as opposed to the request's input. Keys that start with io.modelcontextprotocol/ belong to MCP. Three of them travel on every request:

KeyRequiredWhat it says
io.modelcontextprotocol/protocolVersionyesthe protocol revision this request uses: "2026-07-28"
io.modelcontextprotocol/clientCapabilitiesyeswhat the client can do, for this request
io.modelcontextprotocol/clientInfono, but clients SHOULD send itthe client's name and version

Capabilities are the optional features each side supports. Tom's terminal can do nothing optional, so it sends an empty object. Claude Code sends more: its capabilities say it can show the user a form, which lesson 7 uses to ask the user to confirm a refund. A server "MUST NOT rely on capabilities the client has not declared". If a request needs one the client did not declare, the answer is error -32021.

clientInfo is for display and logs. The specification notes that it is "self-reported by the sender and ... not verified by the protocol", and that implementations "SHOULD NOT rely on them for security decisions." Any program can claim to be Claude Code.

Now the third failure from the recorded session. Two requests have already crossed this same pipe. The first, id 2, declared version 2026-07-28 and empty capabilities. Then this request arrives, on the same pipe, from the same terminal, to the same server process:

JSON
{
  "jsonrpc": "2.0",
  "id": 8,
  "method": "tools/call",
  "params": {
    "name": "get_ticket",
    "arguments": {
      "ticket_id": "2318"
    }
  }
}

It asks to call a tool, the subject of the next lesson. It has no _meta.

The server has already been told, by request 2 on this same connection, which version and capabilities this client uses. Will it use what it learned from request 2?

...

Well, no:

JSON
{
  "jsonrpc": "2.0",
  "id": 8,
  "error": {
    "code": -32602,
    "message": "Invalid params: _meta needs protocolVersion and clientCapabilities"
  }
}

The server rejects it. It has not forgotten anything, because it never stored anything. The specification forbids it from using earlier requests: "Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity). Every request supplies this metadata in its _meta field." And a request without the required fields "is malformed; the server MUST reject it with JSON-RPC error code -32602 (Invalid params)."

The Connection Is Not a Conversation

You might expect an MCP connection to work like a chat over a WebSocket: you connect, say who you are once, and the server remembers you until you hang up. Many protocols work that way, and earlier revisions of MCP did too.

The current revision does not. Request 8 shows it. The connection was open, the client had identified itself on it, and the server still treated request 8 as if it were the first message it had ever seen. The specification names this property: "The Model Context Protocol (MCP) is a stateless protocol: all the information needed to process a request is contained in the request itself." And it draws the conclusion for stdio directly: "an open connection, such as a STDIO process, is not a conversation or session".

The right model: every request is a complete question. It says which protocol it speaks, what the client can do, and what it wants. A server can answer it without knowing anything that came before.

Statelessness has a price, and the client pays it in bytes. Here is Claude Code's tools/list request from lesson 3, measured on the wire:

Text
the whole request line          429 bytes
of which the _meta block        363 bytes
share of the line that is _meta 363 / 429 = 85 percent

Most of every small request is the same block, sent again. On a local pipe, a few hundred bytes per request cost nothing you can measure.

What the server gets in return is freedom from memory. The stdio specification spells out one consequence: if the server process dies, "Because the protocol is stateless, any in-flight requests are ... lost and the client can retry them against the fresh process." The new process needs nothing from the old one. Lesson 8 shows the larger one: over HTTP, any copy of the server, on any machine, can answer any request.

The server judges each request on its own. A client states its protocol version and capabilities on every request, and an open connection carries no memory between them.

What server/discover Returns

Go back to the first response in this lesson. server/discover is the request a client sends to learn about a server. The specification says servers "MUST implement it" and clients may call it, but do not have to. Here is each field of our server's answer:

  • "resultType": "complete" says what kind of result this is. Every result in 2026-07-28 carries it. "complete" means the request is finished. The other value, "input_required", means the server needs more input from the user first; lesson 7 uses it.
  • "supportedVersions": ["2026-07-28"] lists the protocol versions the server speaks. A client picks one it also speaks.
  • "capabilities": {} lists what the server offers. Today, nothing: the server has no tools yet. Lesson 3 adds "tools": {} here.
  • "instructions" is text the server wants the model to read: what the server is for and how to use it. Hosts may put it into the model's context. Ours is one sentence.
  • "_meta" carries io.modelcontextprotocol/serverInfo, the server's name and version. The specification says servers SHOULD include it in every result. Like clientInfo, it is self-reported.
  • "ttlMs": 3600000 and "cacheScope": "public" say how long a client may reuse this answer, and who may share it.

Work out the cache time:

Text
ttlMs = 3,600,000 ms
      = 3,600,000 / 1,000 = 3,600 s
      = 3,600 / 60        = 60 minutes

So a client may treat this answer as fresh for an hour without asking again. "public" means the answer holds no data about any one user, so a shared cache may give it to anyone. The caching page of the specification requires both fields on the results of server/discover and of each list method.

When does a client call server/discover? The specification gives two reasons. A client can show the server's name, versions and capabilities from one response. And on stdio, a client can send it first as a probe, to learn which era of the protocol the server speaks before it sends anything else. Claude Code does exactly that, as lesson 3 records.

stdio: One Message per Line

So far the lesson has said "line" without saying why. The transport explains it. A transport is how messages travel between client and server. MCP has two: stdio, used here, and Streamable HTTP, used from lesson 8.

With stdio, the client starts the server as a child process. A program has three standard streams: standard input (stdin), standard output (stdout) and standard error (stderr). The stdio page of the specification sets the rules for each:

  • "The server reads JSON-RPC messages from stdin and writes JSON-RPC messages to stdout."
  • "Messages are delimited by newlines, and MUST NOT contain embedded newlines."
  • "The server MAY write UTF-8 strings to stderr for any logging purposes including informational, debug, and error messages."
  • "The server MUST NOT write anything to its stdout that is not a valid MCP message."

So a message ends where the line ends. The client reads stdout one line at a time and parses each line as one message. That is the whole framing. No length prefix, no header, no closing marker. It is also why every message in this lesson is shown first as a single line: JSON spread over several lines would be several messages, each of them broken.

The directions are fixed too. The client writes requests and notifications to the server's stdin, and it "MUST NOT write JSON-RPC responses." The server writes responses and notifications to stdout, and it "MUST NOT write JSON-RPC requests". In this revision, the client asks and the server answers.

Tom's "ready", Explained

Now the second puzzle. The companion repository recorded what the server wrote when a console.log("ready") ran before it started. Here are the raw lines from its stdout, as the client received them:

Text
ready
{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{},"instructions":"The company's helpdesk: read support tickets and customer records.","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"helpdesk","version":"0.1.0"}},"ttlMs":3600000,"cacheScope":"public"}}

The first line is not a message. It breaks the MUST NOT rule above. Here is what the official TypeScript client 2.3.1 did with three kinds of stray output, each printed before the server's first response:

Stray outputWhat the client did
console.log("ready"), a whole line that is not JSONskipped the line and connected; its line reader drops lines that fail to parse as JSON
console.log(JSON.stringify({level:"info",msg:"ready"})), JSON but not JSON-RPCconnected, and reported a schema error for that line
process.stdout.write("ready "), no newlinefailed: SdkError: Request timed out

Follow the third case byte by byte. process.stdout.write adds no newline. So the text ready sits at the start of stdout, and the server's response is written right after it, on the same line. The line the client reads begins with ready and then the JSON. That line fails to parse, so the client throws it away, and with it the response to request 1. The client keeps waiting for an answer with id 1. None comes. After its time limit, it gives up.

The console.log case survived only because console.log ends its output with a newline. The stray text became a line of its own, and this client chose to skip such lines. That was the client being lenient, not the server being right. The older v1 TypeScript client, @modelcontextprotocol/sdk 1.32.1, does not skip a bad line: it reports an error for it.

So both versions of Tom's change were bugs. One of them happened to meet a forgiving client.

The rule to keep: on stdio, stdout is the protocol and stderr is the log. Our server writes its only log line with console.error, which goes to stderr. The specification says clients may "capture, forward, or ignore" stderr, and "SHOULD NOT assume stderr output indicates error conditions." A server can log as much as it likes there.

The same trap hides in libraries. Any package the server uses that prints to stdout, a warning, a progress bar, a banner, writes into the protocol stream. If a stdio server works alone and fails inside a client, look at its stdout first.

On stdio, one line is one message. Anything else on stdout is either skipped by a lenient client or corrupts the next real message.

Our Helpdesk Server, Line by Line

Here is the whole server, in four parts. It has no SDK and no dependencies beyond Node itself. Node 24 runs the TypeScript file directly, so there is no build step. The full file is handwritten/server.ts at lesson-02.

Writing Messages

TypeScript
// An MCP server written by hand, with no SDK.
// It reads one JSON-RPC message per line from stdin and writes one per line
// to stdout. stdout is the protocol, so every log line goes to stderr.
import { createInterface } from "node:readline";
 
const SERVER_INFO = { name: "helpdesk", version: "0.1.0" };
const SUPPORTED_VERSIONS = ["2026-07-28"];
const INSTRUCTIONS = "The company's helpdesk: read support tickets and customer records.";
 
function send(message: object) {
  process.stdout.write(JSON.stringify(message) + "\n");
}
 
function fail(id: string | number, code: number, message: string, data?: object) {
  send({ jsonrpc: "2.0", id, error: { code, message, data } });
}

Line by line:

  • createInterface from node:readline splits a stream into lines. The server uses it on stdin, at the end of the file.
  • SERVER_INFO, SUPPORTED_VERSIONS and INSTRUCTIONS are the three facts the server reports about itself in its server/discover result.
  • send is the only place that writes to stdout. JSON.stringify with no extra arguments writes compact JSON, with no newlines inside it. The + "\n" ends the message. One call, one line, one message.
  • fail builds an error response. When data is not given, JSON.stringify leaves the field out, which is why the -32601 error above has no data.

Checking Every Request

TypeScript
function handle(message: any) {
  const { id, method, params } = message;
  if (id === undefined) return; // a notification: the receiver never answers
 
  if (method === "initialize") return initializeLegacy(id, params); // older clients, see below
 
  if (!legacy) {
    // Every modern request carries its protocol version and the client's
    // capabilities in params._meta. There is no handshake to remember them.
    const meta = params?._meta ?? {};
    const version = meta["io.modelcontextprotocol/protocolVersion"];
    if (!version || !meta["io.modelcontextprotocol/clientCapabilities"]) {
      return fail(id, -32602, "Invalid params: _meta needs protocolVersion and clientCapabilities");
    }
    if (!SUPPORTED_VERSIONS.includes(version)) {
      return fail(id, -32022, "Unsupported protocol version", { supported: SUPPORTED_VERSIONS, requested: version });
    }
  }

Line by line:

  • const { id, method, params } = message takes the three fields every request has.
  • if (id === undefined) return is the notification rule. No id, no answer. This is the line that ignored Tom's first message.
  • if (method === "initialize") sends one older kind of client to its own function. The "What changed" note at the end of this section explains it. For a client on the current revision, this line never matches.
  • if (!legacy) is true for every client on the current revision. The legacy flag also belongs to the note below.
  • params?._meta ?? {} reads _meta, or an empty object if it is missing, so the next checks fail cleanly instead of crashing.
  • The first if is request 8's answer: no version or no capabilities, so -32602.
  • The second if is request 3's answer: a version the server does not speak, so -32022, with the supported list in data.

Notice what is not here. The server does not store version anywhere. It checks it, uses it for this one request, and drops it. That is statelessness, in five lines.

Answering

TypeScript
  if (method === "server/discover") {
    return send({
      jsonrpc: "2.0",
      id,
      result: {
        resultType: "complete",
        supportedVersions: SUPPORTED_VERSIONS,
        capabilities: {},
        instructions: INSTRUCTIONS,
        _meta: { "io.modelcontextprotocol/serverInfo": SERVER_INFO },
        ttlMs: 3600000,
        cacheScope: "public",
      },
    });
  }
  if (legacy && method === "ping") return send({ jsonrpc: "2.0", id, result: {} });
 
  fail(id, -32601, `Method not found: ${method}`);
}

Line by line:

  • The server/discover branch writes the result you read at the start of the lesson, field for field. The id is copied from the request.
  • if (legacy && method === "ping") answers a request that only older clients send. The note below covers it.
  • fail(id, -32601, ...) is the end of the line for any method the server does not know, such as tickets/get.

Reading Lines

TypeScript
createInterface({ input: process.stdin }).on("line", (line) => {
  if (line.trim() === "") return;
  let message;
  try {
    message = JSON.parse(line);
  } catch {
    return send({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" } });
  }
  handle(message);
});
 
console.error("helpdesk: listening on stdin");

Line by line:

  • createInterface({ input: process.stdin }).on("line", ...) calls the function once for every line that arrives on stdin. This is the framing rule, in code.
  • if (line.trim() === "") return ignores empty lines, such as an extra Enter in the terminal.
  • JSON.parse(line) turns the line into an object. If that fails, the server sends -32700 with no id, because it never got one. That is the answer to hello server.
  • handle(message) does the rest.
  • console.error(...) writes the startup line to stderr. It is the line Tom saw in his terminal, and it never touches the protocol stream.

When stdin closes, the line reader ends, nothing else keeps Node running, and the process exits. The specification asks for exactly that: "Servers SHOULD exit promptly when their standard input is closed".

What changed. Before revision 2026-07-28, a client opened with an initialize request carrying its version and capabilities, then sent notifications/initialized, and only then other requests, without _meta. The server remembered that handshake for the whole connection. 2026-07-28 removed it, so that "any request can now land on any server instance behind a plain round-robin load balancer" (SEP-2575). You will recognise an old client by its first line: "method":"initialize". Codex and VS Code still open that way (checked 2026-10-15), and the specification lets our server answer: "An initialize request selects legacy semantics, scoped to the stdio process".

TypeScript
const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"];
let legacy = false;
 
function initializeLegacy(id: string | number, params: any) {
  legacy = true;
  const asked = params?.protocolVersion;
  send({
    jsonrpc: "2.0",
    id,
    result: {
      protocolVersion: LEGACY_VERSIONS.includes(asked) ? asked : LEGACY_VERSIONS[0],
      capabilities: {},
      serverInfo: SERVER_INFO,
      instructions: INSTRUCTIONS,
    },
  });
}

legacy = true switches this process to the old rules, so handle stops asking for _meta. The answer uses the client's version if the server knows it, else the newest older one, and has no resultType. A legacy process also answers ping, which 2026-07-28 removed. From lesson 5 the SDK does all of this, and this code goes away.

Driving It from a Terminal

Every message in this lesson can be typed by hand. Get the code at the lesson's tag and start the server:

Shell
git clone https://github.com/karnstack/mcp-from-zero.git
cd mcp-from-zero
git checkout lesson-02
npm ci
node handwritten/server.ts

The server prints helpdesk: listening on stdin and waits. Paste the line from wire/02-discover.jsonl and press Enter. The server/discover result comes back on the next line. Close stdin with Ctrl-D, and the server exits.

Then try the failures yourself. Paste the first line of wire/02-errors.jsonl and you get -32601. Paste the line from wire/02-notification.jsonl and you get nothing, as Tom did.

To replay a whole file of messages and save the session, the repository has a small recorder:

Shell
node scripts/capture.ts /tmp/session wire/02-discover.jsonl --server "node handwritten/server.ts"

It starts the server, types each line of the file into its stdin, and writes everything that comes back to /tmp/session.jsonl, with a readable copy in /tmp/session.md. Every transcript in transcripts/lesson-02/ was made this way.

One more check runs on every tag: npm run check. Besides type checking and tests, it runs wire/validate.ts, which checks every message in wire/ and in the transcripts against the official JSON Schema of the specification, copied unchanged into wire/schema/. Files named *.invalid.jsonl hold deliberate mistakes, such as request 8 without _meta, and the check passes only if they fail. A message you write by hand is not trusted until the schema accepts it.

Tom's Two Puzzles, Answered

Tom's first line had no id. A message with a method and no id is a notification, and the receiver of a notification must not answer. The server did exactly what the protocol requires.

Tom's "ready" broke the client because stdout on stdio is the protocol, one message per line. process.stdout.write("ready ") left text with no newline at the start of the stream, so it joined the server's first response into one line that was not JSON. The client discarded the line, never saw the response, and timed out. console.log("ready") made a line of its own, and one lenient client skipped it. Both broke a MUST NOT in the specification. The fix for both is the same: log with console.error.

The Takeaway

  1. Every MCP message is a JSON-RPC 2.0 object, one of three kinds. A request has a method and an id. A response has the same id and exactly one of result or error. A notification has a method and no id, and nobody answers it.
  2. The client picks each request's id, a string or an integer, never null, never reused while a request is open. Responses are matched by id, not by order, so a server may answer in any order.
  3. Errors have an integer code, a message and optional data. -32700, -32601 and -32602 come from JSON-RPC. -32022 is MCP's "unsupported protocol version", and its data.supported tells the client which version to retry with.
  4. Every request carries its protocol version and the client's capabilities in _meta. A request without them is rejected with -32602, even on a connection where earlier requests had them. The protocol is stateless: an open connection is not a conversation.
  5. server/discover returns the server's versions, capabilities, instructions and identity, with a cache time. Servers must implement it. Clients may call it, and on stdio they use it as a first probe.
  6. On stdio, one line is one message. stdout is the protocol, stderr is the log, and any stray output on stdout breaks the rule, even when a lenient client forgives it.

The one sentence to keep: every MCP request is a complete question on one line, labelled by the client, and the server answers each one as if it had never heard from that client before.

Our server now answers, but it offers nothing: its capabilities are empty. The next lesson gives it its first tool, get_ticket, and asks what the model does when it calls that tool with the wrong argument: Tools: list, call, and the model in between.

References

Specification, revision 2026-07-28 (source at commit 86f6e0c675afea9a8f233e09b6faf566b126086c):

Companion code, tag lesson-02 (karnstack/mcp-from-zero, MIT license; Node 24.14.1, typescript 6.0.3, @types/node 24.19.1, ajv 8.20.0, ajv-formats 3.0.1):

  • handwritten/server.ts: the server in this lesson
  • wire/: the messages typed by hand, and validate.ts, which checks them against the specification's JSON Schema copied into wire/schema/
  • transcripts/lesson-02/: every session shown in this lesson, recorded 2026-10-15, including 05-what-clients-do.md, the stray-output test against @modelcontextprotocol/client 2.3.1 and @modelcontextprotocol/sdk 1.32.1
  • scripts/capture.ts: the session recorder

Clients, checked 2026-10-15: