CodeGym /コース /ChatGPT Apps /MCPにおけるイベントモデル: 通知の種類、メッセージ形式、冪等性

MCPにおけるイベントモデル: 通知の種類、メッセージ形式、冪等性

ChatGPT Apps
レベル 13 , レッスン 0
使用可能

1. なぜ MCP のイベントが必要なのか

ここまで、ChatGPT とあなたのバックエンドのやり取りはほぼ RPC でした。すなわち、モデルがツールを呼び出し、ツールが何かを実行して結果を返す——完了、という流れです。これは処理が短時間(200–500ms、多くても数秒)で済む間は便利です。

しかし、長時間処理が出てくると——たとえば GiftGenius で従業員の嗜好が入った大きなファイルを分析、外部 API 群のレコメンドを集約、大きなフィードの再計算——一気に厄介になります。HTTP タイムアウト、関数の再起動、終わらないスピナー、そしてユーザーは「まだ生きているのか、もう死んだのか?」と不安になります。

そこで登場するのがイベントモデルです。長いツール呼び出しを維持する代わりに、ジョブを起動して jobId を受け取り、その後はサーバーが自発的にイベントを送ります。開始、進行中、完了、失敗。これらのイベントは MCP では JSON-RPC の notification(id のない片方向メッセージ)として実装され、応答は期待しません。

重要なのは、イベントは「回線上の console.log」ではないということです。定義済みのスキーマを持つプロトコルの正式なメッセージであり、あなたの UI(ウィジェット)やエージェントはツールの結果と同じ規律で処理できなければなりません。

おさらい: MCP のメッセージ種別

先へ進む前に、MCP におけるメッセージの種類を軽く復習しましょう。

マーケティング的な層を取り払うと、MCP は JSON-RPC 2.0 に基づいています。そこには基本的に3種類のメッセージ、リクエスト、レスポンス、そして通知があります。

箇条書きにせず、小さな比較表で見てみます。

種別 フィールド id イニシエーター 応答は必要か? MCP の例
Request あり 通常はクライアント(ChatGPT) はい ツール呼び出し tools/call
Response あり MCP サーバー それ自体が応答 tools/call の結果
Notification なし クライアントまたはサーバー いいえ notifications/progressresources/updatedlogging/message

MCP のイベントはこの3行目、すなわち notifications に該当します。特徴は以下の通りです。

  • トップレベルに id がない——resulterror が返ってくることはありません;
  • 送信者は ACK を待ちません——プロトコルレベルでは「fire-and-forget」です;
  • 信頼性は確認応答ではなく、ハンドラの冪等性とリトライ方針で担保します。

重要な制約: MCP のイベントは「いつでもどこでも」飛ぶわけではありません。特定のトランスポート上に確立された MCP 接続の内部に存在します。多くの場合、SSE に似たストリームです(トランスポートの詳細とバリエーションは別の講義で扱います)。

2. 実務的に「MCP イベント」とは何か

形式的には MCP イベントは JSON-RPC の notification、すなわち次のようなオブジェクトです。

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "jobId": "job_123",
    "percentage": 30,
    "stage": "カタログで候補を検索中",
    "eventId": "evt_abc123",
    "timestamp": "2025-11-21T10:15:00Z"
  }
}

ここでの重要ポイントは複数あります。

  1. method フィールドにはイベントの種類とその「名前空間」を符号化します。MCP にはすでにログ、進捗、リソース更新用の notifications/... 形式の標準メソッドがいくつかありますが、notifications/job/progressnotifications/job/completed のように、ビジネス固有のメソッドを追加すべきです。
  2. すべての業務データは params に入ります。ここにジョブの識別子(jobId)、イベントの一意 ID(eventId)、時刻(timestamp)、人が読めるメッセージなどを保持します。
  3. トップレベルに id がありません——だからこそ notification です。プロトコルとしての応答は想定されていません。もしサーバーが「理解されたか」を知りたければ、別のイベントを送るかクライアントの反応(新しいリクエストなど)を待つことはできますが、JSON-RPC でいう ACK はありません。

