MCPイベント解説:ChatGPT対応Webhookサーバーの構築・テスト

MCPイベントの仕組み:ChatGPTがevents/subscribeを使ってあなたのMCPサーバーを購読する方法、標準的なWebhook署名、256 KiBの制限、およびApidogでのテスト。

Medy Evrard

2 10月 2026

MCPイベント解説:ChatGPT対応Webhookサーバーの構築・テスト

Apidog エンタープライズ

オンプレミスデプロイ

SSO & RBAC

SOC 2 準拠

Apidog Enterpriseを見る

MCPイベントを使用すると、MCPサーバーは更新が発生した瞬間にChatGPTにプッシュできるため、エージェントがそれらをポーリングする必要がありません。2026年9月29日のOpenAI DevDay以来、ChatGPTはすべてのプランで、提案されたMCPイベントの仕様(プロトコルバージョン 2026-07-28 (MCP 2.0))をサポートしています。サーバーは3つのメソッド(events/list, events/subscribe, events/unsubscribe)を追加し、server/discoverでeventsをアドバタイズし、コールバック検証チャレンジに合格し、すべての配信を標準Webhook HMACで署名します。ChatGPTはWebhook配信のみを受け入れます。

ボタン

このガイドでは、OpenAIが文書化した各メッセージ形式、サーバーが強制するセキュリティ規則、およびループ全体のテスト手順について説明します。プロトコルが初めてですか?「MCPとは」から始めてください。JSON-RPC呼び出しを送信し、Apidogでコールバックをモックします。

MCPイベントの概要

項目 ChatGPTが期待するもの
プロトコル MCP 2.0, バージョン 2026-07-28
機能 server/discover機能内の"events": {}
メソッド events/list, events/subscribe, events/unsubscribe(ツールと同じ認証済みエンドポイント上)
配信 Webhookのみ:ポーリング、ストリーミング、gapまたはterminated通知なし
署名 標準Webhooks HMAC-SHA256
ヘッダー webhook-id, webhook-timestamp, webhook-signature, X-MCP-Subscription-Id
シークレット whsec_にbase64デコードで24-64バイトになる文字列が続くもの(ChatGPTから提供)
ペイロード制限 256 KiB (262,144バイト)、リクエストごとに1つのイベント
購読ID 決定的:プリンシパル、コールバックURL、イベント名、引数
コールバック HTTPS、チャレンジ検証済み、プライベートアドレスなし、リダイレクトなし

出典:OpenAIのMCPイベントガイドおよびドラフトMCPイベント設計スケッチ。

ポーリングではなくイベントを使用する理由

イベントがない場合、レビューコメントを監視するエージェントは、タイマーでツールを呼び出し、結果を比較します。何も変更がない場合はリクエストを無駄にし、変更があった場合は遅延が発生します。MCPイベントは方向を反転させます。サーバーはレコードが変更されたときにそれを認識し、更新を送信します。トレードオフは、これまでのWebhook vs ポーリングの古典的なものですが、今ではMCP内で発生します。

OpenAIのDevDayのまとめでは、プロジェクトボードが使用されています。ユーザーがChatGPTに新しいタスクの監視を依頼し、タスクが着地すると、ChatGPTはリンクされたドキュメントを読み込み、計画をドラフトします。ドキュメントにはさらに2つのケースが追加されています。チャネル内のバグ報告をドラフトプルリクエストに変える(message.created、channel_idでフィルタリング)、およびレビューコメントをドキュメントに適用する(comment.created、document_idでフィルタリング)。

この仕様はMCPのトリガーとイベントワーキンググループ(リポジトリ)から来ており、実験的とマークされているため、コードを2026-07-28にピン留めしてください。残りのローンチについては、DevDay 2026ハブをご覧ください。

イベントをアドバタイズし、定義する

server/discover応答の機能にeventsを追加します。

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28"],
    "capabilities": { "tools": {}, "events": {} }
  }
}

次に、events/listから定義を返します。それぞれがname、description、delivery: ["webhook"]、購読引数(document_idなどのフィルター)用のinputSchema、および各配信のdataオブジェクト用のpayloadSchemaを持ちます。安定した名前と具体的な説明を使用し、サーバーでフィルターを適用し、接続されたアカウントが見ることができるイベントのみをリストしてください。

events/subscribe: リクエストとレスポンス

ユーザーがChatGPTに何かを監視するように依頼すると、ChatGPTはevents/subscribeを呼び出します。

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "events/subscribe",
  "params": {
    "name": "comment.created",
    "arguments": { "document_id": "doc_123" },
    "delivery": {
      "mode": "webhook",
      "url": "https://receiver.example.com/mcp-events/callback_123",
      "secret": "whsec_<base64-encoded-signing-key>"
    },
    "cursor": null
  }
}

承認する前に、そのイベントと引数に対してユーザーを承認し、定義に対して両方を検証し、base64デコードで24-64バイトになるwhsec_シークレットを要求し、コールバックを検証し(次のセクション)、所有者、フィルター、URL、シークレット、有効期限を含むサブスクリプションを保存します。その後、次のように返信します。

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "id": "sub_123",
    "refreshBefore": "2026-10-02T12:00:00Z",
    "cursor": null,
    "truncated": false
  }
}

