DEV Community

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

Posted on Originally published at apidog.com

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

MCPイベントを使用すると、MCPサーバーは更新が発生した瞬間にChatGPTへプッシュできます。エージェント側で定期ポーリングする必要はありません。2026年9月29日のOpenAI DevDay以降、ChatGPTはすべてのプランで、提案中のMCPイベント仕様(プロトコルバージョン 2026-07-28、MCP 2.0)をサポートしています。サーバー側では events/list、events/subscribe、events/unsubscribe の3メソッドを実装し、server/discover で events を通知し、コールバック検証を通過させ、標準Webhook HMACで全配信を署名します。ChatGPTが受け付ける配信方式はWebhookのみです。

今すぐApidogを試す

このガイドでは、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 通知は不可
署名 Standard Webhooks HMAC-SHA256
ヘッダー webhook-id、webhook-timestamp、webhook-signature、X-MCP-Subscription-Id
シークレット whsec_ の後に、base64デコードで24〜64バイトとなる文字列
ペイロード制限 256 KiB(262,144バイト)、1リクエストにつき1イベント
購読ID プリンシパル、コールバックURL、イベント名、引数から決定的に生成
コールバック HTTPS、チャレンジ検証済み、プライベートアドレス不可、リダイレクト不可

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

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

イベントがない場合、たとえばレビューコメントを監視するエージェントは、タイマーでツールを呼び出し、前回結果との差分を比較します。変更がなければリクエストを消費し、変更があれば次のポーリングまで検知が遅れます。

MCPイベントでは方向が反転します。サーバーはレコード変更を検知した時点でイベントを送信します。このトレードオフは従来のWebhook vs ポーリングと同じですが、MCPの購読モデルに組み込まれています。

OpenAIのDevDayまとめでは、プロジェクトボードのタスク監視が例として使われています。ユーザーがChatGPTに新しいタスクの監視を依頼すると、タスク到着時にChatGPTがリンク済みドキュメントを読み、計画を下書きします。ほかにも次のユースケースがあります。

  • チャネルのバグ報告をドラフトPRへ変換する
    • イベント: message.created
    • フィルター: channel_id
  • レビューコメントをドキュメントへ適用する
    • イベント: comment.created
    • フィルター: document_id

この仕様はMCPのトリガーとイベントワーキンググループのリポジトリに由来し、実験的機能としてマークされています。実装ではプロトコルバージョンを 2026-07-28 に固定してください。関連情報はDevDay 2026ハブも参照してください。

1. イベント機能をアドバタイズする

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

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

次に、events/list からイベント定義を返します。各イベントには少なくとも以下を含めます。

  • name: 安定したイベント名
  • description: 発火条件とデータ内容が分かる具体的な説明
  • delivery: ["webhook"]
  • inputSchema: document_id など、購読時のフィルター引数
  • payloadSchema: 配信時の data オブジェクトのスキーマ

実装時のルールは次のとおりです。

  1. イベント名は後方互換性を意識して固定する
  2. フィルターはクライアントではなくサーバーで適用する
  3. 接続済みアカウントが閲覧できるイベントだけを列挙する
  4. payloadSchema に実際の配信データと一致する型を定義する

2. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

購読を保存する前に、次を必ず検証してください。

  1. 認証済みユーザーが指定イベントと引数にアクセスできること
  2. イベント名と引数がイベント定義のスキーマに一致すること
  3. delivery.secret が whsec_ で始まり、base64デコード後に24〜64バイトであること
  4. コールバックURLが後述する検証を通過すること

保存するレコードには、少なくとも以下を含めます。

  • 所有者または認証済みプリンシパル
  • イベント名
  • 正規化済みフィルター引数
  • コールバックURL
  • 署名シークレット
  • 有効期限
  • 最終カーソル

検証と保存が完了したら、次のように返します。

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

購読実装で守る3つのルール

1. 決定的なIDを生成する

id は、認証済みプリンシパル、コールバックURL、イベント名、引数から導出します。設計スケッチでは、このキーの切り詰めたSHA-256ハッシュが提案されています。

引数オブジェクトはキー順序の差分で別IDにならないよう、正規化済みJSONとして扱ってください。

2. べき等なアップサートにする

同じIDで再度 events/subscribe が来た場合、新規レコードを作成せず既存レコードを更新します。