メンタルモデルとしてはこう考えられます。ツール呼び出し tools/call は「返信を待つ手紙」、一方イベントは「Slack ボットからの通知:「バックグラウンドタスク #123 が完了しました」」です。

3. イベントのタクソノミー: 通知の種類

何でも JSON を notifications として送ってよいことにすると、2週間でシステムは混沌とします。イベント名はバラバラ、フィールドも揺れ、UI はどう扱えばいいか分からない——そこで小さなタクソノミーに合意しておくと有益です。

以下は MCP 仕様や実際の ChatGPT Apps のユースケースによく適合する、扱いやすい分類の一例です。

ジョブのライフサイクルイベント(Job Lifecycle)

タスクの状態遷移を表すイベントです。通常、タスクには pendingrunning → (completed | failed | canceled) のような状態機械(state machine)があります。

典型的なイベント:

  • job.created — タスクが登録された;
  • job.started — ワーカーが処理を開始した;
  • job.completed — タスクが正常終了した;
  • job.failed — タスクがエラーで失敗した;
  • job.canceled — タスクがユーザーによりキャンセルされた。

GiftGenius における job.completed の例:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/completed",
  "params": {
    "eventId": "evt_gg_100",
    "jobId": "giftjob_42",
    "timestamp": "2025-11-21T10:20:00Z",
    "summary": "ギフト選定が完了しました",
    "resultResourceId": "resource:gifts:giftjob_42"
  }
}

ここで resultResourceId は、後でウィジェットやエージェントが読み取る MCP リソースを指せます。

進捗イベント(Progress Updates)

ライフサイクルの中の「小さなステップ」です。最終ステータスは変えませんが、ユーザーに「何かが進んでいる」感覚を与えます。

典型的な job.progress イベント:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "eventId": "evt_gg_101",
    "jobId": "giftjob_42",
    "timestamp": "2025-11-21T10:18:30Z",
    "percentage": 40,
    "stage": "予算でギフトをフィルタリング中",
    "etaSeconds": 25
  }
}

ここで重要なのは、percentage100 に向かって合理的に増えていくことです。あちこちに跳ねないようにしてください。進捗用フィールド名(例: percentage)を1つに決め、すべてのイベントでそれを用いましょう。公式の MCP 進捗ユーティリティにも「進捗は増える一方」というルールがあります。

データ更新イベント(Resource/Data events)

ときには特定の jobId は重要ではありません。むしろ、あるエンティティが変わったこと——商品フィードが更新された、新しいレポートスナップショットが生成された、個人プロファイルが再生成された——のほうが重要です。

MCP にはすでに resources/updatedresources/list_changed などの標準通知があり、クライアントに「リソース一覧を再読み込みせよ、何かが変わった」と知らせます。

GiftGenius の例:

{
  "jsonrpc": "2.0",
  "method": "resources/updated",
  "params": {
    "eventId": "evt_feed_17",
    "timestamp": "2025-11-21T09:00:00Z",
    "resourceId": "resource:product-feed",
    "changeType": "snapshot_ready"
  }
}

ウィジェットはこのイベントを受けて、たとえば「ギフト一覧を更新」ボタンを強調表示する、といった対応ができます。

UX・システム系イベント

ビジネスに直接関係しないものの、UX や診断上重要なイベントもあります。

  • ログメッセージ logging/message —— ログのための標準 MCP 通知;
  • heartbeat/ping —— サーバーからの定期的な「生きています」;
  • 劣化の警告: たとえば「いま外部 API が遅いため、結果が遅延する場合があります」。

これらは監視やデバッグに有用です。ときには UI でうまく扱い、システムが死んでいるわけではなく、単に忙しいのだとユーザーに知らせることができます。

4. イベントの構造: 必須フィールドとペイロード

イベントはツールリクエストと同じく API オブジェクトです。設計が必要です。基本的なフィールド集合に合意しておくのがよい習慣です。

概念的には、イベントをメタデータ、相関(correlation)、ペイロードの3つに分けると便利です。

一般形の例:

