テストやビルドを AI に走らせ、結果を受け取れるようにします。08 章の通知と、10 章の考え方を全部使います。
| 出すもの | 名前 | すること |
|---|---|---|
| Resource | build://commands | 実行できるコマンドの一覧 |
| Tool | build_run | 名前を指定して実行する |
commands.json に定義します。コードを直さずに増やせます。
{
"test": {
"description": "テストを全件実行する",
"command": "node",
"args": ["--test", "tests/"]
},
"version": {
"description": "Node.js の版を表示する",
"command": "node",
"args": ["--version"]
}
}
// 名前で引ける形にしておく。ここに無いものは実行しない
const commands = JSON.parse(await readFile(COMMANDS_FILE, 'utf8'));
server.registerTool(
'build_run',
{
title: 'コマンドを実行する',
description: '決めておいたコマンドを名前で指定して実行する',
inputSchema: {
name: z.string().describe('commands.json に書いてある名前'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
},
},
async ({ name }, extra) => {
const spec = commands[name];
if (!spec) {
return {
content: [{
type: 'text',
text: `${name} は実行できません。使えるのは ${Object.keys(commands).join(' / ')} です`,
}],
isError: true,
};
}
// ...
},
);
断るときに「何なら使えるか」を書きます。「実行できません」だけだと、モデルは同じ名前で何度も試します。使える名前を並べれば、その中から選び直します。05 章の「失敗の返し方」と同じ考え方です。
// シェルを通さない。文字列を組み立てて渡すと、
// 引数に仕込まれた記号がそのまま命令になる
const child = spawn(spec.command, spec.args, { cwd: CWD, shell: false });
function run(spec, onLine) {
return new Promise((resolve) => {
const child = spawn(spec.command, spec.args, { cwd: CWD, shell: false });
const out = [];
let lines = 0;
const pick = (chunk) => {
const text = chunk.toString();
out.push(text);
for (const line of text.split('\n')) {
if (line.trim()) onLine(++lines, line.trim());
}
};
child.stdout.on('data', pick);
child.stderr.on('data', pick);
child.on('error', (err) => resolve({ code: -1, output: `起動できなかった: ${err.message}` }));
child.on('close', (code) => resolve({ code, output: out.join('') }));
});
}
error と close の両方を拾います。error はコマンドが見つからないときなど、そもそも起動できなかった場合に飛びます。これを拾い忘れると、Promise が解決されず永久に待ち続けます。
出力が 1 行出るたびに進捗として送ります。08 章で見た形です。
const token = extra._meta?.progressToken;
const { code, output } = await run(spec, (n, line) => {
if (token === undefined) return;
// 総数が分からないので total は付けない
extra.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress: n, message: line.slice(0, 80) },
}).catch(() => { /* 送れなくても本題は続ける */ });
});
| やっていること | 理由 |
|---|---|
total を付けない | 何行出るか事前に分からない。progress だけ増やせばよい |
slice(0, 80) | 長い行をそのまま流さない |
await しない | 通知の送信でコマンドの読み取りを止めない |
.catch() を付ける | 通知が失敗しても本題は続ける |
test('進捗通知が出力の行ごとに届く', async () => {
const client = await connect(sample('11-build/server.mjs'));
const progress = [];
try {
await client.callTool(
{ name: 'build_run', arguments: { name: 'slow' } },
undefined,
{ onprogress: (p) => progress.push(p) },
);
// 1 秒ごとに 1 行出るコマンドなので 3 回来る
assert.equal(progress.length, 3);
assert.equal(progress[0].message, '1');
assert.equal(progress[2].message, '3');
} finally {
await client.close();
}
});
✔ 実行できるコマンドの一覧を読める (465.2207ms)
✔ 決めたコマンドは実行でき、終了コードが 0 なら成功と返る (470.6477ms)
✔ 一覧に無いものは実行せずに断る (402.7811ms)
✔ 進捗通知が出力の行ごとに届く (3450.8708ms)
const seconds = ((Date.now() - started) / 1000).toFixed(1);
const ok = code === 0;
await toast(ok ? `${name} 成功` : `${name} 失敗`, `${seconds} 秒 / 終了コード ${code}`);
// 出力が長いと会話を埋めてしまう。末尾だけ返す
const tail = output.split('\n').slice(-30).join('\n');
return {
content: [{
type: 'text',
text: `${ok ? '成功' : '失敗'}(終了コード ${code}、${seconds} 秒)\n\n${tail}`,
}],
isError: !ok,
};
トーストは 08 章のものを使い回します。失敗しても本題は止めません。
async function toast(title, message) {
if (!TOAST) return;
try {
await execFileAsync('powershell', [ /* ... */ ]);
} catch {
// 知らせられなくても本題は済んでいる。落とさない
}
}
通知は既定で切っておきます。環境変数 BUILD_TOAST=1 のときだけ出す形にしました。テストのたびに画面へトーストが出ては邪魔になります。人の環境を邪魔する機能は、既定で切るのが作法です。
{
"mcpServers": {
"build": {
"command": "node",
"args": ["docs/samples/11-build/server.mjs"],
"env": { "BUILD_TOAST": "1" }
}
}
}
「テストを流して」と頼めば実行され、進み具合が表示され、終わればトーストが出ます。
コマンドを実行するサーバーは、作れるものの中で最も危ないものです。線を引いておきます。
| やってはいけないこと | なぜ |
|---|---|
| 任意のコマンドを受け取って実行する | モデルが誤ったものを渡せば、そのまま実行される |
引数を文字列で組み立てて shell: true で渡す | 記号が命令として解釈される |
| 作業場所を呼ぶ側に決めさせる | どこでも実行できてしまう |
| 結果に環境変数をそのまま載せる | 認証情報が会話に流れる |
| 認証なしで HTTP に出す | 同じネットワークの誰でも実行できる |
ツールの説明文は信用されない前提で設計します。仕様にも「注釈は信用できないものとして扱え」とあります。「危険なので確認してください」と description に書いても、それはお願いにすぎません。実際に守らせるには、サーバー側で実行できる範囲を絞るしかありません。
| 章 | できるようになったこと |
|---|---|
| 02・03 | JSON-RPC で何が流れているか分かる。SDK でも手書きでも書ける |
| 04 | 3 つのホストに登録し、動かないときに切り分けられる |
| 05・06 | Tools・Resources・Prompts を使い分け、失敗を正しく返せる |
| 07・10 | stdio と HTTP を選び、1 本を共有できる |
| 08・09 | 伝えられること・伝えられないことの線が引ける |
| 11 | 危ないものを、危なくない形にして出せる |
詰まったときは A2. 詰まったとき を、やり方を忘れたときは A1. 逆引き を引いてください。Node.js を入れられない環境へ配る話は A3. 単一 exe にする にあります。