3つのルールにより、サブスクリプションが正しく保たれます。

データ送信前にコールバックを検証する

アプリケーションデータを送信する前に、新鮮で1回限りの短期間のチャレンジを含む署名付き検証ボディをPOSTします。

{ "type": "verification", "challenge": "a-single-use-random-value" }

一意のwebhook-id(例:msg_verification_123)と、配信と同じ署名ヘッダーを付与します。ChatGPTは2xxで{"challenge": "a-single-use-random-value"}をエコーします。定数時間で比較し、その後配信をアクティブにします。失敗した場合は、-32015(CallbackEndpointError)のJSON-RPCエラーを返し、challenge_failedまたはtimeoutなどのdata.reasonを含めます。リフレッシュ時に再チャレンジされないように、プリンシパルとURLごとに検証キャッシュを一定期間保存します。

チャレンジが存在するのは、サブスクライバーがシークレットを提供するからです。それがなければ、誰でもあなたのサーバーを犠牲者のURLに向け、それを洪水させることができます。すべての送信リクエストに同じ強化を適用してください。HTTPSのみ、接続時にアドレスを解決し検証し、プライベート、ローカル、その他の非公開アドレスをブロックし、リダイレクトは絶対にフォローしないでください。

イベントを配信し署名する

一致するイベントが発生すると、1つのイベントオブジェクトをコールバックURLにPOSTします。

{
  "eventId": "evt_456",
  "name": "comment.created",
  "timestamp": "2026-10-01T12:05:00Z",
  "data": {
    "document_id": "doc_123",
    "comment_id": "comment_456",
    "text": "Can we add the rollout dates to this section?",
    "url": "https://docs.example.com/doc_123#comment_456"
  },
  "cursor": null
}

Content-Type: application/json、webhook-id(eventIdと同じ)、webhook-timestamp(署名時のUnix秒)、webhook-signature、およびX-MCP-Subscription-Idを送信します。署名はボディをカバーするため、ボディは一度シリアライズし、正確なバイト列を送信してください。

256 KiB未満に保ちます。大きなレコードの場合、要約を送信し、読み取りツールを公開します。ユーザーが作成したテキストをデータとして扱い、ペイロードにモデルの指示を絶対に入れないでください。一時的な障害は、キャップ付きの指数関数的バックオフで再試行し、イベントIDを保持し、各試行を新しく署名します。410または413は再試行しないでください。イベントは順不同で到着する可能性があるため、書き込みツールはべき等にしてください。信頼性の高いWebhook設計ガイドでは、再試行スケジュールについて説明しています。

標準Webhook署名を検証する

標準Webhook仕様によると、署名されたコンテンツは${webhook-id}.${webhook-timestamp}.${body}であり、HMAC-SHA256キーはwhsec_を削除した後にbase64デコードされたシークレットです。ヘッダーには1つ以上のスペースで区切られたv1,<base64>署名が含まれます。MCPドラフトは、受信者に対し、5分以上古いタイムスタンプを拒否し、webhook-idで重複排除するよう指示しています。

ChatGPTは自身の側で検証しますが、厳密な受信機を自分で持つことは、ローカルテストの優れたオラクルになります。これはNodeの組み込み機能のみを使用しています。

// receiver.mjs: strict Standard Webhooks receiver for local tests (Node 18+)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.WEBHOOK_SECRET; // whsec_...
const MAX_BYTES = 256 * 1024;
const TOLERANCE_S = 5 * 60;
const seen = new Set();

