MCP Events 接入实战

从一条文档评论出发,把订阅身份、原始字节签名、重复投递与中断恢复拆成可核对的工程步骤。

你能带走什么
  • 先检查宿主事件扩展与传输支持,再设计接入
  • callback 与 secret 来自平台订阅请求,业务服务负责验证和投递
  • 原始字节验签、持久去重和业务幂等分别覆盖不同风险
  • 续订仍调用 events/subscribe,cursor 的恢复能力取决于上游历史

1. 接入前,先确认宿主支持哪一段协议

项目中的构建结束、文档出现新评论、审批状态变化,都可以成为代理任务的输入。要让这些变化到达一个长期存在的会话,需要把事件目录、订阅状态、投递端点和任务执行连起来。本文以文档评论为例,先厘清接入方各自负责什么,再用一个没有网络连接的实验验证签名、过滤和重复投递。

本文核对日期为 2026 年 10 月 5 日。当前 OpenAI 文档列出的入口包括 ChatGPT 网页端 Work 会话、桌面端选择 Cloud 的 Work 会话,以及 dots;接入要求 MCP 2.0,协议版本为 2026-07-28。当前集成实现 webhook 投递和回调验证,未支持草案中的轮询、流式投递以及 gap、terminated 控制通知。当前宿主支持范围应在实际接入当天再检查。

MCP Events 仍有独立的设计草案。一个产品能调用 MCP 工具,并不能据此确认它支持这套事件扩展。本文不承诺任意 Codex 客户端、Grok 或 Muse 能接收相同消息,也不把草案里的所有方法算进 ChatGPT 的已开放能力。下面标注为接入约定的内容取自当前官方集成;标注为本地策略的内容用于演示工程实现。

图 01 / 原创概念示意
双通道架构。支持此扩展的宿主通过 MCP 发现事件并建立或续订订阅,将 delivery.url 与 delivery.secret 提供给 MCP 服务。业务系统产生事件,由 sender 签名投递至平台 callback,再进入宿主任务。
宿主提供 callback URL 与 secret;MCP 服务管理订阅,业务事件经 sender 签名后投递到平台回调。
查看图示文字说明

双通道架构。支持此扩展的宿主通过 MCP 发现事件并建立或续订订阅,将 delivery.url 与 delivery.secret 提供给 MCP 服务。业务系统产生事件,由 sender 签名投递至平台 callback,再进入宿主任务。

图中有两条连接:宿主通过已认证的 MCP 端点发现事件并建立订阅;你的服务随后向宿主给出的 HTTPS callback 投递。callback 和签名 secret 由宿主供应。插件作者不能随手填写一个公网地址或自选 secret,就宣称已经接入平台。本文只保存结构样本,离线接收器也只在函数之间传递字节。

2. 发现事件,并把两种 Schema 分开设计

先让 server/discover 返回 events 能力,再在同一个已认证 MCP 端点实现 events/list、events/subscribe 和 events/unsubscribe。能力声明只说明这类接口可用,具体能订阅哪些事件仍由目录返回。不要把事件伪装成一个持续运行、一直不返回的普通工具。能力发现与事件定义给出了当前集成的消息结构。

server/discover 响应结构
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": [
      "2026-07-28"
    ],
    "capabilities": {
      "tools": {},
      "events": {}
    }
  }
}

下面是 events/list 的一项定义。inputSchema 校验订阅时传入的 arguments;payloadSchema 校验每次事件里的 data。二者服务于不同阶段。document_id 在订阅中限定范围,在事件中说明来源;接收端复核它,可以更早发现发送方过滤失误。

events/list 响应结构
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "events": [
      {
        "name": "comment.created",
        "description": "指定文档出现新的审阅评论时发送事件。",
        "delivery": [
          "webhook"
        ],
        "inputSchema": {
          "type": "object",
          "properties": {
            "document_id": {
              "type": "string"
            }
          },
          "required": [
            "document_id"
          ],
          "additionalProperties": false
        },
        "payloadSchema": {
          "type": "object",
          "properties": {
            "document_id": {
              "type": "string"
            },
            "comment_id": {
              "type": "string"
            },
            "text": {
              "type": "string"
            },
            "url": {
              "type": "string"
            }
          },
          "required": [
            "document_id",
            "comment_id",
            "text",
            "url"
          ],
          "additionalProperties": false
        }
      }
    ]
  }
}
图 02 / 原创概念示意
server/discover 的 capabilities.events 宣告能力;events/list 返回事件定义。inputSchema 校验 events/subscribe 的 arguments,而 payloadSchema 校验实际事件的 event.data。两种 schema 不能互换。
发现阶段返回事件契约;订阅参数对照 inputSchema,事件的 data 对照 payloadSchema。
查看图示文字说明

server/discover 的 capabilities.events 宣告能力;events/list 返回事件定义。inputSchema 校验 events/subscribe 的 arguments,而 payloadSchema 校验实际事件的 event.data。两种 schema 不能互换。

事件名称宜保持稳定,描述应说明何时触发及过滤条件。目录也受权限约束,只返回当前连接账户可发现的事件。存在分页时,目录响应使用 nextCursor,下一次 events/list 请求使用 cursor;这枚目录分页游标与事件历史位置用途不同,代码里应分别命名。

新增字段前要考虑仍在续订的老客户端。示例采用严格的固定字段,便于验收;实际演进应协调消费者更新,破坏性变更可使用新事件名并保留迁移期。不要自造顶层 schemaVersion 充当现成协议字段。草案中的 Schema 演进说明讨论了这些边界。

3. 用认证身份、过滤条件和目的地确定订阅

宿主发来的 subscribe 请求包含事件名、arguments 和 delivery。下面所有尖括号内容都必须来自真实授权流程,callback.invalid 是故意不可用于投递的占位域名。样例没有请求命令,也没有任何有效密钥。ttlMs 只是希望获得的一小时租期,服务端会在响应里给出实际到期时间。

events/subscribe 消息形状 · 仅供阅读,不能用于真实订阅
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "events/subscribe",
  "params": {
    "name": "comment.created",
    "arguments": {
      "document_id": "<AUTHORIZED_DOCUMENT_ID>"
    },
    "delivery": {
      "mode": "webhook",
      "url": "https://callback.invalid/<PLATFORM_SUPPLIED_CALLBACK>",
      "secret": "<PLATFORM_SUPPLIED_WHSEC_SECRET>"
    },
    "cursor": null,
    "ttlMs": 3600000
  }
}

订阅身份由已认证主体、callback URL、事件名和 arguments 共同决定。主体必须来自认证上下文,不能相信正文中额外塞入的 user_id。参数按规范化 JSON 比较,字段顺序变化不应产生第二条订阅。服务端据此派生稳定 id;secret、租期等可更新值不属于这个身份键。订阅身份与刷新规则解释了不同租户之间的隔离方式。

接收请求时依次检查资源访问权、事件名和参数、secret 格式、callback 安全性,再完成回调握手和存储。secret 要有 whsec_ 前缀,后续 Base64 解码得到 24 至 64 字节。对同一身份重复订阅,应更新已有记录。服务重启以后,已承诺租期内的订阅信息也必须仍然可用。