{
  "jsonrpc": "2.0",
  "method": "notifications/job/progress",
  "params": {
    "eventId": "evt_gg_103",
    "type": "job.progress",
    "timestamp": "2025-11-21T10:19:00Z",
    "jobId": "giftjob_42",
    "payload": {
      "percentage": 60,
      "stage": "レビューを比較中",
      "etaSeconds": 15
    }
  }
}

この構造では以下を区別できます。

  • eventId —— イベントの一意識別子。クライアント側での重複排除に必要;
  • type —— イベントの論理名(method を正規化/重複させてもよい);
  • timestamp —— サーバーがイベントを生成した時刻;
  • jobId などの correlation-id —— このイベントが何に関係するかを示す;
  • payload —— 実データ。イベント種別ごとに形が異なる。

実システムでは、これらの構造を JSON Schema や少なくとも TypeScript 型で形式的に記述し、サーバーとクライアントの双方で検証したくなるでしょう。CloudEvents に着想を得た形式を使うチームもあり、そこでも idsourcetypetime などの標準フィールドが定義されています。

しかし要点は単純です。イベントは機械可読かつ一貫しているべきで、「あるときは jobId、あるときは job_id、あるときはそもそもない」といったサプライズをなくすことです。

以下の例では、コードを重くしないためにしばしば「平坦化」した形式を使います。すなわち、イベントのすべてのデータを params に直接入れ、type は役割を method が果たしていると判断できる場合には省略します。それでも原則は同じです。各イベントには安定したメタデータ(eventIdjobIdtimestamp)と予測可能なペイロードがあります。

5. イベントの冪等性: なぜ必要で、どう実現するか

ここからが本講義の最重要ワード——冪等性です。

イベントハンドラの冪等性とは、同じイベントを1回処理しても10回処理しても、システムの最終状態が正しく保たれることを意味します。ネットワークやリトライが絡む分散システムでは死活問題です。

そもそも同じイベントが複数回届くのはなぜでしょうか?

理由は多々あります。接続切断と再接続、サーバー側の「念のため」の再送、ストリーミング系プロトコル(サーバーがオープンな接続へプッシュする、例えば SSE——詳細は輸送レイヤの講義で)などでは定番です。クライアントが Last-Event-ID で再接続すると、サーバーは取り逃したイベントをまとめて送り、一部をクライアントが二度見ることになります。

ハンドラが冪等でないと、奇妙なことが起き始めます。

  • job.completed イベントでボーナスが二重付与されたり、注文ステータスが二重に変更されたりする;
  • resource.updated でウィジェットがカードを毎回「追加」して UI が重複だらけになる;
  • 重複した job.progress により、プログレスバーが前後に跳ねてユーザーが混乱する。

正しい戦略は、サーバーでのイベント生成とクライアントでの処理の二層で考えます。

サーバー側: 安定した ID と state machine

サーバーがすべきこと:

  • 各論理イベントに一意な eventId を発行する;
  • 同一の jobId に属するイベントが妥当な状態遷移列を形成することを保証する。たとえば job.completed の後に job.failed を送ったり、異なる結果で2つの job.completed を送ったりしてはならない。

実質的にあなたのシステムにはタスクの状態機械があり、各イベントは許可された遷移なのです。

クライアント側: 重複排除と「穏やかな」更新

クライアント(ウィジェット、エージェントなど)は次を行います。

  • 少なくとも現在の接続/セッションの間、すでに処理した eventId の集合を保持する;
  • 処理前に確認し、既知の eventId であれば無視するか、副作用なしで UI を描き直す;
  • タスクのステータスを変えるイベント(job.completedjob.failed)を受け取ったら、その遷移が妥当か検証する。たとえばすでに completed なら、重複の job.completed は何も変えるべきでないし、failed は不正として無視するのがよい。

コマースの典型例は支払い確定 webhook の処理です。同じ order.paid が二度届くのは珍しくありません。そのためバックエンドは paymentId と「すでに入金済み」フラグを保持します。webhook が二度届いても、注文状態は変わりません。MCP のイベントもこの発想で設計しましょう。