export function verify(raw, h, secret, now = Math.floor(Date.now() / 1000)) {
  const id = h["webhook-id"], ts = h["webhook-timestamp"], sigs = h["webhook-signature"];
  if (!id || !ts || !sigs) return false;
  const t = Number(ts);
  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_S) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.`).update(raw).digest();
  return sigs.split(" ").some((s) => {
    const [version, b64] = s.split(",");
    const got = Buffer.from(b64 ?? "", "base64");
    return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
  });
}

if (SECRET) createServer((req, res) => {
  const chunks = [];
  let size = 0;
  req.on("data", (c) => { size += c.length; if (size <= MAX_BYTES) chunks.push(c); });
  req.on("end", () => {
    if (size > MAX_BYTES) return res.writeHead(413).end();
    const raw = Buffer.concat(chunks);
    if (!verify(raw, req.headers, SECRET)) return res.writeHead(401).end();
    let body;
    try { body = JSON.parse(raw); } catch { return res.writeHead(400).end(); }
    if (body.type === "verification") {
      res.writeHead(200, { "Content-Type": "application/json" });
      return res.end(JSON.stringify({ challenge: body.challenge }));
    }
    const id = req.headers["webhook-id"];
    if (!seen.has(id)) { seen.add(id); console.log(req.headers["x-mcp-subscription-id"], body.name, id); }
    res.writeHead(200).end();
  });
}).listen(8787);

WEBHOOK_SECRET=whsec_... node receiver.mjsで実行します。256 KiBを超えると413を返し、不正または古い署名の場合は401を返し、チャレンジをエコーし、各webhook-idを一度ログに記録します。Standard Webhooks JavaScriptライブラリの署名テストベクトルに対してverify()をチェックしました。理論については、Webhook署名検証をご覧ください。

サーバーはプライベートアドレスをブロックするため、デフォルトではlocalhostに配信しません。ドラフトでは、明示的に構成されている場合にのみ非公開ターゲットが許可されます。開発専用の許可リストエントリを追加するか、トンネルを介して受信機を公開してください。

ChatGPTが実行する前にテストする

OpenAIがリストする失敗モードのApidogテスト手順を以下に示します。MCP_URL、MCP_TOKEN、CALLBACK_URL、WEBHOOK_SECRETを含む環境を作成し、各JSON-RPC呼び出しを{{MCP_URL}}へのPOSTとして、Authorization: Bearer {{MCP_TOKEN}}に加えて、Streamable HTTPバインディングが必要とするヘッダー(Accept: application/json, text/event-stream、MCP-Protocol-Version: 2026-07-28、およびメソッドに設定されたMcp-Method)を付けて送信します。各ボディには、io.modelcontextprotocol/protocolVersionとio.modelcontextprotocol/clientCapabilitiesを含むparams._metaも必要です。OpenAIのサンプルではこれが省略されており、_metaがない場合も-32602が返されるため、ステップ3が誤った理由で成功する可能性があります。

  1. ディスカバリ。 server/discoverを送信し、$.result.capabilities.eventsが存在し、$.result.supportedVersionsに2026-07-28が含まれていることをアサートします。events/listを送信し、各イベントのdeliveryにwebhookが含まれていることをアサートします。
  2. べき等な購読。 {{CALLBACK_URL}}と{{WEBHOOK_SECRET}}を使用して上記の購読ボディを送信し、$.result.idをSUB_IDとして保存するExtract Variableポストプロセッサーを使用します。変更せずに再送信し、次にargumentsキーを並べ替えて再送信し、どちらの場合も$.result.idが{{SUB_ID}}と等しいことをアサートします。
  3. 検証。 24バイト未満にデコードされるシークレットを送信し、次にhttp://コールバックを送信します。ドラフトでは、両方とも-32602(InvalidParams)にマップされます。$.error.codeでアサートします。プライベートIPコールバックも失敗する必要があります。
  4. 検証チャレンジ。 {"challenge": "wrong"}と応答するApidogモックエンドポイントを作成し、そのクラウドモックURLをコールバックとして購読します。$.error.codeが-32015であり、$.error.data.reasonがchallenge_failedであることをアサートします。次に、CALLBACK_URLをレシーバーに向け、購読が成功することを確認します。
  5. 過大なペイロード。 ボディが262,144バイトを超えるイベントをトリガーします。送信者はそれを拒否するはずです。もしすり抜けた場合、受信者は413と応答し、サーバーログには再試行ではなく1回の試行が表示されるはずです。
  6. リプレイと改ざん。 署名された配信のヘッダーとボディをサーバーの送信ログから新しいApidogリクエストにコピーします。すぐに再送信します。200が返され、2行目のログは表示されません。ボディの1バイトを変更します。401が返されます。5分後に再送信します。401が返されます。

これらの手順をシナリオとして保存し、Apidog CLIでCIで実行します。MCPサーバーテストプレイブックではツール呼び出しについて、Webhookのテスト方法ではレシーバーについて詳しく説明しています。その後、プラグインを介してサーバーをChatGPTに接続し、OpenAIのライフサイクルチェックリストを実行します。

よくある質問

MCPイベントとは何ですか? クライアントがポーリングする代わりに、サーバーがクライアントにイベント通知をプッシュできるようにする実験的なMCP拡張機能です。ChatGPTは2026-07-28でそのWebhookモードをサポートしています。

ChatGPTはMCPイベントのポーリングまたはストリーミングをサポートしていますか? いいえ。ChatGPTはWebhook配信とコールバック検証のみをサポートしています。ポーリング、ストリーミング、gapおよびterminated通知はサポートされていません。

どのChatGPTプランがMCPイベントを利用できますか? OpenAIのDevDayまとめによると、この機能はすべてのプランで利用可能です。

署名シークレットは誰が作成しますか? サブスクライバーです。ChatGPTはdelivery.secretにwhsec_シークレットを送信します。あなたのサーバーはそれを検証、保存、署名に使用し、決して独自のものを生成しません。

これはAgents APIとどう違うのですか? MCPイベントは、あなたのサーバーからChatGPTにデータをプッシュします。OpenAI Agents APIは、あなたが構築したエージェントを実行し、ストリーミングまたはWebhookによってその進捗状況を報告します。

次のステップ

1つのサーバーに"events": {}を追加し、1つのフィルターを持つ1つのイベントを公開し、ChatGPTに接続する前にローカルレシーバーに対して6つのチェックを実行してください。コミットごとに実行されるシナリオとしてこれらのチェックを保持するためにApidogをダウンロードしてください。

ApidogでAPIデザイン中心のアプローチを取る

APIの開発と利用をよりシンプルなことにする方法を発見できる