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
| Word | What it means | Example |
|---|---|---|
| JSON-RPC | a message format for calling methods, in JSON | |
| Request | a message with an id that expects an answer | |
| Notification | a message with no id and no answer | |
| stdio | standard input and output of a process | |
| Error code | a 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
| Kind | Example | Answer |
|---|---|---|
| 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
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
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
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
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
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
docker exec -w /home/node/project agent node client.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
One possible output
| Request | Answer |
|---|---|
| initialize | A result with the protocol version and the server name |
| tools/list | A result listing get_price |
| get_price for a pear | A result with the text 45p |
| get_price for a kiwi | A result with isError true and No such item |
| delete_all | An error with code -32602 |
| resources/list | An 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.