6. 例: GiftGenius のイベントを設計する

これを学習用の GiftGenius に当てはめます。長いシナリオを想定します。ユーザーが従業員とその興味の大きな CSV をアップロードし、「全員分のギフトアイデアを選んで」と依頼します。処理には数十秒かかる可能性があります。

妥当なイベントモデルは次のように記述できます。

  1. ユーザーがツール start_bulk_gift_analysis を起動。ツールは jobId を返す: "bulk_2025_001"
  2. MCP サーバーはジョブを作成し、ほどなく job.started を要約付きで送る。
  3. 実行中に複数の job.progress を送る。段階例:
    • 10% — 「ファイルをパースして形式を検証」;
    • 40% — 「興味関心と部署を抽出」;
    • 70% — 「カテゴリに基づきギフトをマッピング」;
    • 100% — 完了直前。
  4. 最後に job.completed が届き、最終推薦を含むリソースへのリンクが含まれる。
  5. もし失敗した場合は、completed の代わりに job.failed(エラーコードや修正のヒント付きの場合あり)が届く。

実質的にはこのとおりですが、job.progressjob.completed の2つの重要イベントを JSON スキーマ風に固定化してみましょう(簡略化した擬似 JSON Schema)。

{
  "job.progress": {
    "type": "object",
    "properties": {
      "eventId": { "type": "string" },
      "jobId": { "type": "string" },
      "timestamp": { "type": "string", "format": "date-time" },
      "percentage": { "type": "number", "minimum": 0, "maximum": 100 },
      "stage": { "type": "string" },
      "etaSeconds": { "type": "number" }
    },
    "required": ["eventId", "jobId", "timestamp", "percentage", "stage"]
  }
}
{
  "job.completed": {
    "type": "object",
    "properties": {
      "eventId": { "type": "string" },
      "jobId": { "type": "string" },
      "timestamp": { "type": "string", "format": "date-time" },
      "summary": { "type": "string" },
      "resultResourceId": { "type": "string" }
    },
    "required": ["eventId", "jobId", "timestamp", "resultResourceId"]
  }
}

いますぐ完全なスキーマ検証を実装する必要はありませんが、この構造を念頭に置くのは有益です。フィールドがフォーマットを横断して「拡散」したり、重要なメタデータを忘れたりするのを防ぎます。

7. ミニ実践: MCP イベントを送るサーバー

ここで理論を小さな TypeScript 擬似コードと結び付けます。実際の MCP ライブラリには踏み込みません(進化の途上でもあり、焦点はモデルにあるため)が、骨格を描きます。

MCP サーバーに sendNotification という抽象化があり、ChatGPT へ JSON-RPC の notification を送れるとしましょう。擬似インターフェイス:

// MCPのnotificationを送信するユーティリティ
async function sendNotification(
  method: string,
  params: Record<string, unknown>
) {
  // ここでJSONをシリアライズし、アクティブなMCP接続に送信します
}

ではツール start_bulk_gift_analysis のハンドラを実装しましょう。ジョブを登録して jobId を返し、バックグラウンドで「刻み」ながら進捗を送ります。実際にはワーカーとキューになりますが、ここではタイマーに留めます。

type Job = {
  id: string;
  status: "pending" | "running" | "completed" | "failed";
};

const jobs = new Map<string, Job>();

export async function startBulkGiftAnalysisTool() {
  const jobId = `bulk_${Date.now()}`;
  jobs.set(jobId, { id: jobId, status: "pending" });

  // すぐに job.started を送信
  await sendNotification("notifications/job/started", {
    eventId: `evt_${jobId}_started`,
    jobId,
    timestamp: new Date().toISOString(),
    summary: "大きなギフト一覧の分析を開始しました"
  });

  simulateJob(jobId); // バックグラウンドでジョブを「起動」

  return { jobId };
}

ジョブのシミュレーション本体:

