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

Free lesson

MCP by hand: JSON-RPC over stdio

After this lesson: I can read and write the messages a tool server and its client exchange.

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

What a tool server really sends

The Model Context Protocol (MCP) is a standard way for an agent to find and call tools in another program. Its messages are plain JSON, one per line, over standard input and output. It matters because once you can read the messages, you can debug any tool server.

A tool server that works alone but not with your agent is usually sending one message in the wrong shape.

By the end of this lesson

Note

I can read and write the messages a tool server and its client exchange.

Key words

WordWhat it meansExample
JSON-RPCa message format for calling methods, in JSON
Requesta message with an id that expects an answer
Notificationa message with no id and no answer
stdiostandard input and output of a process
Error codea number that says what kind of failure-32601

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

JSON-RPC has three kinds of message

A request has an id, a method name and optional params. A response copies that id and holds either a result or an error. A notification has no id, so nothing answers it.

Over stdio, each message is one line of JSON. The server writes only messages to its output, and sends any logs to its error stream.

Worked example: three messages

KindExampleAnswer
Request{"jsonrpc":"2.0","id":2,"method":"tools/list"}A response with id 2
Response{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}None
Notification{"jsonrpc":"2.0","method":"notifications/initialized"}None

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

This is the shape, not the whole thing

Important

The server below is the shape of the real protocol, kept small. A real server uses the official SDK, which also handles versions, cancellation, more content types and many more methods. Nothing is installed here.

Write the tool server, part 1

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const PRICES = { apple: 40, pear: 45 };' 'const TOOLS = [{ name: \"get_price\", description: \"Price of a shop item in pence\", inputSchema: { type: \"object\", properties: { item: { type: \"string\" } }, required: [\"item\"] } }];' 'function send(message) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, message)) + \"\n\"); }' 'function reply(id, result) { send({ id: id, result: result }); }' 'function fail(id, code, text) { send({ id: id, error: { code: code, message: text } }); }' 'function text(words, isError) { return { content: [{ type: \"text\", text: words }], isError: isError }; }' > server.js"

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

Write the tool server, part 2

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function call(id, params) {' '  if (params.name === \"get_price\") {' '    const price = PRICES[(params.arguments || {}).item];' '    if (price === undefined) return reply(id, text(\"No such item\", true));' '    return reply(id, text(price + \"p\", false));' '  }' '  return fail(id, -32602, \"Unknown tool: \" + params.name);' '}' 'function handle(msg) {' '  if (msg.id === undefined) return;' '  if (msg.method === \"initialize\") return reply(msg.id, { protocolVersion: \"2024-11-05\", capabilities: { tools: {} }, serverInfo: { name: \"shop\", version: \"0.1\" } });' '  if (msg.method === \"tools/list\") return reply(msg.id, { tools: TOOLS });' '  if (msg.method === \"tools/call\") return call(msg.id, msg.params || {});' '  return fail(msg.id, -32601, \"Method not found: \" + msg.method);' '}' >> server.js"

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

Write the tool server, part 3

Command
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (line) {' '  try { handle(JSON.parse(line)); } catch (error) { fail(null, -32700, \"Parse error\"); }' '});' >> server.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 server = require(\"child_process\").spawn(\"node\", [\"server.js\"]);' 'const call = function (n, name, args) { return { id: n, method: \"tools/call\", params: { name: name, arguments: args } }; };' 'const script = [' '  { id: 1, method: \"initialize\", params: { protocolVersion: \"2024-11-05\", capabilities: {}, clientInfo: { name: \"demo\", version: \"0.1\" } } },' '  { method: \"notifications/initialized\" },' '  { id: 2, method: \"tools/list\" },' '  call(3, \"get_price\", { item: \"pear\" }),' '  call(4, \"get_price\", { item: \"kiwi\" }),' '  call(5, \"delete_all\", {}),' '  { id: 6, method: \"resources/list\" },' '];' > client.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' 'let waiting = 6;' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (line) {' '  console.log(\"<- \" + line);' '  waiting -= 1;' '  if (waiting === 0) server.stdin.end();' '});' 'script.forEach(function (m) {' '  const full = Object.assign({ jsonrpc: \"2.0\" }, m);' '  console.log(\"-> \" + JSON.stringify(full));' '  server.stdin.write(JSON.stringify(full) + \"\n\");' '});' >> client.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 client.js

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

One possible output

RequestAnswer
initializeA result with the protocol version and the server name
tools/listA result listing get_price
get_price for a pearA result with the text 45p
get_price for a kiwiA result with isError true and No such item
delete_allAn error with code -32602
resources/listAn error with code -32601

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

A session has an order

The client sends initialize first and the server answers with what it supports. The client then sends the notification that it is ready. Only then does it ask for tools/list and use tools/call.

The agent shows the model the listed tools. When the model asks for one, the client sends tools/call.

Two kinds of failure

A protocol error is a JSON-RPC error response. The request itself was wrong: an unknown method, an unknown tool or bad JSON. Each has a code, such as -32601 for a missing method.

A tool error is a normal result with isError set to true. The call was fine but the tool could not do it. The model reads the text and can try something else.

A common mistake

Warning

Many people think every failure should be a JSON-RPC error. An error response says the request was malformed. A tool that ran and found no item should give a result with isError true. That way the model sees the reason and can recover.

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.