11. ハンズオン② ビルド実行

コマンドを実行し、進み具合を知らせ、終わったら画面にも出す

📅 作成: 2026-09-07 / 更新: 2026-09-07

この資料の内容

  1. 何を作るか
  2. 実行してよいものを決める
  3. 進み具合を伝える
  4. 終わったら知らせる
  5. 越えてはいけない線

何を作るか

テストやビルドを AI に走らせ、結果を受け取れるようにします。08 章の通知と、10 章の考え方を全部使います。

出すもの名前すること
Resourcebuild://commands実行できるコマンドの一覧
Toolbuild_run名前を指定して実行する
「テスト流して」 利用者 build_run name: "test" 一覧と照らす あれば実行 無ければ断る 進捗を送る 1 行ごと 終わったら トーストを出す 結果は末尾 30 行だけ返す。全部返すと会話が埋まる
「何でも実行できる」ではなく「決めたものだけ実行できる」形にする。理由は第 5 章。

実行してよいものを外に書く

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 });
shell: true + 文字列を組み立てる exec(`node build.js ${userInput}`) userInput が "x & del /s /q C:\\" だったら、その後ろも実行される shell: false + 配列で渡す spawn('node', ['build.js', userInput], { 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('') }));
	});
}

errorclose の両方を拾います。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)
子プロセス stdout / stderr 1 行ずつ出る サーバー 溜めつつ 1 行ごとに通知 進捗(途中で何度も) notifications/progress 結果(最後に 1 回) 末尾 30 行だけ
途中は通知、最後は結果。全出力を結果に詰め込まない。

終わったら知らせる

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 のときだけ出す形にしました。テストのたびに画面へトーストが出ては邪魔になります。人の環境を邪魔する機能は、既定で切るのが作法です。

返す量を絞る

全部返すと 1 件ごとの ✔ が 500 行 大半は「通った」の羅列 肝心の失敗が埋もれる 末尾だけ返すと 「成功(終了コード 0、5.1 秒)」 + 集計と失敗の内訳 必要なものが先に来る テストの出力は末尾に集計が来る。だから末尾を残すのが理にかなう
何を残すかは対象による。テストなら末尾、ビルドのエラーなら該当箇所の周辺。

登録して使う

{
	"mcpServers": {
		"build": {
			"command": "node",
			"args": ["docs/samples/11-build/server.mjs"],
			"env": { "BUILD_TOAST": "1" }
		}
	}
}

「テストを流して」と頼めば実行され、進み具合が表示され、終わればトーストが出ます。

越えてはいけない線

コマンドを実行するサーバーは、作れるものの中で最も危ないものです。線を引いておきます。

やってはいけないことなぜ
任意のコマンドを受け取って実行するモデルが誤ったものを渡せば、そのまま実行される
引数を文字列で組み立てて shell: true で渡す記号が命令として解釈される
作業場所を呼ぶ側に決めさせるどこでも実行できてしまう
結果に環境変数をそのまま載せる認証情報が会話に流れる
認証なしで HTTP に出す同じネットワークの誰でも実行できる
任意のコマンドを受ける command: "何でも書ける" 安全かどうかを判断するのは モデルとホストの承認だけ 承認を切っている環境では素通り 名前で選ばせる name: "test" | "version" | "slow" 実行できる範囲を決めるのは commands.json を書いた人 承認の設定に関わらず範囲が保たれる 「何を実行できるか」を、実行時ではなく設計時に決める
ホスト側の承認は最後の砦であって、最初の砦ではない。範囲はサーバー側で決める。

ツールの説明文は信用されない前提で設計します。仕様にも「注釈は信用できないものとして扱え」とあります。「危険なので確認してください」と description に書いても、それはお願いにすぎません。実際に守らせるには、サーバー側で実行できる範囲を絞るしかありません。

ここまでで身に付いたこと

できるようになったこと
02・03JSON-RPC で何が流れているか分かる。SDK でも手書きでも書ける
043 つのホストに登録し、動かないときに切り分けられる
05・06Tools・Resources・Prompts を使い分け、失敗を正しく返せる
07・10stdio と HTTP を選び、1 本を共有できる
08・09伝えられること・伝えられないことの線が引ける
11危ないものを、危なくない形にして出せる

詰まったときは A2. 詰まったとき を、やり方を忘れたときは A1. 逆引き を引いてください。Node.js を入れられない環境へ配る話は A3. 単一 exe にする にあります。