async function simulateJob(jobId: string) {
  jobs.set(jobId, { id: jobId, status: "running" });

  const stages = [
    { percent: 10, stage: "CSVをパース中" },
    { percent: 40, stage: "関心を分析中" },
    { percent: 70, stage: "ギフトを選定中" },
    { percent: 100, stage: "結果を生成中" }
  ];

  for (const s of stages) {
    await sendNotification("notifications/job/progress", {
      eventId: `evt_${jobId}_${s.percent}`,
      jobId,
      timestamp: new Date().toISOString(),
      percentage: s.percent,
      stage: s.stage
    });
    await new Promise(r => setTimeout(r, 1000));
  }

  jobs.set(jobId, { id: jobId, status: "completed" });

  await sendNotification("notifications/job/completed", {
    eventId: `evt_${jobId}_done`,
    jobId,
    timestamp: new Date().toISOString(),
    summary: "ギフト分析が完了しました",
    resultResourceId: `resource:gifts:${jobId}`
  });
}

コードは意図的に単純ですが、次の点が見て取れます。

  • startedprogress* → completed のイベント列を用いる;
  • 各イベントに一意の eventId を付与する;
  • すべてのイベントを同一の jobId に紐付ける。

将来、実際のキューやワーカーを導入してもイベント構造はほぼ同じで、変更されるのは sendNotification をどこで呼ぶかだけです。

8. クライアント: 単純な冪等イベントハンドラ

クライアント側(たとえば Apps SDK のウィジェット)では、こうしたイベントを受け取り、現在のジョブに結び付け、重複で狂わないようにする必要があります。

トランスポートには深入りせず(後で扱います)、各 notification 受信時に MCP クライアント層が呼び出す onMcpNotification のような関数を想定します。

最小限の重複排除を追加します。

const processedEvents = new Set<string>();

function handleNotification(method: string, params: any) {
  const eventId = params.eventId as string | undefined;
  if (!eventId) return; // 実際には要検討だが、例としては十分

  if (processedEvents.has(eventId)) {
    // 重複 — 無視するか、UIを穏やかに更新
    return;
  }
  processedEvents.add(eventId);

  if (method === "notifications/job/progress") {
    updateJobProgress(params.jobId, params.percentage, params.stage);
  } else if (method === "notifications/job/completed") {
    markJobCompleted(params.jobId, params.resultResourceId);
  }
}

updateJobProgressmarkJobCompleted の実装は純粋な React/UI コードです。

function updateJobProgress(jobId: string, percent: number, stage: string) {
  // 例: Zustand/Redux/React state に格納
  console.log(`Job ${jobId}: ${percent}% — ${stage}`);
}

function markJobCompleted(jobId: string, resourceId: string) {
  console.log(`Job ${jobId} が完了しました。リソース: ${resourceId}`);
}

このハンドラは次の特徴を持ちます。

  • イベントが二度届いても壊れない;
  • 副作用を起こさない(例: 「完了」モーダルを二度出さない);
  • より複雑なロジック(状態遷移の妥当性検証——すでに completed なのに failed を受けても無効化する等)への道を整える。

実運用では、MCP サーバーへの再接続時に processedEvents をクリアしたり、eventId だけでなく各 jobId の現在ステータスも保持して、奇妙なイベント順序に対してより賢く振る舞うことになるでしょう。

次に、これらの MCP イベントがどのようにエージェント/ウィジェットを経由して具体的なユーザー体験(プログレスバー、実行段階、最終結果の提示)に変換されるのかを理解することが重要です。イベント、run/workflow、UX の結び付けに進みます。

9. イベント、run/workflow、UX の結合

すでに workflow とエージェントのモジュールを扱いましたが、ここで全体像を見ます。job.*resource.*、システムといったイベントのファミリーを導入しました。これらがエージェント/ウィジェットと ChatGPT を通って具体的なユーザー体験になる流れを確認しましょう。

長時間タスクの典型的なシナリオは次のとおりです。ChatGPT が MCP ツールを呼び出して jobId を取得。その jobId に紐付く進捗、完了、エラーのイベントをサーバーが送信。ウィジェットやエージェントのロジックがそれに基づいて UI を更新し、意思決定を行います。

シーケンス図にすると次のようになります。