これにより、ChatGPTの再試行やリフレッシュで重複購読が作られるのを防げます。

3. refreshBefore より前の更新を受け入れる

ChatGPTは refreshBefore より前に、同じIDと最後に保存したカーソルを使って events/subscribe を再実行します。サーバーは新しい有効期限を返します。

リフレッシュ時に新しいシークレットが渡された場合は置き換えます。キー切り替え直後の失敗を避けるため、短期間は旧キーと新キーの両方で署名します。再生できないイベントでは cursor: null を返してください。

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

アプリケーションデータを送る前に、短期間で失効する一度限りのチャレンジを含む署名済みボディをPOSTします。

{
  "type": "verification",
  "challenge": "a-single-use-random-value"
}
Enter fullscreen mode Exit fullscreen mode

このリクエストには、通常のイベント配信と同じ署名ヘッダーと、一意な webhook-id を付与します。

webhook-id: msg_verification_123
webhook-timestamp: <unix-seconds>
webhook-signature: v1,<signature>
Enter fullscreen mode Exit fullscreen mode

ChatGPTは 2xx とともに、同じチャレンジを返します。

{
  "challenge": "a-single-use-random-value"
}
Enter fullscreen mode Exit fullscreen mode

サーバー側では返却値を定数時間で比較し、一致した場合のみ購読を有効化してください。失敗時はJSON-RPCエラー -32015(CallbackEndpointError)を返し、data.reason に challenge_failed や timeout を設定します。

{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32015,
    "message": "Callback endpoint verification failed",
    "data": {
      "reason": "challenge_failed"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

リフレッシュごとにチャレンジを繰り返さないよう、プリンシパルとURLの組み合わせごとに検証結果を一定期間キャッシュします。

SSRF対策を適用する

チャレンジ検証は、サブスクライバーがシークレットを提供する設計だからこそ必要です。検証がなければ、攻撃者は第三者のURLを指定して、サーバーから任意の宛先へリクエストを送らせる可能性があります。

検証時と実配信時の両方で、次を実施してください。

  • HTTPSのみ許可する
  • 接続時に名前解決し、解決済みIPを検証する
  • プライベート、ローカル、その他の非公開アドレスを拒否する
  • HTTPリダイレクトを追跡しない

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

一致するイベントが発生したら、コールバックURLへイベントオブジェクトを1件だけ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
}
Enter fullscreen mode Exit fullscreen mode

リクエストには以下のヘッダーを付与します。

Content-Type: application/json
webhook-id: evt_456
webhook-timestamp: <unix-seconds>
webhook-signature: v1,<signature>
X-MCP-Subscription-Id: sub_123
Enter fullscreen mode Exit fullscreen mode

webhook-id は eventId と同じ値にします。署名はリクエストボディそのものを対象にするため、JSONは一度だけシリアライズし、その正確なバイト列を署名と送信の両方に使用してください。

配信時の実装チェックリスト

  • ペイロードは256 KiB未満に保つ
  • 大きなレコードは要約だけを送り、詳細取得用の読み取りツールを公開する
  • ユーザー作成テキストはデータとして扱い、モデルへの指示をペイロードに混ぜない
  • 一時的な失敗には上限付き指数バックオフで再試行する
  • 再試行時もイベントIDを維持し、試行ごとに新しいタイムスタンプで署名する
  • 410 と 413 では再試行しない
  • イベントが順不同で到達しても安全なよう、書き込みツールをべき等にする

再試行設計の詳細は、信頼性の高いWebhook設計ガイドを参照してください。

5. Standard Webhooks署名を検証する

Standard Webhooks仕様では、署名対象の文字列は次の形式です。

${webhook-id}.${webhook-timestamp}.${body}
Enter fullscreen mode Exit fullscreen mode

HMAC-SHA256のキーには、whsec_ プレフィックスを除去してbase64デコードしたシークレットを使用します。webhook-signature ヘッダーには、空白区切りで1つ以上の v1,<base64> 署名が含まれます。

MCPドラフトでは、受信者に次も求めています。

  • 5分以上古いタイムスタンプを拒否する
  • webhook-id を使って重複排除する

ChatGPT側も署名を検証しますが、ローカルテスト用に厳密な受信機を用意すると、送信実装の検証に役立ちます。以下はNode.js標準機能のみで実装した受信機です。

// 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"];
  const ts = h["webhook-timestamp"];
  const 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", (chunk) => {
      size += chunk.length;
      if (size <= MAX_BYTES) chunks.push(chunk);
    });

    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);
}
Enter fullscreen mode Exit fullscreen mode

