Easier to read with cream paper, a tint, more contrast or larger text? Choose your colours and text, once, for every page.

Free lesson

Reading and writing MCP messages

After this lesson: I can read and write the JSON messages a server and a client swap.

70 min lesson8 commandsAI coding agent lab: use opencode to explain and fix code, and check its work

How an MCP server reads its messages

An MCP server is a program that reads JSON messages from its input and writes JSON messages to its output, one message to a line. A server that handles this badly works alone and fails with every real client.

Module 67, lesson 5, showed the client side and the kinds of message. Here you write the reading loop of a server.

By the end of this lesson

Note

I can read and write the JSON messages a server and a client swap.

Key words

WordWhat it meansExample
Buffertext kept until a whole line has arrived
Frameone whole message cut from the stream at a newline
stdoutthe output stream: only messages go here
stderrthe error stream: logs go here
Parse errorthe line was not JSON at all-32700
Invalid RequestJSON, but not a request-32600

The cells students fill in are left blank in this preview.

A stream is cut into lines

Input does not arrive as whole messages. It arrives in chunks. A chunk can hold half a message, one message, or two and a half.

The server keeps a buffer. It adds each chunk, cuts off every complete line and keeps the rest. A message never holds a raw newline, so a newline always ends one.

Worked example: three chunks

Chunk arrivesBuffer after itMessages ready
{"id":1} {"id{"id1 message: {"id":1}
":2} (empty)1 message: {"id":2}
{"i{"iNone yet

The cells students fill in are left blank in this preview.

A bad line still gets an answer

A line that is not JSON gets a parse error. The server cannot read an id, so the id is null.

A line that is JSON but not a request gets Invalid Request. If an id can be read, copy it. A request with no id is a notification, and even a notification for an unknown method gets nothing.

What the server sends back

LineAnswer
not json at allError -32700, id null
{"jsonrpc":"2.0","id":5,"method":5}Error -32600, id 5
[1,2]Error -32600, id null
{"jsonrpc":"2.0","method":"nonsense"}Nothing: a notification

The cells students fill in are left blank in this preview.

Answers can come back in any order

A slow request must not hold up a fast one. The server answers each request when its answer is ready.

So a client cannot match answers by position. It matches them by id. That is why a server copies the id exactly, as a number or as text, whichever it received.

A common mistake

Warning

Many people debug with a print line. On stdout that line is not a message, and the client fails on it. Only messages go to stdout. Write logs to stderr, which the client can show or ignore.

Write the model helper

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function ask(messages, sample, max) {' '  try {' '    const res = await fetch(base + \"/v1/chat/completions\", { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" }, body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 80 }), signal: AbortSignal.timeout(30000) });' '    if (res.ok === false) throw new Error(\"status \" + res.status);' '    const data = await res.json();' '    return { text: data.choices[0].message.content, recorded: false };' '  } catch (error) {' '    console.log(\"Recorded sample: no model answered (\" + error.message + \").\");' '    return { text: sample, recorded: true };' '  }' '}' 'module.exports = { ask };' > model.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the server, part 1

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'let buffer = \"\";' 'function send(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function fail(id, code, text) { send({ id: id, error: { code: code, message: text } }); }' 'function log(text) { process.stderr.write(\"log: \" + text + \"\n\"); }' > server1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the server, part 2

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function handle(line) {' '  let msg;' '  try { msg = JSON.parse(line); } catch (error) { return fail(null, -32700, \"Parse error\"); }' '  const seen = msg && (typeof msg.id === \"number\" || typeof msg.id === \"string\") ? msg.id : null;' '  const good = msg && typeof msg === \"object\" && msg.jsonrpc === \"2.0\" && typeof msg.method === \"string\";' '  if (Boolean(good) === false) return fail(seen, -32600, \"Invalid Request\");' '  log(\"got \" + msg.method);' '  if (msg.id === undefined) return;' '  const ms = (msg.params || {}).ms || 0;' '  if (msg.method === \"ping\") return send({ id: msg.id, result: {} });' '  if (msg.method === \"wait\") return setTimeout(function () { send({ id: msg.id, result: { waited: ms } }); }, ms);' '  fail(msg.id, -32601, \"Method not found: \" + msg.method);' '}' >> server1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the server, part 3

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'process.stdin.on(\"data\", function (chunk) {' '  buffer += chunk.toString();' '  let at = buffer.indexOf(\"\n\");' '  while (at >= 0) {' '    const line = buffer.slice(0, at);' '    buffer = buffer.slice(at + 1);' '    if (line.trim().length > 0) handle(line);' '    at = buffer.indexOf(\"\n\");' '  }' '});' 'log(\"ready\");' >> server1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the scripted client, part 1

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { ask } = require(\"./model\");' 'const server = require(\"child_process\").spawn(\"node\", [\"server1.js\"]);' 'const m = function (obj) { return JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, obj)); };' 'const wait = function (id, ms) { return m({ id: id, method: \"wait\", params: { ms: ms } }) + \"\n\"; };' 'const ping = m({ id: 3, method: \"ping\" }) + \"\n\";' 'const bad = \"not json at all\n\" + m({ id: 5, method: 5 }) + \"\n\" + m({ method: \"notifications/x\" }) + \"\n\";' 'const steps = [[0, wait(1, 80)], [10, wait(2, 10)], [20, ping.slice(0, 12)], [40, ping.slice(12) + bad]];' > client1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the scripted client, part 2

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function start() {' '  steps.forEach(function (s) {' '    setTimeout(function () { console.log(\"-> \" + s[1].trim().split(\"\n\").join(\" | \")); server.stdin.write(s[1]); }, s[0]);' '  });' '  setTimeout(function () { server.stdin.end(); }, 200);' '}' 'require(\"readline\").createInterface({ input: server.stderr }).on(\"line\", function (line) {' '  console.log(\"stderr \" + line);' '  if (line === \"log: ready\") start();' '});' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (line) { console.log(\"<- \" + line); });' >> client1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Write the scripted client, part 3

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'server.on(\"close\", async function () {' '  const q = \"In one sentence, why must a response carry the id of its request?\";' '  const r = await ask([{ role: \"user\", content: q }], \"So the client can tell which request each answer belongs to, because answers can arrive in any order.\", 60);' '  console.log(\"model: \" + r.text);' '});' >> client1.js"

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

Run the client against the server

Command
docker exec -w /home/node/project agent node client1.js

Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.

What the client does

The client starts the server and waits for its ready line on stderr. It sends two slow requests, half a ping, then the rest of the ping with three more lines.

A live call at the end asks a model for one sentence. With no model, the Run prints a Recorded sample for that line.

The answers, in the order they arrive

Line printed with <-Why it came then
id 2, waited 10The short wait finished first
id 3, an empty resultThe ping was whole once its second half came
id null, error -32700The line "not json at all"
id 5, error -32600A method that is a number
id 1, waited 80The long wait finished last

The cells students fill in are left blank in this preview.

About this lab and its source: AI coding agent lab: use opencode to explain and fix code, and check its work

Teaching a class? Set this module for your students with a teacher guide, marked assignments and a lab for every student.