How an MCP tool checks its input
An MCP tool has a name, a description and an input schema. The schema says what its arguments must look like. The server checks the arguments against it before anything runs.
This matters because a model fills in the arguments, and a model sometimes gets them wrong. A good refusal tells it what to fix.
By the end of this lesson
Note
I can offer a tool whose input is checked before it runs.
Key words
| Word | What it means | Example |
|---|---|---|
| Schema | a description of what the input must look like | |
| Argument | one named value in a tool call | |
| Validate | check the input before using it | |
| Enum | a fixed list of allowed values | pasta, wrap, salad |
| Tool error | a result with isError true that says what went wrong |
The cells students fill in are left blank in this preview.
A schema lists the rules for each argument
Each argument has a type. It may also have an enum, a range of numbers or a longest length. The schema names which arguments are required, and may say that no others are allowed.
The server checks each rule in a fixed order and collects every problem, not only the first.
Worked example: the order_lunch tool
| Argument | Rule | Passes | Fails |
|---|---|---|---|
| item | Required. One of pasta, wrap, salad | wrap | soup |
| quantity | Required. A whole number, 1 to 5 | 2 | 9, or "2" as text |
| note | Optional text, at most 20 characters | no onions | A 21 character note |
| any other name | Not allowed | None | colour |
The cells students fill in are left blank in this preview.
A refusal tells the model what to fix
A call with bad arguments is not a broken request. The request was fine. So the server sends a normal result with isError true and a sentence that lists the problems.
A call for a tool that does not exist is different. That is a protocol error, code -32602.
A common mistake
Warning
Many people think the model always follows the schema, so the server need not check. A model can leave a name out, send text for a number or add one it made up. The client may not check either. The server is the last place to check, so it always does.
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' 'const SCHEMA = { type: \"object\", properties: { item: { type: \"string\", enum: [\"pasta\", \"wrap\", \"salad\"] }, quantity: { type: \"integer\", minimum: 1, maximum: 5 }, note: { type: \"string\", maxLength: 20 } }, required: [\"item\", \"quantity\"], additionalProperties: false };' 'function send(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function typeOk(r, v) { return r.type === \"string\" ? typeof v === \"string\" : Number.isInteger(v); }' > server3.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 problems(args) {' ' const out = [];' ' SCHEMA.required.forEach(function (n) { if ((n in args) === false) out.push(\"missing: \" + n); });' ' Object.keys(SCHEMA.properties).forEach(function (n) {' ' if ((n in args) === false) return;' ' const r = SCHEMA.properties[n], v = args[n];' ' if (typeOk(r, v) === false) return out.push(n + \": expected \" + r.type);' ' if (r.enum && r.enum.indexOf(v) < 0) out.push(n + \": must be one of \" + r.enum.join(\", \"));' ' if (typeof r.minimum === \"number\" && (v < r.minimum || v > r.maximum)) out.push(n + \": must be between \" + r.minimum + \" and \" + r.maximum);' ' if (typeof r.maxLength === \"number\" && v.length > r.maxLength) out.push(n + \": at most \" + r.maxLength + \" characters\");' ' });' ' Object.keys(args).sort().forEach(function (n) { if ((n in SCHEMA.properties) === false) out.push(\"extra: \" + n); });' ' return out;' '}' >> server3.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' 'function words(text, bad) { return { content: [{ type: \"text\", text: text }], isError: bad }; }' 'function handle(msg) {' ' if (msg.id === undefined) return;' ' const p = msg.params || {};' ' if (msg.method === \"initialize\") return send({ id: msg.id, result: { protocolVersion: p.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: \"lunch\", version: \"0.3\" } } });' ' if (msg.method === \"tools/list\") return send({ id: msg.id, result: { tools: [{ name: \"order_lunch\", description: \"Order lunch from the canteen\", inputSchema: SCHEMA }] } });' >> server3.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 4
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' if (p.name === \"order_lunch\") {' ' const args = p.arguments || {};' ' const found = problems(args);' ' if (found.length > 0) return send({ id: msg.id, result: words(\"Could not run order_lunch: \" + found.join(\"; \"), true) });' ' return send({ id: msg.id, result: words(\"Ordered \" + args.quantity + \" \" + args.item, false) });' ' }' ' send({ id: msg.id, error: { code: -32602, message: \"Unknown tool: \" + p.name } });' '}' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (line) { handle(JSON.parse(line)); });' >> server3.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\", [\"server3.js\"]);' 'const order = function (id, args) { return { id: id, method: \"tools/call\", params: { name: \"order_lunch\", arguments: args } }; };' 'const script = [' ' { id: 1, method: \"initialize\", params: { protocolVersion: \"2025-03-26\" } },' ' { method: \"notifications/initialized\" },' ' order(2, { item: \"wrap\", quantity: 2 }),' ' order(3, { item: \"soup\", quantity: 9, colour: \"red\" }),' ' order(4, {}),' ' order(5, { item: \"pasta\", quantity: \"2\" }),' ' { id: 6, method: \"tools/call\", params: { name: \"delete_menu\", arguments: {} } },' '];' > client3.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' 'const seen = [];' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (line) {' ' const m = JSON.parse(line);' ' console.log(\"<- \" + (m.result && m.result.content ? m.result.content[0].text + \" (isError \" + m.result.isError + \")\" : line));' ' seen.push(m);' ' if (seen.length === 6) server.stdin.end();' '});' 'script.forEach(function (m) { server.stdin.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); });' >> client3.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 shown = seen[2].result.content[0].text;' ' const r = await ask([{ role: \"user\", content: \"A tool refused a call. In one sentence, what should the caller change? \" + shown }], \"Use one of pasta, wrap or salad, a quantity from 1 to 5, and no colour.\", 60);' ' console.log(\"model: \" + r.text);' '});' >> client3.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 client3.jsShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
What the script calls
The script starts a session and then calls order_lunch five times and a tool that does not exist once. One call is good. The others break one rule, several rules, or all the required names.
A short model call at the end reads one refusal and says what to change. With no model it prints a Recorded sample.
What each call gets back
| Call | Answer |
|---|---|
| wrap, quantity 2 | Ordered 2 wrap, isError false |
| soup, quantity 9, colour red | Three problems in one sentence, isError true |
| no arguments | missing: item; missing: quantity |
| quantity as text "2" | quantity: expected integer |
| delete_menu | Protocol error -32602 |
The cells students fill in are left blank in this preview.