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

Free lesson

Limiting MCP tools by scope

After this lesson: I can limit a server's tools by who is asking.

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

How an MCP server limits who calls what

A tool that places an order and a tool that cancels every order should not be open to the same caller. A server limits this with scopes: a short label for each thing a caller may do.

A server run over standard input and output has no sign-in page. Whoever starts it says which scopes the caller holds, and the server checks them on every call.

By the end of this lesson

Note

I can limit a server's tools by who is asking.

Key words

WordWhat it meansExample
Scopea label for one thing a caller may doorders:write
Granta scope the caller holds
Denyrefuse a call because a scope is missing
Wildcarda grant that covers every scope with the same startorders:*
Audit loga record of every call and what was decided

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

Each tool names the scope it needs

The server keeps a table: each tool and the one scope it needs. The caller holds a list of grants. A call is allowed when a grant covers the scope.

A wildcard grant such as orders: covers every scope that starts with orders: and nothing else. The grant covers everything.

Worked example: a caller holds two grants

ToolNeedsCaller holds menu:read, orders:writeDecision
read_menumenu:readCoveredAllow
place_orderorders:writeCoveredAllow
cancel_allorders:adminNot coveredDeny
burn_kitchenNo such toolNot applicableUnknown

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

Hiding a tool is not refusing it

The server may leave tools out of tools/list when the caller cannot use them. That saves the model from trying. But a caller can still send a call for a tool it was never shown.

So the check happens on every tools/call, always. A missing scope gives a tool error with the scope named. An unknown tool is still a protocol error.

A common mistake

Warning

Many people hide the dangerous tool from the list and call that security. A hidden tool is still there. Anyone who knows its name can call it. Check the scope on every call, and log the calls that are refused as well as the ones that run.

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 NEEDS = { read_menu: \"menu:read\", place_order: \"orders:write\", cancel_all: \"orders:admin\" };' 'const HAVE = (process.env.SCOPES || \"\").split(\",\");' 'let count = 0;' 'function send(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function words(text, bad) { return { content: [{ type: \"text\", text: text }], isError: bad }; }' 'function note(tool, decision) {' '  count += 1;' '  process.stderr.write(JSON.stringify({ n: count, tool: tool, decision: decision, needs: NEEDS[tool] || null }) + \"\n\");' '}' > server5.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 call(msg, p) {' '  if (Object.keys(NEEDS).indexOf(p.name) < 0) {' '    note(p.name, \"unknown\");' '    return send({ id: msg.id, error: { code: -32602, message: \"Unknown tool: \" + p.name } });' '  }' '  if (HAVE.indexOf(NEEDS[p.name]) < 0) {' '    note(p.name, \"deny\");' '    return send({ id: msg.id, result: words(\"Not allowed: \" + p.name + \" needs \" + NEEDS[p.name], true) });' '  }' '  note(p.name, \"allow\");' '  send({ id: msg.id, result: words(\"done: \" + p.name, false) });' '}' >> server5.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 listed() {' '  return Object.keys(NEEDS).filter(function (n) { return HAVE.indexOf(NEEDS[n]) >= 0; }).map(function (n) {' '    return { name: n, description: \"Canteen tool \" + n, inputSchema: { type: \"object\" } };' '  });' '}' '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.5\" } } });' '  if (msg.method === \"tools/list\") return send({ id: msg.id, result: { tools: listed() } });' '  if (msg.method === \"tools/call\") return call(msg, p);' '  send({ id: msg.id, error: { code: -32601, message: \"Method not found: \" + msg.method } });' '}' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (line) { handle(JSON.parse(line)); });' >> server5.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 env = Object.assign({}, process.env, { SCOPES: \"menu:read,orders:write\" });' 'const server = require(\"child_process\").spawn(\"node\", [\"server5.js\"], { env: env });' 'const call = function (id, name) { return { id: id, method: \"tools/call\", params: { name: name, arguments: {} } }; };' 'const script = [' '  { id: 1, method: \"initialize\", params: { protocolVersion: \"2025-03-26\" } },' '  { id: 2, method: \"tools/list\" },' '  call(3, \"read_menu\"), call(4, \"place_order\"), call(5, \"cancel_all\"), call(6, \"burn_kitchen\"),' '];' > client5.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 = [];' 'const log = [];' 'require(\"readline\").createInterface({ input: server.stderr }).on(\"line\", function (line) { log.push(line); });' 'require(\"readline\").createInterface({ input: server.stdout }).on(\"line\", function (line) {' '  const m = JSON.parse(line);' '  const names = m.result && m.result.tools ? \"tools: \" + m.result.tools.map(function (t) { return t.name; }).join(\", \") : null;' '  console.log(\"<- \" + (names || (m.result && m.result.content ? m.result.content[0].text : JSON.stringify(m.error || m.result))));' '  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\"); });' >> client5.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 () {' '  log.forEach(function (l) { console.log(\"log \" + l); });' '  const refusal = seen[4].result.content[0].text;' '  const r = await ask([{ role: \"user\", content: \"In one short sentence, explain this refusal to a student: \" + refusal }], \"Your account has no permission to cancel every order.\", 60);' '  console.log(\"model: \" + r.text);' '});' >> client5.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 client5.js

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

What the script does

The client starts the server with the scopes menu:read and orders:write in its environment. It lists the tools, then calls three that exist and one that does not.

It prints the server's log last. A short model call explains one refusal. With no model it prints a Recorded sample.

What each call returns and logs

CallAnswerLog decision
tools/listOnly read_menu and place_orderNot a tool call, so not logged
read_menudone: read_menuallow
place_orderdone: place_orderallow
cancel_allNot allowed: cancel_all needs orders:admindeny
burn_kitchenProtocol error -32602unknown

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.