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
| Word | What it means | Example |
|---|---|---|
| Buffer | text kept until a whole line has arrived | |
| Frame | one whole message cut from the stream at a newline | |
| stdout | the output stream: only messages go here | |
| stderr | the error stream: logs go here | |
| Parse error | the line was not JSON at all | -32700 |
| Invalid Request | JSON, 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 arrives | Buffer after it | Messages ready |
|---|---|---|
| {"id":1} {"id | {"id | 1 message: {"id":1} |
| ":2} | (empty) | 1 message: {"id":2} |
| {"i | {"i | None 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
| Line | Answer |
|---|---|
| not json at all | Error -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
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
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
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
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
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
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
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
docker exec -w /home/node/project agent node client1.jsShown 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 10 | The short wait finished first |
| id 3, an empty result | The ping was whole once its second half came |
| id null, error -32700 | The line "not json at all" |
| id 5, error -32600 | A method that is a number |
| id 1, waited 80 | The long wait finished last |
The cells students fill in are left blank in this preview.