订阅响应示例 · 时间和 id 为演示值
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "id": "sub_demo_placeholder",
    "refreshBefore": "2026-10-05T13:00:00Z",
    "cursor": null,
    "truncated": false
  }
}

id 是接收方查找订阅的句柄,不提供访问权限。改变文档过滤器或 callback 会得到另一个订阅,旧订阅需要显式停止,或等待租期到期。否则两个目的地可能同时收到事件。存储中宜保留拥有者、过滤器、到期时间和状态;secret 应进入受保护存储,避免混入常规日志。

4. 先验证 callback,再允许业务数据离开服务

建立订阅后立即发送业务数据,会把 URL 输入校验和网络安全问题带进正常流量。当前接入流程要求先向 callback 发送已签名的 verification 通知,携带新生成、单次使用且短时有效的 challenge。接收方成功响应中回显同一 challenge,发送方检查 2xx 状态,并以常量时间比较确认一致,才激活投递。官方 callback 验证流程包含失败返回方式。

控制通知 body · 也要签名
{
  "type": "verification",
  "challenge": "<FRESH_SINGLE_USE_CHALLENGE>"
}
成功响应 body
{
  "challenge": "<SAME_CHALLENGE>"
}
图 03 / 原创概念示意
宿主调用 events/subscribe 并提供 delivery.url 与 delivery.secret。MCP 服务向 callback 发送已签名的 verification challenge。callback 验签并回显 challenge;只有 HTTP 2xx 且 challenge 完全匹配,服务才激活订阅并返回 id、refreshBefore。
验证 POST 使用独立 webhook-id 并签名。HTTP 2xx 与 challenge 常量时间比较一致共同构成订阅激活条件。
查看图示文字说明

宿主调用 events/subscribe 并提供 delivery.url 与 delivery.secret。MCP 服务向 callback 发送已签名的 verification challenge。callback 验签并回显 challenge;只有 HTTP 2xx 且 challenge 完全匹配,服务才激活订阅并返回 id、refreshBefore。

控制通知使用独立唯一的 webhook-id,带上 webhook-timestamp、webhook-signature 和 X-MCP-Subscription-Id。它没有业务事件的 eventId、name、data 形状,不能送进评论处理器。握手失败时,MCP 服务端在 events/subscribe 的 JSON-RPC 响应里返回 -32015,并把 timeout 或 challenge_failed 等归类原因放进 data.reason;这与 callback 的 HTTP 状态是两层信息。不要把远端错误页整段暴露给调用者。成功验证可按认证主体与 callback URL 做有界缓存,避免重复订阅触发不必要的 challenge。

callback 仍是不可信输入。生产发送方应要求 HTTPS,在实际连接时解析并校验目标地址,阻止私有、回环及其他非公网地址;连接必须使用已校验的地址,同时保留原主机名做 TLS 验证,并拒绝重定向。只在注册时解析一次域名不足以防止后续 DNS 变化。握手请求和业务投递都需要相同约束。

这些网络防护需要与 HTTP 客户端、DNS 解析和出站策略一起实现。本文没有提供一个看似完整却跳过这些步骤的公网发送脚本。离线实验只复现接收端的验签与回显,无法证明某个真实 callback 的 TLS、可达性或 SSRF 防护已经通过验收。

5. 按原始字节验签,保留事件时间与签名时间

当前集成使用 Standard Webhooks 的 HMAC 签名。把 webhook-id、点号、webhook-timestamp、点号编码为 UTF-8 前缀,拼上原始请求体字节,再用解码后的 secret 做 HMAC-SHA256。结果经 Base64 编码后,加 v1, 前缀放入 webhook-signature。签名定义与Standard Webhooks 规范可交叉核对。

图 04 / 原创概念示意
将 webhook-id、一个点、webhook-timestamp、一个点和原始 HTTP body 字节精确拼接,使用 secret 对应的密钥字节进行 HMAC-SHA256,结果 Base64 后加 v1, 前缀写入 webhook-signature。X-MCP-Subscription-Id 仅用于路由,不参与这条签名公式。
Standard Webhooks 的签名输入精确到字节:id、点、timestamp、点、raw body;订阅路由头不在该公式内。
查看图示文字说明

将 webhook-id、一个点、webhook-timestamp、一个点和原始 HTTP body 字节精确拼接,使用 secret 对应的密钥字节进行 HMAC-SHA256,结果 Base64 后加 v1, 前缀写入 webhook-signature。X-MCP-Subscription-Id 仅用于路由,不参与这条签名公式。

签名公式 · 完整实现见离线实验
signed_bytes = (webhook_id + "." + signed_at + ".").encode("utf-8") + raw_body
signature = "v1," + base64.b64encode(
    hmac.new(decoded_secret, signed_bytes, hashlib.sha256).digest()
).decode("ascii")

发送方应只序列化一次,签名和发送共用同一份字节。接收方须先验签,再解析 JSON。把正文解析成对象以后重新序列化,会改变空格、转义或字段顺序,导致原本正确的签名失败。X-MCP-Subscription-Id 用于查找预先配置的 secret,它不在上述签名串中;不能仅凭这个 header 信任请求。每个订阅使用独立 secret,可以限制错误路由的影响。

事件正文中的 timestamp 表示发生时间,webhook-timestamp 表示这次投递的签名时间。重试必须保留事件身份与发生时间,同时生成新的签名时间和签名。离线接收器采用前后各 300 秒的允许窗口,作为可调整的安全策略;这不代表平台承诺了一模一样的边界条件。

轮换期间,一个 header 可含多个以空格分隔的签名,接收方接受任何一个匹配的受信任密钥。比较签名需使用常量时间函数。正文大小、header 长度、时间戳格式和 JSON 重复键也值得单独检查,避免认证处理器被格式异常拖垮。

6. 运行一个完全离线的接收实验

本节提供 demo.py、receiver.py、fixtures.py 和 test_receiver.py 的完整代码。先在本机新建 examples 目录,按代码块标注的文件名保存,再运行下面的命令。示例只使用 Python 标准库,不安装依赖、不监听端口、不读取环境凭据,也不创建真实订阅。练习中的 secret 是固定的公开测试材料,只在本机模拟发送者与接收者之间使用。它不能复制进平台配置,不能作为生产签名密钥。

本地运行命令 · 无网络、无模型请求
cd examples
python3 -m unittest -v
python3 demo.py

demo.py 构造一条 comment.created,内容只涉及 demo_doc 的虚构评论,然后把序列化字节与签名 header 直接传给接收函数。首次接收会写入 SQLite inbox 和一个本地摘要任务;再次投递相同事件,只会返回重复结果。摘要任务只是数据库中的练习记录,没有调用模型或外部业务工具。

图 05 / 原创概念示意
接收端依次执行验签、数据校验、过滤,再进入 SQLite inbox 事务。在 BEGIN IMMEDIATE 事务中查询唯一键并插入,区分首次收件与本地重复。重复事件不重复入库;确认持久化后快速结束接收,不在回调中执行外部动作。
校验与过滤之后,用数据库唯一约束和事务实现并发安全去重;接收回调只负责可靠收件。
查看图示文字说明

