MCP Events 详解:构建并测试一个 ChatGPT 可订阅的 webhook MCP 服务端

MCP Events 详解:ChatGPT 如何通过 events/subscribe 订阅你的 MCP 服务端、Standard Webhooks 签名机制、256 KiB 限制,以及如何用 Apifox 完成测试。

用 Apifox,节省研发团队的每一分钟

MCP Events 详解:构建并测试一个 ChatGPT 可订阅的 webhook MCP 服务端

免费使用 Apifox

相关推荐

最新文章

API

一体化协作平台

API 设计

API 文档

API 调试

自动化测试

API Mock

API Hub

立即体验 Apifox
目录

MCP Events 让你的 MCP server 在更新一发生时就推送给 ChatGPT,无需任何 agent 轮询。自 2026 年 9 月 29 日 OpenAI DevDay 起,ChatGPT 在所有套餐上支持该提案中的 MCP Events 规范,协议版本为 2026-07-28(MCP 2.0)。你的 server 需要新增三个 method(events/list、events/subscribe、events/unsubscribe),声明 events(在 server/discover 中),通过回调验证 challenge,并用 Standard Webhooks HMAC 为每次 delivery 签名。ChatGPT 只接受 webhook 方式投递。

本指南覆盖 OpenAI 文档中记录的每一种消息结构、你的 server 需要实施的安全规则,以及针对整个闭环的一轮测试。刚接触该协议?先从「什么是 MCP」看起。你可以在 Apifox 中发送这些 JSON-RPC 调用并 mock 回调。

MCP Events 一览

项目 ChatGPT 的要求
协议 MCP 2.0,版本 2026-07-28
能力 在 capabilities 中声明 "events": {}(server/discover)
Method events/list、events/subscribe、events/unsubscribe,与你的 tools 使用同一个已鉴权的接口
投递方式 仅支持 webhook:不支持 polling、streaming、gap 或 terminated 通知
签名 Standard Webhooks HMAC-SHA256
Header webhook-id、webhook-timestamp、webhook-signature、X-MCP-Subscription-Id
密钥 由 ChatGPT 提供,以 whsec_ 开头、base64 解码后为 24-64 字节
载荷上限 256 KiB(262,144 字节),每个请求一个 event
订阅 ID 确定性生成:principal、回调 URL、event 名称、arguments
回调 HTTPS、需通过 challenge 验证、不允许私有地址、不跟随重定向

来源:OpenAI 的 MCP Events 指南 以及草案 MCP Events 设计草案。

为什么用 event 取代 polling

没有 event 时,一个盯着评审评论的 agent 只能定时调用 tool 并对比结果:什么都没变时白白消耗请求,真有变化时又滞后。MCP Events 把方向反过来:你的 server 知道记录何时变化,于是主动把更新发出去。这里的取舍就是经典的 webhooks 与 polling 之争,如今被搬进了 MCP。

OpenAI 的 DevDay 回顾用一块项目看板举例:用户让 ChatGPT 盯住新任务,一旦有任务进来,ChatGPT 就阅读关联文档并起草一份计划。文档又补充了两个场景:把某个频道里的 bug 报告变成草稿 pull request(message.created,按 channel_id 过滤),以及把评审评论应用到某份文档上(comment.created,按 document_id 过滤)。

该规范来自 MCP 的 Triggers and Events 工作组(repo),并被标记为实验性,因此请把你的代码固定到 2026-07-28。想了解本次发布的其他内容,请看 DevDay 2026 汇总。

声明并定义 event

把 events 加入 server/discover 响应的 capabilities 中:

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

然后从 events/list 返回各 event 的定义。每个定义包含 name、description、delivery: ["webhook"]、用于订阅 arguments 的 inputSchema(即你的过滤器,例如 document_id),以及 payloadSchema,用于每次 delivery 中的 data 对象。请使用稳定的名称和具体的描述,在你的 server 上应用过滤器,并且只列出当前已连接账号有权看到的 event。

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
  }
}