次のコマンドで起動します。

WEBHOOK_SECRET=whsec_... node receiver.mjs
Enter fullscreen mode Exit fullscreen mode

この受信機は次の動作を行います。

  • 256 KiBを超えるボディには 413 を返す
  • 不正または期限切れの署名には 401 を返す
  • 検証チャレンジをそのままエコーする
  • 各 webhook-id を一度だけログ出力する

verify() はStandard Webhooks JavaScriptライブラリの署名テストベクトルで確認されています。署名の仕組みはWebhook署名検証も参照してください。

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

6. ChatGPTへ接続する前にテストする

OpenAIが挙げる失敗モードを検証するためのApidogテスト手順です。

まず、以下の環境変数を作成します。

MCP_URL
MCP_TOKEN
CALLBACK_URL
WEBHOOK_SECRET
Enter fullscreen mode Exit fullscreen mode

各JSON-RPC呼び出しは {{MCP_URL}} へのPOSTとして送ります。認証ヘッダーに加え、Streamable HTTPバインディングで求められるヘッダーを設定します。

Authorization: Bearer {{MCP_TOKEN}}
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: <実行するメソッド名>
Enter fullscreen mode Exit fullscreen mode

各リクエストボディの params._meta には、io.modelcontextprotocol/protocolVersion と io.modelcontextprotocol/clientCapabilities も必要です。仕様を参照してください。

OpenAIのサンプルでは _meta が省略されていますが、_meta がない場合にも -32602 が返るため、購読テストが誤った理由で成功しないように注意してください。

1. ディスカバリを確認する

server/discover を送信し、以下をアサートします。

$.result.capabilities.events が存在する
$.result.supportedVersions に 2026-07-28 が含まれる
Enter fullscreen mode Exit fullscreen mode

続けて events/list を送信し、各イベントの delivery に webhook が含まれることを確認します。

2. 購読のべき等性を確認する

{{CALLBACK_URL}} と {{WEBHOOK_SECRET}} を使って購読リクエストを送信します。

レスポンスの $.result.id を SUB_ID として保存するExtract Variableポストプロセッサーを設定します。

その後、次を実行します。

  1. 同じリクエストを変更せずに再送する
  2. arguments のキー順を入れ替えて再送する
  3. いずれも $.result.id === {{SUB_ID}} を確認する

3. 入力検証を確認する

以下の不正なリクエストを送信します。

  • base64デコード後に24バイト未満となるシークレット
  • http:// のコールバックURL
  • プライベートIPを指すコールバックURL

ドラフトでは、いずれも -32602(InvalidParams)になります。

$.error.code === -32602
Enter fullscreen mode Exit fullscreen mode

4. 検証チャレンジ失敗を確認する

Apidogで、常に次を返すモックエンドポイントを作成します。

{
  "challenge": "wrong"
}
Enter fullscreen mode Exit fullscreen mode

そのクラウドモックURLをコールバックURLとして購読します。以下を確認してください。

$.error.code === -32015
$.error.data.reason === challenge_failed
Enter fullscreen mode Exit fullscreen mode

続いて、CALLBACK_URL をローカル受信機または公開済み受信機へ向け、購読が成功することを確認します。

5. 過大なペイロードを確認する

262,144バイトを超えるイベントを発生させます。送信側が拒否することを確認してください。

送信側を通過した場合でも、受信機は 413 を返す必要があります。サーバーログには再試行ではなく1回の試行だけが記録される必要があります。

6. リプレイと改ざんを確認する

署名済み配信のヘッダーとボディを送信ログからコピーし、新しいApidogリクエストとして再送します。

期待結果は次のとおりです。

操作 期待結果
同じリクエストを即時再送 200、ただし受信ログは増えない
ボディの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. server/discover に "events": {} を追加する
  2. フィルターを1つ持つイベントを1つだけ公開する
  3. events/subscribe の決定的IDとべき等アップサートを実装する
  4. チャレンジ検証とStandard Webhooks署名を実装する
  5. ローカル受信機に対して6つのテストを実行する
  6. シナリオをCIで継続実行する

コミットごとにこれらの検証を実行するため、Apidogをダウンロードしてください。

Top comments (0)