接收端依次执行验签、数据校验、过滤,再进入 SQLite inbox 事务。在 BEGIN IMMEDIATE 事务中查询唯一键并插入,区分首次收件与本地重复。重复事件不重复入库;确认持久化后快速结束接收,不在回调中执行外部动作。

接收顺序是长度检查、订阅路由、签名和新鲜度验证、JSON 与固定结构校验、事件名及文档过滤,最后进入数据库事务。即便正文通过了签名,错误文档、错误事件名和 eventId/header 不一致仍然应该被挡住。签名说明消息没有被未持有该密钥的人任意改写,无法替代业务范围检查。

demo.py · 完整离线示例
"""完整离线入口:python3 demo.py;临时数据库退出时删除,不连接任何服务。"""

from pathlib import Path
import tempfile

from fixtures import NOW, event, headers, raw, subscriptions
from receiver import Receiver


def show(label, response):
    print(f"{label}: {response.status} {response.body}")


def main():
    print("纯离线教学模拟:公开测试 key;不监听、不联网、不调用模型。")
    with tempfile.TemporaryDirectory(prefix="mcp-events-local-") as directory:
        database = str(Path(directory) / "inbox.sqlite")
        receiver = Receiver(database, subscriptions(), clock=lambda: NOW)
        try:
            verification = raw({"type": "verification", "challenge": "local_challenge_123"})
            show("验签后回显 challenge", receiver.receive(
                verification, headers(verification, message_id="msg_verification_local_1")))

            payload = raw(event())
            signed_headers = headers(payload)
            show("第一次投递", receiver.receive(payload, signed_headers))
            show("完全重复", receiver.receive(payload, signed_headers))

            replay = event()
            replay["cursor"] = "opaque_retry_cursor"
            replay_payload = raw(replay)
            show("cursor 改变的已签名重投", receiver.receive(replay_payload, headers(replay_payload)))

            changed = event()
            changed["data"]["text"] = "同一事件 ID,却换了内容"
            changed_payload = raw(changed)
            show("同 ID 业务内容冲突", receiver.receive(changed_payload, headers(changed_payload)))
            show("原始字节被篡改", receiver.receive(payload + b" ", signed_headers))

            rows = receiver.db.execute("SELECT event_id, state FROM local_summary_jobs").fetchall()
            print("仅有本地待处理记录:", rows)
        finally:
            receiver.close()

        receiver = Receiver(database, subscriptions(), clock=lambda: NOW)
        try:
            show("关闭并重开数据库后的重复投递", receiver.receive(payload, signed_headers))
        finally:
            receiver.close()
    print("临时数据库已清理;未创建真实订阅,未执行摘要或外部动作。")


if __name__ == "__main__":
    main()

下方可展开 receiver.py、fixtures.py 和 test_receiver.py 的完整代码;与上面的 demo.py 保存到同一目录后,即可按本节命令运行。实现专门服务于本教程的固定数据结构,并非完整 MCP 服务、通用 JSON Schema 引擎或可直接部署的 webhook 网关。

展开完整 receiver.py
receiver.py · 完整离线示例
"""完全离线教学模型;不是平台 callback、HTTP 服务或生产 SDK。"""

import base64
import binascii
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import hmac
import json
import re
import sqlite3
import time
from urllib.parse import urlsplit


MAX_BODY = 256 * 1024  # 复现当前 MCP Events 集成的完整 body 上限
TOLERANCE = 300  # 投递时间戳允许 ±300 秒;不限制事件发生时间
ID = re.compile(r"[A-Za-z0-9_-]{1,200}\Z")  # 本例额外收紧 ID 格式
ISO_TIME = re.compile(
    r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?"
    r"(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)\Z"
)


class Rejected(Exception):
    def __init__(self, status, code):
        self.status, self.code = status, code
        super().__init__(code)


@dataclass(frozen=True)
class Response:
    status: int
    body: dict


@dataclass(frozen=True)
class Subscription:
    secrets: tuple  # 仅来自可信配置;轮换期可配置同一订阅的多个 key
    document_id: str = "demo_doc"
    event_name: str = "comment.created"


def decode_secret(secret):
    """支持标准 base64(包括省略末尾 padding);拒绝不合规配置。"""
    if not isinstance(secret, str) or not secret.startswith("whsec_"):
        raise ValueError("configured secret must start with whsec_")
    encoded = secret[6:]
    try:
        key = base64.b64decode(encoded + "=" * (-len(encoded) % 4), validate=True)
    except (ValueError, binascii.Error) as exc:
        raise ValueError("invalid configured base64 secret") from exc
    if not 24 <= len(key) <= 64:
        raise ValueError("configured key must contain 24..64 bytes")
    return key


def signature(raw_body, message_id, timestamp, key):
    """只为本地 fixture 签名;绝不生成、请求或替代平台 secret。"""
    message = (message_id + "." + timestamp + ".").encode("utf-8") + raw_body
    digest = hmac.new(key, message, hashlib.sha256).digest()
    return "v1," + base64.b64encode(digest).decode("ascii")


def normalized_headers(headers):
    normalized = {}
    if not isinstance(headers, dict) or len(headers) > 64:
        raise Rejected(400, "invalid_headers")
    for name, value in headers.items():
        if not isinstance(name, str) or not isinstance(value, str):
            raise Rejected(400, "invalid_headers")
        lower = name.lower()
        if lower in normalized or len(value) > 8192 or "\r" in value or "\n" in value:
            raise Rejected(400, "invalid_headers")
        normalized[lower] = value
    for name in ("webhook-id", "webhook-timestamp", "webhook-signature", "x-mcp-subscription-id"):
        if not normalized.get(name):
            raise Rejected(400, "missing_header")
    return normalized


def verify(raw_body, headers, keys, now):
    """精确原始字节验签在 JSON 解析之前;这里只实现 v1 HMAC-SHA256。"""
    message_id, timestamp = headers["webhook-id"], headers["webhook-timestamp"]
    if not ID.fullmatch(message_id) or not re.fullmatch(r"[0-9]{1,12}", timestamp):
        raise Rejected(400, "invalid_signed_metadata")
    supplied = []
    for item in headers["webhook-signature"].split(" "):
        version, sep, value = item.partition(",")
        if version != "v1" or not sep:
            continue
        try:
            digest = base64.b64decode(value, validate=True)
        except (ValueError, binascii.Error):
            continue
        if len(digest) == 32:
            supplied.append(digest)
    message = (message_id + "." + timestamp + ".").encode("utf-8") + raw_body
    valid = False
    for key in keys:
        expected = hmac.new(key, message, hashlib.sha256).digest()
        for digest in supplied:
            valid |= hmac.compare_digest(expected, digest)
    if not valid:
        raise Rejected(401, "invalid_signature")
    if abs(now - int(timestamp)) > TOLERANCE:
        raise Rejected(401, "timestamp_outside_window")


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("duplicate JSON key")
        result[key] = value
    return result


def reject_constant(value):
    raise ValueError("non-finite JSON number: " + value)