在接受之前,先为该用户授予该 event 及其 arguments 的权限,对照你的定义校验二者,要求 whsec_ 密钥的 base64 解码后为 24-64 字节,验证回调(见下一节),并保存该订阅及其所有者、过滤器、URL、密钥和过期时间。然后返回:

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

有三条规则保证订阅的正确性:

  • 确定性 ID。根据已鉴权的 principal、回调 URL、event 名称和 arguments 推导出 id。设计草案建议对该键取截断的 SHA-256。
  • 幂等 upsert。身份相同的重复订阅会更新已有记录。比较 arguments 时使用规范化 JSON,避免键顺序造成重复记录。
  • 在 refreshBefore 之前刷新。ChatGPT 会在该时间之前以相同身份和它上次保存的 cursor 重新调用 events/subscribe;你返回新的过期时间。若刷新携带了新的密钥,请替换它,并在一小段窗口期内用新旧两把密钥同时签名。对于无法重放的 event,返回 cursor: null。

在发送任何数据之前先验证回调

在发送应用数据之前,先 POST 一个已签名的验证 body,其中带有一个全新的、一次性的、短时效的 challenge:

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

为它设置唯一的 webhook-id(例如 msg_verification_123)以及与该次 delivery 相同的签名 header。ChatGPT 会回显 {"challenge": "a-single-use-random-value"},并返回 2xx。用常量时间比较,然后启用投递。失败时返回 JSON-RPC 错误 -32015(CallbackEndpointError),并附上 data.reason,例如 challenge_failed 或 timeout。按 principal 和 URL 在一段有限时间内缓存验证通过的结果,这样刷新时就不必重新发起 challenge。

引入 challenge 是因为密钥由订阅方提供:如果没有它,任何人都可能把你的 server 指向受害者的 URL 并对其发起洪泛攻击。对每个出站请求都要施加同样的加固措施:仅使用 HTTPS,在建立连接时解析并校验地址,阻断私有、本地及其他非公网地址,并且绝不跟随重定向。

投递并签名 event

当发生匹配的 event 时,向回调 URL POST 一个 event 对象:

