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 tools with input validation

After this lesson: I can offer a tool whose input is checked before it runs.

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

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

WordWhat it meansExample
Schemaa description of what the input must look like
Argumentone named value in a tool call
Validatecheck the input before using it
Enuma fixed list of allowed valuespasta, wrap, salad
Tool errora 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

ArgumentRulePassesFails
itemRequired. One of pasta, wrap, saladwrapsoup
quantityRequired. A whole number, 1 to 529, or "2" as text
noteOptional text, at most 20 charactersno onionsA 21 character note
any other nameNot allowedNonecolour

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

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' '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

Command
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

Command
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

Command
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

Command
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

Command
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

Command
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

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

Shown 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

CallAnswer
wrap, quantity 2Ordered 2 wrap, isError false
soup, quantity 9, colour redThree problems in one sentence, isError true
no argumentsmissing: item; missing: quantity
quantity as text "2"quantity: expected integer
delete_menuProtocol error -32602

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.