def parse_body(raw_body):
    try:
        body = json.loads(raw_body.decode("utf-8"), object_pairs_hook=unique_object,
                          parse_constant=reject_constant)
        # 也拒绝转义的孤立 surrogate,避免后续 UTF-8 存储异常。
        json.dumps(body, ensure_ascii=False, allow_nan=False).encode("utf-8")
    except (ValueError, UnicodeError, RecursionError) as exc:
        raise Rejected(400, "invalid_json") from exc
    if not isinstance(body, dict):
        raise Rejected(400, "invalid_schema")
    return body


def require_fields(body, required, optional=()):
    if not isinstance(body, dict) or not set(required) <= body.keys():
        raise Rejected(400, "invalid_schema")
    if body.keys() - set(required) - set(optional):
        raise Rejected(400, "invalid_schema")
    if "_meta" in body and not isinstance(body["_meta"], dict):
        raise Rejected(400, "invalid_schema")


def text(value, maximum=4096, empty=False):
    if not isinstance(value, str) or len(value) > maximum or (not empty and not value):
        raise Rejected(400, "invalid_schema")
    return value


def canonical_event(body, headers, subscription):
    """手写的示例 schema 检查,不是通用 JSON Schema validator。"""
    require_fields(body, ("name", "eventId", "timestamp", "data"), ("cursor", "_meta"))
    for field in ("name", "eventId", "timestamp"):
        text(body[field], 200)
    if body["eventId"] != headers["webhook-id"]:
        raise Rejected(400, "event_id_mismatch")
    if body["name"] != subscription.event_name:
        raise Rejected(422, "event_name_mismatch")
    if body.get("cursor") is not None:
        text(body["cursor"], 4096, empty=True)
    stamp = body["timestamp"]
    try:
        if not ISO_TIME.fullmatch(stamp):
            raise ValueError("expected timezone-aware ISO timestamp")
        event_time = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
        event_time = event_time.astimezone(timezone.utc).isoformat()
    except (ValueError, OverflowError) as exc:
        raise Rejected(400, "invalid_event_timestamp") from exc
    data = body["data"]
    require_fields(data, ("document_id", "comment_id", "text", "url"))
    text(data["document_id"], 200)
    text(data["comment_id"], 200)
    text(data["text"], 10000, empty=True)
    text(data["url"], 4096)
    try:
        url = urlsplit(data["url"])
        if (url.scheme != "https" or not url.hostname
                or url.username is not None or url.password is not None):
            raise ValueError("expected HTTPS display URL")
    except ValueError as exc:
        raise Rejected(400, "invalid_schema") from exc
    if data["document_id"] != subscription.document_id:
        raise Rejected(422, "document_filter_mismatch")
    # 本地策略:比较不可变业务字段;重投可更换 cursor、_meta、JSON 排版。
    core = {"name": body["name"], "eventId": body["eventId"],
            "timestamp": event_time, "data": data}
    return json.dumps(core, sort_keys=True, ensure_ascii=False, separators=(",", ":"))


class Receiver:
    def __init__(self, database, subscriptions, clock=time.time):
        self.clock, self.subscriptions, self.keys = clock, dict(subscriptions), {}
        owners = {}
        for sid, sub in self.subscriptions.items():
            if not ID.fullmatch(sid) or not sub.secrets:
                raise ValueError("invalid local subscription configuration")
            self.keys[sid] = tuple(decode_secret(secret) for secret in sub.secrets)
            for key in self.keys[sid]:
                if key in owners and owners[key] != sid:
                    raise ValueError("never reuse a key across subscriptions")
                owners[key] = sid
        self.db = sqlite3.connect(database, isolation_level=None)
        self.db.execute("PRAGMA foreign_keys = ON")
        self.db.executescript("""
            CREATE TABLE IF NOT EXISTS inbox (
                subscription_id TEXT NOT NULL, event_id TEXT NOT NULL,
                canonical_core TEXT NOT NULL, raw_body BLOB NOT NULL,
                accepted_at REAL NOT NULL,
                PRIMARY KEY (subscription_id, event_id));
            CREATE TABLE IF NOT EXISTS local_summary_jobs (
                subscription_id TEXT NOT NULL, event_id TEXT NOT NULL,
                state TEXT NOT NULL DEFAULT 'pending_local_only',
                PRIMARY KEY (subscription_id, event_id),
                FOREIGN KEY (subscription_id, event_id)
                    REFERENCES inbox(subscription_id, event_id));
        """)

    def close(self):
        self.db.close()

    def receive(self, raw_body, headers):
        try:
            if not isinstance(raw_body, bytes):
                raise Rejected(400, "body_must_be_bytes")
            if len(raw_body) > MAX_BODY:
                raise Rejected(413, "body_too_large")
            headers = normalized_headers(headers)
            sid = headers["x-mcp-subscription-id"]
            if sid not in self.subscriptions:
                raise Rejected(404, "unknown_subscription")
            # 路由头本身不在签名输入中;不同订阅必须使用不同可信 key。
            verify(raw_body, headers, self.keys[sid], self.clock())
            body = parse_body(raw_body)
            if body.get("type") == "verification":
                return self._verification(body)
            core = canonical_event(body, headers, self.subscriptions[sid])
            return self._record(sid, body["eventId"], core, raw_body)
        except Rejected as exc:
            return Response(exc.status, {"error": exc.code})
        except sqlite3.Error:
            return Response(503, {"error": "local_storage_unavailable"})

    def _verification(self, body):
        require_fields(body, ("type", "challenge"))
        text(body["challenge"], 4096)
        return Response(200, {"challenge": body["challenge"]})

    def _record(self, sid, event_id, core, raw_body):
        self.db.execute("BEGIN IMMEDIATE")
        try:
            row = self.db.execute(
                "SELECT canonical_core FROM inbox WHERE subscription_id=? AND event_id=?",
                (sid, event_id)).fetchone()
            if row:
                if row[0] != core:
                    raise Rejected(409, "event_content_conflict")
                result = Response(200, {"status": "duplicate"})
            else:
                self.db.execute("INSERT INTO inbox VALUES (?, ?, ?, ?, ?)",
                                (sid, event_id, core, raw_body, self.clock()))
                self.db.execute(
                    "INSERT INTO local_summary_jobs (subscription_id, event_id) VALUES (?, ?)",
                    (sid, event_id))
                result = Response(202, {"status": "accepted_local_only"})
            self.db.execute("COMMIT")
            return result
        except Exception:
            self.db.execute("ROLLBACK")
            raise
展开公开测试材料 fixtures.py
fixtures.py · 完整离线示例
"""公开的本地测试夹具。以下 secret 没有保密性,绝不能用于实际接入。"""

import base64
import json

from receiver import Subscription, decode_secret, signature


NOW = 1_800_000_000
SUBSCRIPTION_ID = "sub_local_demo"
OTHER_SUBSCRIPTION_ID = "sub_local_other"
# 32 字节可预测 ASCII:教学测试值,不是平台 secret。
LOCAL_TEST_SECRET = "whsec_" + base64.b64encode(b"PUBLIC_LOCAL_TEST_ONLY_KEY_000001").decode()
ROTATED_TEST_SECRET = "whsec_" + base64.b64encode(b"PUBLIC_LOCAL_TEST_ONLY_KEY_000002").decode()
OTHER_TEST_SECRET = "whsec_" + base64.b64encode(b"PUBLIC_LOCAL_TEST_ONLY_KEY_000003").decode()