{
  "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。body 只序列化一次,并发送这些精确的字节,因为签名覆盖的就是它们。

保持在 256 KiB 以内;对于较大的记录,只发送摘要并另外暴露一个读取 tool。把用户生成的文本当作数据,绝不要把模型指令放进载荷。对瞬时失败使用带上限的指数退避重试,保持 event ID 不变,并让每次尝试都重新签名。不要重试 410 或 413。event 可能乱序到达,因此请让写入类 tool 具备幂等性。可靠 webhook 设计指南介绍了重试时间表。

验证 Standard Webhooks 签名

按照 Standard Webhooks 规范,被签名的内容是 ${webhook-id}.${webhook-timestamp}.${body},HMAC-SHA256 密钥是将 whsec_ 去掉后对密钥做 base64 解码得到的结果,而 header 中保存一个或多个以空格分隔的 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,会回显 challenge,并且每个 webhook-id 只记录一次。我们已用 Standard Webhooks JavaScript 库中的签名测试向量校验过 verify()。原理部分可参阅 webhook 签名验证。

你的 server 会阻断私有地址,因此默认不会向 localhost 投递。草案仅在显式配置时才允许非公网目标:添加一条仅开发环境使用的白名单条目,或通过隧道暴露接收方。

在 ChatGPT 之前先自测

下面是一轮 Apifox 测试,覆盖 OpenAI 列出的各种失败模式。创建一个包含 MCP_URL、MCP_TOKEN、CALLBACK_URL 和 WEBHOOK_SECRET 的环境,把每个 JSON-RPC 调用以 POST 发送到 {{MCP_URL}},带上 Authorization: Bearer {{MCP_TOKEN}},以及 Streamable HTTP 绑定 要求的 header:Accept: application/json, text/event-stream、MCP-Protocol-Version: 2026-07-28,并将 Mcp-Method 设为对应的 method。每个 body 还需要在 params._meta 中带上 io.modelcontextprotocol/protocolVersion 和 io.modelcontextprotocol/clientCapabilities。OpenAI 的示例省略了它,而缺少 _meta 同样会返回 -32602,那样第 3 步就会因为错误的原因而通过。

  1. 发现。发送 server/discover,断言 $.result.capabilities.events 存在且 $.result.supportedVersions 包含 2026-07-28。发送 events/list,断言每个 event 的 delivery 包含 webhook。
  2. 幂等订阅。用 {{CALLBACK_URL}} 和 {{WEBHOOK_SECRET}} 发送上面的订阅 body,并配置一个「提取变量」后置操作,把 $.result.id 保存为 SUB_ID。原样重发一次,再把 arguments 的键重排后重发一次,两次都断言 $.result.id 等于 {{SUB_ID}}。
  3. 校验。发送一个解码后不足 24 字节的密钥,然后发送一个 http:// 回调。草案把这两种情况都映射为 -32602(InvalidParams);断言 $.error.code。私有 IP 的回调也必须失败。
  4. 验证 challenge。在 Apifox 中创建一个 mock 接口,返回 {"challenge": "wrong"},并用它的云端 mock URL 作为回调发起订阅。断言 $.error.code 为 -32015、$.error.data.reason 为 challenge_failed。然后把 CALLBACK_URL 指向你自己的接收方,确认订阅成功。
  5. 超大载荷。触发一个 body 超过 262,144 字节的 event。你的发送方应当拒绝它;如果有一个漏了过去,接收方会返回 413,而你的 server 日志应显示只有一次尝试,而不是重试。
  6. 重放与篡改。从你 server 的出站日志中复制一次已签名 delivery 的 header 和 body,放进一个新的 Apifox 请求。立刻重发:200,且没有第二条日志。改动 body 的一个字节:401。5 分钟后再重发:401。

把这些步骤保存为测试场景,并用 Apifox CLI 在 CI 中运行。MCP server 测试手册介绍了 tool 调用,如何测试 webhook 则更深入地讲解了接收方。之后通过 plugin 把 server 接入 ChatGPT,并运行 OpenAI 的生命周期检查清单。

FAQ

什么是 MCP Events?一种实验性的 MCP 扩展,让 server 主动向 client 推送 event 通知,而不必由 client 轮询。ChatGPT 在 2026-07-28 上支持其 webhook 模式。

ChatGPT 支持 MCP Events 的 polling 或 streaming 吗?不支持。ChatGPT 仅支持 webhook 投递和回调验证。不支持 polling、streaming,也不支持 gap 和 terminated 通知。

哪些 ChatGPT 套餐可以使用 MCP Events?OpenAI 的 DevDay 回顾称该功能对所有套餐开放。

签名密钥由谁生成?由订阅方生成。ChatGPT 会发送 whsec_ 密钥,放在 delivery.secret 中;你的 server 负责校验、保存并用它签名,绝不自行生成。

它与 Agents API 有何不同?MCP Events 把数据从你的 server 推入 ChatGPT。OpenAI Agents API 则运行你构建的 agent,并通过 streaming 或 webhooks 汇报它们的进度。

下一步

先给一个 server 加上 "events": {},用一个过滤器暴露一个 event,并在接入 ChatGPT 之前用本地接收方跑完这六项检查。把这些检查保存为测试场景,让它随每次提交运行。


HiFox:将 Agent 变成真正的队友

另外,我们也在思考,AI 如何从个人提效走进团队协作。

HiFox 是一个让人和 AI Agent 在同一个工作现场协作的平台:你可以像给同事分派任务一样指派 Agent,在任务看板中跟踪进度、查看结果,让 Agent 成为团队里的队友。

👉 立即体验 HiFox:https://hifox.com

AI Coding 交流群

如果你也在用 AI 写代码,或者正在研究 Cursor、Claude Code 这些工具,
欢迎加入以下交流群。群里平时会聊一些 AI 编程的实际用法、开发工作流,还有各种新工具和新玩法。

AI Coding 交流群