0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCP ツールに進捗通知(progressToken)とキャンセルを実装する — 進捗が出ない・キャンセル後もゾンビ処理が走る、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

自作 MCP サーバーに「30 秒以上かかるツール」を生やすと、クライアント側からは完全に無反応に見える。ユーザーは止まったと思って中断するし、中断してもサーバー側の処理は走り続ける。

これを直すのが MCP 仕様の 進捗通知(progress notification)キャンセル(cancellation) なのだが、実装例がほとんど落ちていない。実際に組んで動かし、詰まったところをまとめる。

  • 想定読者: 自作 MCP サーバーを持っていて、重いツール(クロール・ビルド・大量 API 呼び出し)を追加したい人
  • 前提環境: Node.js 22.x / TypeScript 5.x / @modelcontextprotocol/sdk 1.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 を受け取る。ここに _metasendNotificationsignal が全部入っている。

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 してくれるが、それは「フラグが立つ」だけ。fetchchild_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.signalfetch や子プロセスまで配線して初めて効く。
  • AbortError は握り潰さず throw する。キャンセル済みリクエストに応答を返さないのが仕様。
  • 検証は MCP Inspector が最速。ここで見えないものはクライアントでも見えない。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?