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
| Word | What it means | Example |
|---|---|---|
| Scope | a label for one thing a caller may do | orders:write |
| Grant | a scope the caller holds | |
| Deny | refuse a call because a scope is missing | |
| Wildcard | a grant that covers every scope with the same start | orders:* |
| Audit log | a 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
| Tool | Needs | Caller holds menu:read, orders:write | Decision |
|---|---|---|---|
| read_menu | menu:read | Covered | Allow |
| place_order | orders:write | Covered | Allow |
| cancel_all | orders:admin | Not covered | Deny |
| burn_kitchen | No such tool | Not applicable | Unknown |
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
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 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
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
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
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
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
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
docker exec -w /home/node/project agent node client5.jsShown 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
| Call | Answer | Log decision |
|---|---|---|
| tools/list | Only read_menu and place_order | Not a tool call, so not logged |
| read_menu | done: read_menu | allow |
| place_order | done: place_order | allow |
| cancel_all | Not allowed: cancel_all needs orders:admin | deny |
| burn_kitchen | Protocol error -32602 | unknown |
The cells students fill in are left blank in this preview.