def subscriptions():
    return {
        SUBSCRIPTION_ID: Subscription((LOCAL_TEST_SECRET,)),
        OTHER_SUBSCRIPTION_ID: Subscription((OTHER_TEST_SECRET,)),
    }


def event(event_id="evt_local_1"):
    return {
        "name": "comment.created",
        "eventId": event_id,
        "timestamp": "2027-01-15T08:00:00Z",
        "data": {"document_id": "demo_doc", "comment_id": "comment_local_1",
                 "text": "请核对第三段的引用。", "url": "https://example.invalid/comments/1"},
        "cursor": "opaque_local_cursor_1",
    }


def raw(body):
    return json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")


def headers(raw_body, message_id="evt_local_1", timestamp=NOW,
            subscription_id=SUBSCRIPTION_ID, secret=LOCAL_TEST_SECRET):
    timestamp = str(timestamp)
    return {
        "webhook-id": message_id,
        "webhook-timestamp": timestamp,
        "webhook-signature": signature(raw_body, message_id, timestamp, decode_secret(secret)),
        "X-MCP-Subscription-Id": subscription_id,
    }
展开完整离线测试 test_receiver.py
test_receiver.py · 完整离线示例
"""无网络、无依赖包、无监听端口;所有行为均在临时 SQLite 中断言。"""

import base64
from concurrent.futures import ThreadPoolExecutor
import json
from pathlib import Path
import tempfile
import threading
import unittest
from unittest.mock import patch

from fixtures import (LOCAL_TEST_SECRET, NOW, OTHER_SUBSCRIPTION_ID, OTHER_TEST_SECRET,
                      ROTATED_TEST_SECRET, SUBSCRIPTION_ID, event, headers, raw,
                      subscriptions)
from receiver import (MAX_BODY, Receiver, Subscription, decode_secret,
                      signature, verify)


class SignatureTests(unittest.TestCase):
    def test_official_known_vector(self):
        # 官方 Python tests/test_webhooks.py:test_sign_function 的公开向量。
        # Official public vector: Standard Webhooks Python tests (see README.md).
        key = decode_secret("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw")
        payload = b'{"test": 2432232314}'
        message_id, stamp = "msg_p5jXN8AQM9LWM0D4loKWxJek", "1614265330"
        expected = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
        self.assertEqual(signature(payload, message_id, stamp, key), expected)
        verify(payload, {"webhook-id": message_id, "webhook-timestamp": stamp,
                         "webhook-signature": expected}, (key,), int(stamp))

    def test_secret_format_and_size(self):
        for size in (24, 32, 64):
            value = "whsec_" + base64.b64encode(b"x" * size).decode()
            self.assertEqual(decode_secret(value), b"x" * size)
            self.assertEqual(decode_secret(value.rstrip("=")), b"x" * size)
        bad = ("whsec_", "not_a_secret", "whsec_@@@", "whsec_" + "eA==",
               "whsec_" + base64.b64encode(b"x" * 65).decode())
        for value in bad:
            with self.subTest(value=value), self.assertRaises(ValueError):
                decode_secret(value)