sequenceDiagram
    participant User as ユーザー
    participant GPT as ChatGPT(モデル)
    participant App as GiftGenius MCPサーバー
    participant Widget as GiftGeniusウィジェット

    User->>GPT: "2000人の従業員向けのギフトを選んで"
    GPT->>App: tools.call start_bulk_gift_analysis
    App-->>GPT: response { jobId: "bulk_2025_001" }

    GPT->>Widget: ToolOutput { jobId }
    Widget->>Widget: プログレスバーを表示

    App-->>GPT: notification job.started
    App-->>GPT: notification job.progress (10%, 40%, 70%, 100%)
    App-->>GPT: notification job.completed { resultResourceId }

    GPT->>Widget: イベント/データをウィジェットへ中継
    Widget->>User: 進捗を更新し、結果を表示
    

実際の図はもう少し複雑になるでしょうが、要点は単純です。MCP のイベントは、バックグラウンド処理とユーザー体験を結ぶ「神経系」です。

10. MCP イベント取り扱いの典型的な落とし穴

誤り1: 「イベント = 本番ログの置き換え」だと考える。
開発者が、以前 console.log に書いていたものを、そのまま MCP に送るところから始めてしまうことがあります。結果、eventIdjobId も適切な timestamp もなく、「もうすぐ終わります」といった詩的メッセージだけが流れます。これでは脆く、パースしづらく、重複排除もできず、UI はメッセージがどのタスクのものか分かりません。最初からイベントを正式な契約として設計してください。明確なメソッド名、安定したフィールド集合、論理的なペイロードです。

誤り2: 冪等性と一意な eventId がない。
「イベントは1回しか来ないだろう」と安易に考えて始めるケースが多いです。1週間もすれば、クライアント再接続で通知が重複し、ユーザーは同じものを二度受け取り、商用バックエンドはボーナスを二重付与、といった事態が起こります。一意な eventId と、クライアント側の基本的な重複排除なしには、遅かれ早かれ重大なバグを踏むことになります。分散システムでは「at-least-once delivery」を前提にしてください。重複は避けられません。

誤り3: システムイベントとビジネスイベントを混在させる。
たとえば logging/messagejob.progressjob.completedresources/updated を同一ストリームに混ぜ、type/method の区別も曖昧、という事態です。結果として UI 層が if (message.includes("完了")) のような不自然な判定を始め、タスク完了を把握しようとします。システム通知(ログ、heartbeat)と、厳密にスキーマを定義したビジネスイベント(job.*resource.*)は明確に分けましょう。

誤り4: タスク状態遷移の一貫性がない。
同一のイベントストリームで、まず job.completed、その後に job.progress、さらに job.failed を送る、といったことが起きます。明確な state machine がなく、イベント発行時の検査もないとこうなります。クライアントは何が起きているか理解不能です。有限オートマトンとして状態遷移を記述し、それを壊すイベントは出さないようにしましょう。たとえば completed 後に送るのは追加情報のイベントまでで、running に戻すようなことはしません。

誤り5: 現行仕様の MCP メソッド名に過度に依存する。
MCP 仕様は進化中です。システムのすべてを現時点のシステム名に強く結び付け、独自の名前空間を持たないままだと、仕様変更のたびにシステムの半分を書き換える羽目になります。イベントは MCP の上に自分たちのミニ仕様を持つものと捉えましょう。既存のメソッド(notifications/progressresources/updated)に基づきつつも、ビジネスイベント(notifications/job/*)は自分たちの名前空間で設計し、相対的に独立させるのが賢明です。

誤り6: イベントが UX と結び付いていない。
バックエンドで美しいイベントモデルを作っても、ウィジェットに反映されていないケースがあります。job.progress はログにしか存在せず、UI は 40 秒間孤独なスピナーだけを見せる、といった具合です。この状況ではユーザーは MCP にも AI にも不信感を持ちます。イベントを設計する際は、常に欲しい具体的な UI 効果(プログレスバー、実行段階、部分的結果)を考えてください。MCP イベントはプロトコルのためではなく、わかりやすいアプリの振る舞いのために存在します。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION