誰が何を呼べるかをどう限るか
注文を出すツールと、すべての注文を取り消すツールを、同じ呼び出し側に開いてはいけません。サーバーはスコープで限ります。スコープは、呼び出し側にできることごとの短いラベルです。
標準入出力で動くサーバーには、サインインのページがありません。サーバーを起動する人が、持っているスコープを伝え、サーバーは呼び出しのたびに確かめます。
このレッスンのゴール
メモ
誰が頼んでいるかに応じて、サーバーのツールを限ることができる。
キーワード
| ことば | いみ | れい |
|---|---|---|
| スコープ | 呼び出し側にできる一つのことのラベル | orders:write |
| 付与 | 呼び出し側が持つスコープ | |
| 拒否 | スコープが足りないので、呼び出しを断ること | |
| ワイルドカード | 同じ書き出しのスコープすべてをまかなう付与 | orders:* |
| 監査ログ | すべての呼び出しと判断の記録 |
学生が入力するセルは、このプレビューでは空白です。
ツールは、要るスコープを名指しする
サーバーは表を持ちます。ツールごとに、要るスコープが一つあります。呼び出し側は、付与のリストを持ちます。付与がスコープをまかなっていれば、呼び出しは許されます。
orders: のようなワイルドカードは、orders: で始まるスコープすべてをまかない、ほかはまかないません。付与 はすべてをまかないます。
例: 呼び出し側が二つの付与を持つ
| ツール | 要るもの | 持つのは menu:read、orders:write | 判断 |
|---|---|---|---|
| read_menu | menu:read | まかなわれる | allow |
| place_order | orders:write | まかなわれる | allow |
| cancel_all | orders:admin | まかなわれない | deny |
| burn_kitchen | そのツールはない | 該当しない | unknown |
学生が入力するセルは、このプレビューでは空白です。
隠すことは、断ることではない
呼び出し側が使えないツールを、サーバーは tools/list から外してもかまいません。モデルが試さずにすみます。ただし、見せられなかったツールの呼び出しも、送ることはできます。
だから確認は、tools/call のたびに、必ず行います。スコープが足りなければ、そのスコープを名指ししたツールのエラーを返します。知らないツールは、これまでどおりプロトコルのエラーです。
よくある間違い
注意
危ないツールを一覧から隠して、それを安全だと言う人は多いです。隠したツールは、まだそこにあります。名前を知る人は誰でも呼べます。呼び出しのたびにスコープを確かめ、動いた呼び出しと同じく、断った呼び出しも記録します。
モデル用の補助を書く
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(\"記録済みサンプル: モデルが答えませんでした (\" + error.message + \")。\");' ' return { text: sample, recorded: true };' ' }' '}' 'module.exports = { ask };' > model.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
サーバーを書く(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"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
サーバーを書く(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"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
サーバーを書く(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: \"食堂のツール \" + n, inputSchema: { type: \"object\" } };' ' });' '}' 'function handle(msg) {' ' if (msg.id === undefined) return;' >> server5.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
サーバーを書く(4)
docker exec -w /home/node/project agent sh -c "printf '%s\n' ' 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"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
台本のクライアントを書く(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"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
台本のクライアントを書く(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"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
台本のクライアントを書く(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: \"この拒否を学生に一文で説明してください: \" + refusal }], \"あなたのアカウントには、すべての注文を取り消す権限がありません。\", 60);' ' console.log(\"モデル: \" + r.text);' '});' >> client5.js"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
クライアントをサーバーに向けて動かす
docker exec -w /home/node/project agent node client5.jsここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
台本がすること
クライアントは、環境に menu:read と orders:write のスコープを入れて、サーバーを起動します。ツールを一覧し、あるツールを三つと、ないツールを一つ呼びます。
最後にサーバーのログを表示します。モデルが、拒否の一つを一文で説明します。モデルがないと「記録済みサンプル」になります。
呼び出しごとの答えと記録
| 呼び出し | 答え | ログの判断 |
|---|---|---|
| tools/list | read_menu と place_order だけ | ツール呼び出しでないので、記録なし |
| read_menu | done: read_menu | allow |
| place_order | done: place_order | allow |
| cancel_all | Not allowed: cancel_all needs orders:admin | deny |
| burn_kitchen | プロトコルのエラー -32602 | unknown |
学生が入力するセルは、このプレビューでは空白です。