class ReceiverTests(unittest.TestCase):
    def setUp(self):
        self.directory = tempfile.TemporaryDirectory()
        self.path = str(Path(self.directory.name) / "inbox.sqlite")
        self.receiver = Receiver(self.path, subscriptions(), clock=lambda: NOW)

    def tearDown(self):
        self.receiver.close()
        self.directory.cleanup()

    def send(self, body=None, **header_options):
        body = event() if body is None else body
        payload = raw(body)
        return self.receiver.receive(payload, headers(payload, **header_options))

    def counts(self):
        return tuple(self.receiver.db.execute("SELECT COUNT(*) FROM " + name).fetchone()[0]
                     for name in ("inbox", "local_summary_jobs"))

    def test_valid_event_is_only_durably_queued(self):
        response = self.send()
        self.assertEqual((response.status, response.body),
                         (202, {"status": "accepted_local_only"}))
        self.assertEqual(self.counts(), (1, 1))
        state = self.receiver.db.execute("SELECT state FROM local_summary_jobs").fetchone()[0]
        self.assertEqual(state, "pending_local_only")

    def test_body_whitespace_change_breaks_old_signature(self):
        payload = raw(event())
        response = self.receiver.receive(payload + b" ", headers(payload))
        self.assertEqual(response.status, 401)
        self.assertEqual(self.counts(), (0, 0))

    def test_tampered_signed_headers(self):
        payload = raw(event())
        for name, value in (("webhook-id", "evt_other"), ("webhook-timestamp", str(NOW + 1))):
            tampered = headers(payload)
            tampered[name] = value
            with self.subTest(name=name):
                self.assertEqual(self.receiver.receive(payload, tampered).status, 401)

    def test_expired_and_future_delivery_timestamps(self):
        for offset in (-301, 301):
            with self.subTest(offset=offset):
                response = self.send(timestamp=NOW + offset)
                self.assertEqual(response.body, {"error": "timestamp_outside_window"})

    def test_time_window_boundary_is_inclusive(self):
        for offset in (-300, 300):
            with self.subTest(offset=offset):
                self.assertIn(self.send(timestamp=NOW + offset).status, (200, 202))

    def test_unknown_subscription(self):
        self.assertEqual(self.send(subscription_id="sub_unknown").status, 404)

    def test_unsigned_routing_header_cannot_switch_subscription(self):
        payload = raw(event())
        changed = headers(payload)
        changed["X-MCP-Subscription-Id"] = OTHER_SUBSCRIPTION_ID
        self.assertEqual(self.receiver.receive(payload, changed).status, 401)

    def test_configuration_rejects_cross_subscription_key_reuse(self):
        config = subscriptions()
        config[OTHER_SUBSCRIPTION_ID] = Subscription((LOCAL_TEST_SECRET,))
        with self.assertRaises(ValueError):
            Receiver(":memory:", config)

    def test_body_event_id_must_match_signed_header(self):
        self.assertEqual(self.send(event("evt_other")).body, {"error": "event_id_mismatch"})

    def test_wrong_event_name_and_document_filter(self):
        wrong_name = event()
        wrong_name["name"] = "comment.deleted"
        wrong_document = event()
        wrong_document["data"]["document_id"] = "some_other_doc"
        for body in (wrong_name, wrong_document):
            with self.subTest(body=body):
                self.assertEqual(self.send(body).status, 422)

    def test_invalid_schema_and_timestamp(self):
        invalid = []
        body = event(); del body["data"]["comment_id"]; invalid.append(body)
        body = event(); body["data"]["text"] = 42; invalid.append(body)
        body = event(); body["data"]["url"] = "file:///etc/passwd"; invalid.append(body)
        body = event(); body["data"]["extra"] = True; invalid.append(body)
        body = event(); body["timestamp"] = "2027-01-15T08:00:00"; invalid.append(body)
        body = event(); body["timestamp"] = "2027-02-30T08:00:00Z"; invalid.append(body)
        body = event(); body["timestamp"] = "2027-01-15T08:00:00+00:60"; invalid.append(body)
        body = event(); body["timestamp"] = "0001-01-01T00:00:00+01:00"; invalid.append(body)
        body = event(); body["timestamp"] = "9999-12-31T23:59:59-01:00"; invalid.append(body)
        body = event(); body["cursor"] = 4; invalid.append(body)
        body = event(); body["_meta"] = []; invalid.append(body)
        body = event(); body["secret"] = LOCAL_TEST_SECRET; invalid.append(body)
        invalid.extend(([], None, 4, "event"))
        for body in invalid:
            payload = raw(body)
            with self.subTest(body=body):
                self.assertEqual(self.receiver.receive(payload, headers(payload)).status, 400)
        self.assertEqual(self.counts(), (0, 0))

    def test_duplicate_json_keys_nonfinite_invalid_utf8_and_surrogate(self):
        bad_payloads = (b'{"name":"comment.created","name":"comment.deleted"}',
                        b'{"data":{"text":"a","text":"b"}}',
                        b'{"data":NaN}', b'{"data":Infinity}', b'{"data":1e400}',
                        b'\xff', b'{"x":"\\ud800"}', b'{')
        for payload in bad_payloads:
            with self.subTest(payload=payload):
                response = self.receiver.receive(payload, headers(payload))
                self.assertEqual(response.body, {"error": "invalid_json"})

    def test_invalid_signature_is_checked_before_json(self):
        payload = b'{invalid'
        bad_headers = headers(payload)
        bad_headers["webhook-signature"] = "v1,not_base64"
        self.assertEqual(self.receiver.receive(payload, bad_headers).body,
                         {"error": "invalid_signature"})

    def test_dual_signature_rotation_and_unknown_versions(self):
        payload = raw(event())
        old_headers = headers(payload)
        new_headers = headers(payload, secret=ROTATED_TEST_SECRET)
        combined = dict(old_headers)
        combined["webhook-signature"] = ("v2,ignored v1,malformed " +
            new_headers["webhook-signature"] + " " + old_headers["webhook-signature"])
        self.assertEqual(self.receiver.receive(payload, combined).status, 202)
        self.receiver.close()
        config = subscriptions()
        config[SUBSCRIPTION_ID] = Subscription((ROTATED_TEST_SECRET,))
        self.receiver = Receiver(self.path, config, clock=lambda: NOW)
        self.assertEqual(self.receiver.receive(payload, combined).status, 200)
        self.assertEqual(self.receiver.receive(payload, old_headers).status, 401)

    def test_duplicate_survives_restart(self):
        self.assertEqual(self.send().status, 202)
        self.assertEqual(self.send().body, {"status": "duplicate"})
        self.receiver.close()
        self.receiver = Receiver(self.path, subscriptions(), clock=lambda: NOW)
        self.assertEqual(self.send().body, {"status": "duplicate"})
        self.assertEqual(self.counts(), (1, 1))

    def test_concurrent_connections_create_only_one_job(self):
        barrier = threading.Barrier(2)
        payload = raw(event())
        def deliver():
            receiver = Receiver(self.path, subscriptions(), clock=lambda: NOW)
            try:
                barrier.wait(timeout=5)
                return receiver.receive(payload, headers(payload)).status
            finally:
                receiver.close()
        with ThreadPoolExecutor(max_workers=2) as pool:
            responses = list(pool.map(lambda _: deliver(), range(2)))
        self.assertEqual(sorted(responses), [200, 202])
        self.assertEqual(self.counts(), (1, 1))

    def test_cursor_meta_json_layout_and_equivalent_timezone_can_change(self):
        self.send()
        body = event()
        body["cursor"] = "different_opaque_cursor"
        body["_meta"] = {"attempt": 2}
        body["timestamp"] = "2027-01-15T09:00:00+01:00"
        payload = json.dumps(body, sort_keys=True, indent=2).encode("utf-8")
        response = self.receiver.receive(payload, headers(payload, timestamp=NOW + 1))
        self.assertEqual(response.status, 200)
        self.assertEqual(self.counts(), (1, 1))

    def test_cursor_is_optional(self):
        body = event()
        del body["cursor"]
        self.assertEqual(self.send(body).status, 202)
        body["cursor"] = None
        self.assertEqual(self.send(body).body, {"status": "duplicate"})

    def test_same_event_id_different_core_content_is_conflict(self):
        self.send()
        for field, value in (("text", "changed"), ("comment_id", "another"),
                             ("url", "https://example.invalid/comments/2")):
            body = event()
            body["data"][field] = value
            with self.subTest(field=field):
                self.assertEqual(self.send(body).body, {"error": "event_content_conflict"})
        body = event(); body["timestamp"] = "2027-01-15T08:00:01Z"
        self.assertEqual(self.send(body).status, 409)
        self.assertEqual(self.counts(), (1, 1))

    def test_same_event_id_is_scoped_to_subscription(self):
        self.send()
        self.assertEqual(self.send(subscription_id=OTHER_SUBSCRIPTION_ID,
                                   secret=OTHER_TEST_SECRET).status, 202)
        self.assertEqual(self.counts(), (2, 2))

    def test_job_failure_rolls_back_inbox_and_retry_succeeds(self):
        self.receiver.db.execute("""CREATE TRIGGER fail_job BEFORE INSERT ON local_summary_jobs
                                 BEGIN SELECT RAISE(ABORT, 'injected local test failure'); END""")
        self.assertEqual(self.send().status, 503)
        self.assertEqual(self.counts(), (0, 0))
        self.receiver.db.execute("DROP TRIGGER fail_job")
        self.assertEqual(self.send().status, 202)
        self.assertEqual(self.counts(), (1, 1))

    def test_out_of_order_events_are_accepted_without_order_claim(self):
        newer, older = event("evt_newer"), event("evt_older")
        newer["timestamp"], older["timestamp"] = "2027-01-15T08:00:10Z", "2027-01-15T08:00:00Z"
        self.assertEqual(self.send(newer, message_id="evt_newer").status, 202)
        self.assertEqual(self.send(older, message_id="evt_older").status, 202)
        self.assertEqual(self.counts(), (2, 2))

    def verification(self):
        return {"type": "verification", "challenge": "local_challenge_123"}

    def test_verification_echo_after_signature_without_business_job(self):
        body = self.verification()
        response = self.send(body, message_id="verification_local_1")
        self.assertEqual(
            (response.status, response.body),
            (200, {"challenge": "local_challenge_123"}),
        )
        self.assertEqual(self.counts(), (0, 0))

    def test_verification_wrong_signature_does_not_echo(self):
        payload = raw(self.verification())
        response = self.receiver.receive(payload, headers(payload, secret=OTHER_TEST_SECRET))
        self.assertEqual(response.body, {"error": "invalid_signature"})

    def test_verification_invalid_challenge(self):
        for challenge in (None, "", "x" * 4097):
            body = self.verification(); body["challenge"] = challenge
            with self.subTest(challenge_type=type(challenge)):
                self.assertEqual(self.send(body).status, 400)

    def test_oversize_body_rejected_before_parse_or_storage(self):
        payload = b"x" * (MAX_BODY + 1)
        self.assertEqual(self.receiver.receive(payload, headers(payload)).status, 413)
        self.assertEqual(self.counts(), (0, 0))

    def test_body_at_exact_size_limit_is_accepted(self):
        payload = raw(event())
        payload += b" " * (MAX_BODY - len(payload))
        self.assertEqual(self.receiver.receive(payload, headers(payload)).status, 202)

    def test_no_network_operation_is_needed(self):
        with patch("socket.socket", side_effect=AssertionError("network forbidden")), \
             patch("socket.create_connection", side_effect=AssertionError("network forbidden")), \
             patch("socket.getaddrinfo", side_effect=AssertionError("DNS forbidden")):
            self.assertEqual(self.send().status, 202)
            self.assertEqual(self.send(self.verification()).status, 200)

    def test_header_names_are_case_insensitive(self):
        payload = raw(event())
        mixed = {name.upper(): value for name, value in headers(payload).items()}
        self.assertEqual(self.receiver.receive(payload, mixed).status, 202)

    def test_invalid_or_ambiguous_headers_and_non_bytes(self):
        payload = raw(event())
        cases = []
        bad = headers(payload); del bad["webhook-id"]; cases.append(bad)
        bad = headers(payload); bad["Webhook-Id"] = bad["webhook-id"]; cases.append(bad)
        bad = headers(payload); bad["webhook-id"] = "evt.with.dot"; cases.append(bad)
        bad = headers(payload); bad["webhook-timestamp"] = "1.2"; cases.append(bad)
        bad = headers(payload); bad["webhook-id"] = "x\r\nInjected: y"; cases.append(bad)
        for bad in cases:
            with self.subTest(headers=bad):
                self.assertEqual(self.receiver.receive(payload, bad).status, 400)
        self.assertEqual(self.receiver.receive(payload.decode(), headers(payload)).status, 400)


