はじめに / 対象と前提
自作 MCP サーバーに「30 秒以上かかるツール」を生やすと、クライアント側からは完全に無反応に見える。ユーザーは止まったと思って中断するし、中断してもサーバー側の処理は走り続ける。
これを直すのが MCP 仕様の 進捗通知(progress notification) と キャンセル(cancellation) なのだが、実装例がほとんど落ちていない。実際に組んで動かし、詰まったところをまとめる。
- 想定読者: 自作 MCP サーバーを持っていて、重いツール(クロール・ビルド・大量 API 呼び出し)を追加したい人
-
前提環境: Node.js 22.x / TypeScript 5.x /
@modelcontextprotocol/sdk1.x 系 - 動作確認: MCP Inspector と Claude Code v2 系
- 前提知識: stdio 接続の MCP サーバーを 1 本立ち上げたことがある程度
TL;DR
- 進捗は クライアントが
_meta.progressTokenを送ってきたときだけ 返す。無条件に送っても捨てられる。 -
progressの値は 通知のたびに必ず増やす。同じ値を 2 回送ると無視される。 - キャンセルは
extra.signal(AbortSignal)を 下流の処理まで配線しないと止まらない。AbortErrorを try/catch で握り潰すのも NG。
進捗通知はどう流れるか
ポイントは、トークンを発行するのはクライアント側という点。サーバーが勝手に採番するものではない。
手順 / 動かし方
1. ツールハンドラで progressToken を拾う
registerTool(旧 SDK の server.tool() と同じもの)の第 3 引数ハンドラは、第 2 引数に extra を受け取る。ここに _meta と sendNotification と signal が全部入っている。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "heavy-tools", version: "1.0.0" });
server.registerTool(
"crawl_pages",
{
title: "URL を順に取得する",
inputSchema: { urls: z.array(z.string().url()).max(50) },
},
async ({ urls }, extra) => {
const token = extra._meta?.progressToken; // 無いこともある
const results: string[] = [];
for (const [i, url] of urls.entries()) {
// ここが肝。signal を fetch まで通す
const res = await fetch(url, { signal: extra.signal });
results.push(`${url} -> ${res.status}`);
if (token !== undefined) {
await extra.sendNotification({
method: "notifications/progress",
params: {
progressToken: token,
progress: i + 1, // 必ず単調増加
total: urls.length,
message: `取得中: ${url}`,
},
});
}
}
return { content: [{ type: "text", text: results.join("\n") }] };
}
);
2. 動作確認する
MCP Inspector が一番早い。ツールを実行すると進捗が逐次表示される。
npx @modelcontextprotocol/inspector node ./dist/server.js
Inspector で進捗が出れば実装は正しい。そのうえで Claude Code に繋ぐ。
claude mcp add heavy-tools -- node /abs/path/to/dist/server.js
claude mcp list # ✓ connected を確認
ハマりどころ
1. progressToken が無いのに送って、通知が全部消える
最初は progressToken: "task-1" のように自分で固定文字列を採番していた。結果、通知は 1 件も表示されない。
Received a progress notification for an unknown token
進捗トークンは リクエストごとにクライアントが発行する使い捨ての値で、サーバー側が知る手段は _meta.progressToken しかない。クライアントが送ってこなければ「進捗はいらない」という意思表示なので、素直に送らないのが正解。上のコードのように token !== undefined で必ず分岐する。
2. progress を増やし忘れて、途中から進捗が止まる
「全体の何 % か」を送ろうとして Math.floor(i / total * 100) を渡したところ、件数が多いときに同じ値が連続して出た。この重複した通知は捨てられ、UI 上は進捗バーが固まったように見える。
仕様上 progress は 通知ごとに増加していることが要求される。パーセントを送りたいなら整数に丸めた時点で重複しうるので、素直に処理済み件数を progress、総数を total に入れてクライアント側に割り算させたほうが安全。
もうひとつ、total を途中で変えないこと。「実は 50 件じゃなく 80 件だった」と後から増やすと、進捗バーが巻き戻って壊れて見える。総数が確定しないなら total を省略し、message に状況を書く。
3. キャンセルしたのにサーバー側が走り続ける(ゾンビ処理)
一番ハマったのがこれ。クライアントで中断しても、サーバーのログには 50 件ぶんの取得が最後まで流れ続けた。
原因は 2 つあった。
(a) signal を下流に渡していない
SDK は notifications/cancelled を受け取ると extra.signal を abort してくれるが、それは「フラグが立つ」だけ。fetch や child_process に渡していなければ処理は止まらない。ループを回すだけの処理なら、各イテレーションの先頭でも見る。
for (const url of urls) {
extra.signal.throwIfAborted(); // 中断済みなら即座に抜ける
await fetch(url, { signal: extra.signal });
}
(b) AbortError を握り潰していた
「エラーは全部ツール結果として返す」つもりで、こう書いていた。
// NG: キャンセル済みリクエストに応答を返してしまう
try {
await doWork(extra.signal);
} catch (e) {
return { content: [{ type: "text", text: `失敗: ${e}` }], isError: true };
}
MCP 仕様では、キャンセルされたリクエストにレスポンスを返してはいけない。返すとクライアント側は「知らない ID の応答」を受け取ることになり、実装によっては警告が出る。AbortError はそのまま throw して SDK に処理させるのが正しい。
try {
await doWork(extra.signal);
} catch (e) {
if (extra.signal.aborted) throw e; // キャンセルはそのまま伝播
return { content: [{ type: "text", text: `失敗: ${e}` }], isError: true };
}
背景・補足
進捗通知もキャンセルも 通知(notification) であって要求(request)ではないので、応答が返らない。送りっぱなしで届いたか確認できないため「無視されている」状態に気づきにくく、Inspector での目視確認が事実上の唯一の検証手段になる。
また、キャンセル通知とレスポンスが行き違うのは仕様上ありうる。「もう返した後だった」場合は受け取った側が無視してよい。キャンセルすればサーバーが必ず即死する、という前提でクライアントを書いてはいけない。
まとめ
-
_meta.progressTokenが 来たときだけnotifications/progressを送る。自前採番は無意味。 -
progressは単調増加。パーセントより処理済み件数を送るほうが事故らない。totalは途中で変えない。 - キャンセルは
extra.signalを fetch や子プロセスまで配線して初めて効く。 -
AbortErrorは握り潰さず throw する。キャンセル済みリクエストに応答を返さないのが仕様。 - 検証は MCP Inspector が最速。ここで見えないものはクライアントでも見えない。