if __name__ == "__main__":
    unittest.main()

7. 把收件落库与业务动作分别做成幂等

接收方已经写入数据,但成功响应在网络中丢失时,发送方会再次投递。同一事件因此可能出现多次。先查询是否存在、再插入的两步操作,在并发下仍可能同时通过;唯一约束和事务更适合把这件事交给数据库裁决。

示例使用 subscription_id 与 event_id 组成联合主键。接收记录和本地待办在同一事务里提交,避免出现记录已去重、待办却没有创建的半成品。模拟故障测试会在事务中途抛错,再确认两张表都没有留下部分结果。程序重开同一数据库以后,唯一键仍能阻止重复写入。

正文格式化不同不必然代表业务内容不同。示例对 eventId、name、timestamp、data 做稳定序列化后比较;cursor 不参与这次比较,因为重投时安全游标可以变化;本实验不实现 cursor 的保存与恢复。同 id 对应不同业务内容会被拒绝为冲突,防止静默覆盖。这是本地的完整性策略,额外响应字段和具体状态码均不属于 MCP Events 的通用要求。

数据库收件幂等只覆盖本地这一段。任务以后若要创建 issue、发消息或修改文档,仍需把事件身份与目标动作组合成业务幂等键,并交给支持该语义的目标接口或事务处理。把进程执行过某行代码当成操作完成,会在崩溃重试时留下重复动作。当前官方投递说明也提醒写入工具需要具备幂等性。

协议的 2xx 确认表示接收成功,宿主可以异步处理事件。它无法证明模型已经运行、任务已经成功,或下游修改已经落地。运维视图最好分别显示投递被接收、任务已安排、业务动作完成,让每个状态都有对应证据。

8. 续订、到期和密钥轮换都走已有订阅

renew 是常见的口头说法,这里实际调用的仍是 events/subscribe,没有额外发明 events/renew。宿主在 refreshBefore 之前,用相同订阅身份和最后保存的 cursor 再次请求;服务端更新租期,返回新截止时间。调用方应根据这次响应安排下次刷新,而不是一直相信最初请求的 ttlMs。

图 06 / 原创概念示意
订阅应在 refreshBefore 之前再次调用 events/subscribe 续订。成功续订获得新的 refreshBefore;未及时续订则到期停止投递。替换 secret 时,仅在短暂重叠窗口用旧、新密钥生成双签,以兼容交接,之后停用旧密钥。
截止前用 events/subscribe 续订;更换 secret 时使用短窗双签,交接结束后停止旧密钥签名。
查看图示文字说明

订阅应在 refreshBefore 之前再次调用 events/subscribe 续订。成功续订获得新的 refreshBefore;未及时续订则到期停止投递。替换 secret 时,仅在短暂重叠窗口用旧、新密钥生成双签,以兼容交接,之后停用旧密钥。

ttlMs 省略表示使用服务端默认租期;有限值请求对应的时长;null 表示请求无到期时间。服务端只有接受无到期请求时才应返回 refreshBefore: null,否则返回有限时间。实际授予时间通常不应超过请求,防止过短租期引发刷新风暴的服务端最小值属于例外。租期协商规则与当前集成刷新说明共同界定了这一行为。

生产实现应为已授予的存续期持久保存订阅,并在到期后停止投递。不要因为进程重启而默认把旧订阅忘掉,也不要把数据库里有一行记录误当成永远有效。资源权限撤销、账户断开和正常到期,都应有明确的停止路径。

刷新可以携带新的 signing secret。发送方替换存储值,并在有限过渡期用新旧密钥分别签名,将结果放进同一 header。过渡期是实施参数,应记录开始与结束时间;不能无限期保留旧密钥。示例测试了多签名验证,但没有模拟真实平台分发和轮换时序。

停止订阅时,events/unsubscribe 使用原来的事件名、arguments 和 callback URL,并按已认证账户检查权限。成功后停止该身份的投递,重复停止也要安全。返回空 result 的当前集成方式,比再向已停用 callback 发业务消息更容易形成清晰的收尾状态。

9. cursor 能帮助恢复,也可能暴露恢复缺口

cursor 是服务端定义的不透明位置。客户端保存它并在恢复或刷新时原样传回,无需猜测里面是数据库行号、消息队列偏移还是编码字符串。cursor: null 的事件类型不提供协议内重放能力,中断期间漏掉的变化不能靠重复订阅自动补齐。

图 07 / 原创概念示意
日志位置 101 已收到,102 尚待重试,103 已收到。安全 cursor 只能推进至连续已确认的 101,不能因为见到 103 就越过缺口。重放 cursor 之后的数据时,102 被补齐,而再次到来的 103 通过稳定 eventId 去重,之后可推进 cursor 到 103。
本图的 102 仍在有效重试,尚未被放弃。安全 cursor 保留这个缺口;重放补齐后,重复的 103 按稳定 eventId 去重。
查看图示文字说明

日志位置 101 已收到,102 尚待重试,103 已收到。安全 cursor 只能推进至连续已确认的 101,不能因为见到 103 就越过缺口。重放 cursor 之后的数据时,102 被补齐,而再次到来的 103 通过稳定 eventId 去重,之后可推进 cursor 到 103。

假设 101 已收到,102 还在重试,103 已收到。如果服务端把 103 当作已全部交付的位置,客户端恢复时可能直接跳过 102。因此,webhook 正文和订阅响应里的 cursor 必须是安全水位,不能越过仍待投递的事件。重试 103 时可能带上后来推进的 cursor,但它的 eventId 仍保持不变。草案的 cursor 生命周期详细说明了这种恢复语义。

安全水位也不能充当零丢失证明。草案允许重试耗尽、已被发送方放弃的事件不再阻塞水位;图中 102 仍处于有效重试阶段,才会继续阻止推进。生产系统应显式记录放弃原因与可能缺口,需要恢复时再依据仍保留的上游历史和授权范围安排对账。

安全水位与接收顺序需要分别考虑。接收器看到一个新 cursor,不应擅自把字符串转成整数,再用大小比较推导通用顺序。本文图中的 101、102、103 只是解释上游有序日志的编号;实验接收器不实现历史服务或水位推进,避免让本地演示误充完整重放系统。

所请求的历史已经超出保留窗口时,服务端使用 truncated: true 表示恢复存在缺口。收到这个信号后,可安排一次有范围的状态对账,或通知用户有一段变化无法复原。不要把缺口当成普通成功,也不要临时扫描账户全部历史来扩大原来的监控范围。

可靠重放依赖上游持久历史、合理保留时间和正确确认逻辑;有序交付与端到端恰好一次执行都不能仅靠启用 Events 获得。队列、重试和 cursor 改善恢复能力,业务动作的幂等仍需要单独设计。高频突发时还要考虑排队上限、过期工作和任务合批。

10. 事件正文是输入,任务授权来自用户与系统边界

一条评论即使通过验签,也可能由不可信的人写入。评论中要求删除仓库、泄露密钥或转发文件,仍然只是外部内容。签名证明的是某个持有订阅密钥的发送方交付了这些字节,不能证明评论作者有权扩大用户授予代理的操作范围。事件正文的信任边界与工具结果一样需要谨慎处理。

在创建监控任务时,把要看的资源、触发条件、允许产出的结果和需要审批的动作写清楚。例如:监控指定文档的新评论,生成变更建议并报告;这份授权可以支持读取相关评论和准备建议,却没有自动包含提交代码、给所有参与者发信或公开发布文档。正文后来出现的请求也不会自行补足这些权限。

发送端先按账户权限和订阅过滤器筛选事件,接收端再检查消息结构与订阅匹配,执行端继续按既定权限处理工具动作。三段检查有不同目的,适合分别记录结果。权限被撤销后,既要停止新投递,也要考虑队列里尚未处理的旧事件是否仍允许继续读取原资源。

数据可以只带定位字段和足够短的摘要,需要完整内容时再调用受权限约束的读取工具。日志默认保留事件 id、状态和耗时,减少存储完整评论。对会反过来触发新事件的写入,还要设计来源标记、业务幂等或条件判断,阻止代理自己的改动形成无限循环。

11. 沿着五个检查点定位故障

没有收到任务,不必立即怀疑模型。先把链路分成目录发现、订阅建立、握手验证、业务投递和任务执行五段;每段都保存可关联的 subscription id、eventId、尝试序号、结果类别和耗时。业务发生时间与尝试时间分别记录,排查延迟时才知道时间花在哪一段。

  • 目录看不到事件:检查 server/discover 版本与能力、events/list 权限、目录分页,以及变更后的插件重扫描。先确认宿主支持该扩展,再排查具体事件定义。
  • 订阅被拒绝:先核对认证主体是否有资源权限,再看 arguments 是否匹配 inputSchema。不要把字段拼错和权限不足合并成一个模糊的失败。
  • 握手失败:看 TLS、目的地校验、超时、签名及 challenge 回显。记录归类原因,避免记录 secret、完整 callback 查询串或远端响应中的私人信息。
  • 投递验签失败:对照原始字节、签名时间与时钟偏差、secret 的 Base64 解码、订阅路由,以及重试时有没有重新签名。
  • 2xx 后没有业务结果:检查宿主任务设置、合批、排队、执行权限与具体工具错误。发送日志中的成功只能证明前一段已接收。

每次请求只发送一个事件,完整 body 上限为 256 KiB。瞬时失败采用有界次数的指数退避,重试保留 eventId 并刷新签名时间;410 和 413 不应重试。当前 ChatGPT 投递限制与响应处理是此处的接入依据。固定重试次数、抖动算法与延迟预算由实现选择,教程不把它们写成协议常量。

建议对未投递积压、临近到期但尚未续订、权限撤销、签名失败率和恢复缺口分别设观察指标。排查超大正文时,应缩减业务摘要或改由读取工具获取详情,不能把一条协议事件随意切成多个没有身份关系的 HTTP 片段。

12. 本稿验证了什么,真正接入还需验收什么

本文的可执行部分以离线故障测试为验收依据。测试检查签名和时间窗、正文篡改、事件身份不一致、文档过滤、重复投递、跨数据库重开去重、事务回滚以及控制通知回显。图示为按协议关系绘制的原创概念图,并非产品截图。第 6 节提供完整测试代码和运行命令;离线验证结果不能证明生产环境已经跑通。

真实接入仍应在受支持的宿主中逐项测试:发现目录、收到平台提供的 callback 与 secret、完成公网 HTTPS 握手、匹配事件触发任务、不匹配事件不投递、到期续订、权限撤销、重启恢复、历史缺口,以及取消后停止。涉及写入工具时,还需验证重复调用和反馈循环。

本次验证没有创建真实订阅、使用生产凭据、调用模型或部署服务。公开测试材料帮助复现算法与失败分支,不提供真实平台的接入身份。准备部署时,应把真实宿主联调结果与本地测试报告分别保存,让读者和维护者都能看清每项结论对应哪一类证据。

参考资料与来源说明

本教程为 pan 的独立技术内容,与 OpenAI 官方文档相互区分。MCP Events 草案文档按 CC BY 4.0 发布;本文对相关技术说明做了中文改写和教学编排,原作者及许可见草案项目许可。签名实验另附来源与许可说明。

所有支持 MCP 的客户端都能用这份接入吗?

不能据此推断。当前教程按 OpenAI 文档中的 ChatGPT webhook 集成组织,其他宿主需要分别确认事件扩展、协议版本和传输方式。

为什么不直接给一个可部署的公网接收地址?

平台集成的 callback 与 secret 由宿主提供。本文的接收器只用于理解验证逻辑,不会代替平台地址,也没有实现生产网络与身份系统。

收到 2xx 就说明代理完成任务了吗?

2xx 确认 webhook 已被接收,宿主可以异步处理。任务执行及下游动作需要自己的状态和证据。

能把每个重试分配一个新的 eventId 吗?

不应这样处理同一事件。重试保留稳定 eventId,刷新签名时间和签名;否则接收方无法据此识别重复投递。

cursor: null 以后还能恢复漏掉的评论吗?

它表示该事件类型不通过协议提供重放。若业务需要补齐,应另行设计受授权范围约束的状态对账,并明确哪些历史无法恢复。

通过离线测试就可以直接上线吗?

还需要真实宿主联调、TLS 与目的地校验、持久订阅、密钥保护、权限撤销、容量及重试验收。教程的测试范围随包列出。