ai-ml

دليل أحداث MCP: بناء خادم Webhook (2026)

٣٠ سبتمبر ٢٠٢٦

MCP Events Tutorial: Build a Webhook Server (2026)

ملخص: MCP Events هي مسودة توسعة لبروتوكول Model Context Protocol تتيح لخادم MCP إخطار العميل عند حدوث شيء ما في النظام المصدر، بحيث يمكن للعميل react. في وضع webhook، يقوم الخادم بإرسال كل حدث عبر POST إلى عنوان URL للاستدعاء (callback URL) بدلاً من انتظار عملية الاستطلاع (polling).1

في DevDay بتاريخ 29 سبتمبر 2026، أعلنت OpenAI عن دعمها لهذه الميزة في أتمتة إضافات ChatGPT وأدرجتها كميزة متاحة لجميع الخطط. يستخدم تكامل ChatGPT تسليم الـ webhook فقط.234

لا تحتوي أي من حزم MCP SDK وأطر العمل السبعة التي قمت بتثبيتها في 30 سبتمبر على أي من أسماء طرق events/ الموجودة في المسودة. لذا قمت بكتابة جانب الـ webhook يدوياً باستخدام Node.js بسيط وأجريت 24 اختباراً عليه.5

هناك نتيجتان هما الأكثر أهمية. أحد المستلمين الذي قام بإزالة التكرار بناءً على webhook-id وحده قام بإسقاط حدث بصمت عندما اشتركت اشتراكان في عنوان URL استدعاء واحد. كما أن الجسم (body) الذي تم تحليله وإعادة تسلسله قبل التحقق فشل في فحص التوقيع.

يتكون الخادم والمستقبل معاً من 248 سطراً من Node.js مع تبعية واحدة من npm وهي standardwebhooks.

MCP Events: الإجابة المختصرة

MCP Events هي مسودة توسعة لبروتوكول Model Context Protocol. يقوم الخادم بسرد أنواع الأحداث الخاصة به باستخدام events/list. في وضع webhook، يستدعي العميل events/subscribe مع عنوان URL استدعاء HTTPS وسر توقيع قام العميل بتوليده بنفسه.1

يؤكد الخادم أن نقطة نهاية الاستدعاء ترغب في استلام التسليمات، ثم يرسل كل حدث هناك عبر POST، موقعاً وفقاً لمخطط Standard Webhooks. يقوم العميل بتجديد الاشتراك قبل refreshBefore، ويمكنه إنهاؤه مبكراً باستخدام events/unsubscribe.1

مخطط تسلسلي لوضع webhook في MCP Events: يستدعي العميل events/subscribe مع عنوان URL استدعاء وسر whsec_، يرسل الخادم تحدي تحقق موقع إلى المستقبل ما لم يكن ذلك الطرف وعنوان URL قد تم التحقق منهما بالفعل، يقوم المستقبل بترديد صدى التحدي، يعيد الخادم معرف الاشتراك و refreshBefore، ثم تتبع ذلك عمليات POST للأحداث الموقعة ويقوم العميل بالتجديد قبل انتهاء الصلاحية

ما ستتعلمه

  • ما هي MCP Events، ومن يقوم بكتابتها، وما الذي يدعمه ChatGPT اليوم
  • كيف تتكامل events/list و events/subscribe و events/unsubscribe معاً
  • كيفية التحقق من سر whsec_ وعنوان URL الاستدعاء، مع فحص العنوان وقت الاتصال
  • كيفية عمل تحدي التحقق وتوقيع Standard Webhooks، من خلال كود Node قابل للتشغيل
  • ثلاثة أخطاء شائعة للمستقبل قمت باختبارها: إزالة التكرار في عنوان URL المشترك، الأجسام المعاد تسلسلها، والتسليمات القديمة أو المعاد إرسالها
  • أي من حزم MCP SDK تدعم MCP Events اليوم، بناءً على البحث في الكود المنشور الخاص بها
  • قائمة مراجعة للإنتاج ومخرجات الاختبار الكاملة

ما هي MCP Events؟

يصف مخطط التصميم MCP Events كوسيلة تسمح لـ "عميل MCP بالاشتراك في الأشياء التي تحدث في نظام مصدر — رسالة Slack، أو دفع GitHub، أو حادث PagerDuty — وجعل العميل react عند وقوعها، دون الحاجة لوجود المستخدم."1

يعود هذا العمل إلى مجموعة عمل MCP Triggers and Events. ويشير سجل تغييرات ميثاقها إلى أن الميثاق الأولي كان في 24 مارس 2026، ويقودها كل من كلير ليغوري من Amazon Web Services وبيتر ألكسندر من Anthropic.6

توجد التفاصيل الفنية في ذلك المخطط التصميمي، الذي كتبه بيتر ألكسندر بتاريخ 19 فبراير 2026، والموسوم بـ "مقترح مسودة". وتربط صفحة OpenAI به كمرجع للبروتوكول الخاص بها.31

مستودع الحضانة الذي يحتوي على المخطط صريح بشأن حالته: محتوياته "استكشافية ولا تمثل مواصفات أو توصيات رسمية لـ MCP".7

يحدد المخطط ثلاثة أنماط للتسليم ولا يجعل أيًا منها إلزاميًا. يستخدم نمط الاستطلاع (Poll mode) events/poll، ويستخدم نمط الدفع (push mode) طلب events/stream طويل الأمد، بينما يستخدم نمط الـ webhook كل من events/subscribe و events/unsubscribe.1

ما الذي يدعمه ChatGPT؟

يسرد ملخص DevDay 2026 الخاص بـ OpenAI "أحداث MCP لأتمتة الإضافات": "نحن نضيف دعمًا لمواصفات MCP Events المقترحة، بحيث يمكن للإضافات بدء عمليات الأتمتة عند حدوث شيء ما في تطبيق متصل." وهي موسومة بأنها "متاحة لجميع الخطط".2

تضيف صفحة المطورين في OpenAI المتطلبات الفنية: إصدار البروتوكول 2026-07-28، والذي تسميه الصفحة MCP 2.0، وتخزين اشتراكات دائم، ووصول HTTPS خارجي إلى روابط الـ callback.3

يدعم التكامل تسليم الـ webhook والتحقق من الـ callback. وبكلمات OpenAI: "الاستطلاع (Polling)، والبث (streaming)، وإشعارات التحكم gap و terminated الواردة في المسودة غير مدعومة في هذا التكامل."3 وهذا هو السبب في أن هذا البرنامج التعليمي يبني نمط الـ webhook فقط.

في هذا التدفق، يكون ChatGPT هو العميل. عندما يطلب المستخدم مراقبة حدث ما، "يقوم ChatGPT باستدعاء events/subscribe مع اسم الحدث، ووسائط التصفية، ووجهة الـ webhook"، ويقوم ChatGPT بالإجابة على تحدي التحقق.3

أنا لم أقم بتوصيل هذا الخادم بـ ChatGPT. كل ما يلي تم اختباره مقابل المستقبل (receiver) الخاص بي على جهاز واحد.

كيف اختبرت ذلك

تتكون منصة الاختبار من ثلاثة ملفات: server.mjs (طرق الأحداث الثلاثة وكود التسليم)، و receiver.mjs (نقطة نهاية webhook في دور العميل) و demo.mjs (24 فحصًا مرقمًا). وقد تم تشغيلها على Node.js v22.23.2 مع standardwebhooks 1.1.1 من npm، في 30 سبتمبر 2026.8

طبقة JSON-RPC هي معالج node:http بسيط، وليست وسيلة نقل SDK، لأن أيًا من الحزم التي فحصتها لا تحتوي على هذه الطرق (راجع قسم SDK أدناه). تقول صفحة OpenAI بتقديمها "على نفس نقطة نهاية MCP الموثقة الخاصة بأدواتك"، لذا في الخادم الحقيقي، تكون بجانب أدواتك.3

يحدد الكود اختصاراته، وأكبرها هو DEV_ALLOW_LOOPBACK=1، والذي يسمح للعرض التوضيحي (demo) بالتسليم إلى http://127.0.0.1. هذا العلم (flag) يستثني هذا المضيف تحديداً فقط. أما كل رابط URL آخر فلا يزال يمر عبر فحوصات HTTPS والعناوين غير العامة، وهو ما يتم التحقق منه في التمرين من 4 إلى 6.

كيف يعمل events/subscribe؟

يحدد طلب الاشتراك اسم الحدث، ويمرر arguments يجب أن تطابق inputSchema الخاصة بالحدث، ويوفر delivery.mode و delivery.url و delivery.secret. الحقول الاختيارية هي cursor و maxAgeMs و ttlMs.1

يمكن فقط للمتصل المصرح له استخدامه: "يجب على الخوادم رفض المكالمات التي لا تحتوي على طرف مصرح له باستخدام -32012 Forbidden."1 كما يجب على الخادم أيضاً التحقق من أن الطرف المصرح له يمكنه الاشتراك في هذا الحدث بتلك الوسائط (arguments). يترك العرض التوضيحي الخاص بي هذا التحقق كعلامة مكانية (placeholder) محددة.1

يتم مفتاح اشتراك الـ webhook بواسطة أربع قيم: الطرف المصرح له، ورابط الـ callback URL، واسم الحدث، والوسائط (arguments). يشتق الخادم id حتمي من هذا المفتاح، وتؤدي المكالمة المتكررة بنفس المفتاح إلى تحديث ذلك الاشتراك بدلاً من إنشاء اشتراك جديد.31

تذكر كلتا الوثيقتين مقارنة arguments كـ canonical JSON. وتوضح صفحة OpenAI السبب: "قارن الوسائط باستخدام canonical JSON حتى لا يؤدي ترتيب مفاتيح الكائن إلى إنشاء اشتراكات مكررة."31

يقوم الخادم الخاص بي بفرز المفاتيح بشكل متكرر (recursively) قبل التجزئة (hashing). في اختبار منفصل، أنتج كل من {"document_id":"doc_789","extra":"x"} و {"extra":"x","document_id":"doc_789"} نفس الـ id للاشتراك.

تحمل الاستجابة id و refreshBefore و cursor و truncated.31 بالنسبة لأنواع الأحداث التي لا تدعم إعادة التشغيل (replay)، تقول صفحة OpenAI بإرجاع cursor: null، وإرجاع refreshBefore: null فقط عند الموافقة على طلب العميل ttlMs: null لاشتراك بدون تاريخ انتهاء.3

التحقق من السر ورابط الـ callback URL

يقوم العميل بتوليد السر؛ ولا يقوم الخادم بذلك أبداً. يجب أن يبدأ بـ whsec_ متبوعاً بـ base64 يتم فك تشفيره إلى 24 إلى 64 بايت، ويجب على الخوادم رفض أي شيء آخر باستخدام InvalidParams (-32602).1

يجب أن تستخدم روابط الـ Callback https://، ويتم أيضاً رفض أي رابط لا يستخدم HTTPS بالكود -32602.1 كما يجب على الخوادم رفض عناوين الـ callback التي لا يمكن توجيهها عالمياً (globally routable)، ويذكر المخطط (sketch) أن هذا الفحص يجب أن يتم وقت التسليم، وليس فقط وقت الاشتراك، لمنع هجمات DNS rebinding.1

مكان إجراء الفحص أمر بالغ الأهمية. يذكر كل من المخطط وصفحة OpenAI بضرورة الاتصال بالعنوان الذي تم التحقق منه مع الاحتفاظ باسم المضيف (hostname) الأصلي لـ TLS، لذا لا يمكن أن يتغير العنوان بين عملية الفحص والاتصال. ولا يسمح أي منهما بتتبع عمليات إعادة التوجيه (redirects).31

تقوم دالة post() الخاصة بي بذلك عن طريق تزويد node:https بدالة lookup مخصصة، بحيث لا يتصل الـ socket إلا بالعنوان الذي وافقت عليه تلك الدالة. الروابط التي تحتوي على IP صريح لا تستدعي lookup أبداً، لذا تقوم post() بفحصها بشكل منفصل. ولا تقوم أي من http.request أو https.request بتتبع عمليات إعادة التوجيه.

تقوم post() أيضاً بتمرير كائنات Agent خاصة بها. وبدونها، أدى اختبار منفصل على Node v22.22.2 مع ضبط NODE_USE_ENV_PROXY=1 و HTTPS_PROXY إلى إرسال callback لـ localhost إلى البروكسي كـ CONNECT localhost:9، وبالتالي لم يكتشف فحص العناوين الخاصة (private-address check) اسم المضيف هذا. أما مع استخدام الـ agents الصريحة، فقد تم رفض الاختبار نفسه بالكود -32602.

لقد تحققت من سلوك Node الذي يعتمد عليه هذا الأمر في سكريبت منفصل على Node v22.23.2، مقابل خادم TLS محلي بشهادة موقعة ذاتياً لـ events.test. وباستخدام دالة lookup تجريبية تعيد 127.0.0.1، وصل الطلب إلى ذلك العنوان، ورأى الخادم Host: events.test:8443 و SNI events.test.

نفس دالة lookup المستخدمة لـ other.test فشلت مع الخطأ ERR_TLS_CERT_ALTNAME_INVALID، مما يعني أن الشهادة لا تزال تُفحص مقابل اسم المضيف الخاص بالرابط. أما الروابط ذات الـ IP الصريح فلم تستدعِ lookup أبداً، وعادت استجابة 302 كـ 302 عادية من كل من http.request و https.request.

تستخدم isPrivate() ميزة net.BlockList في Node مع النطاقات التوضيحية الموجودة في المخطط، مثل 127.0.0.0/8 و 10.0.0.0/8 و fc00::/7، بالإضافة إلى 0.0.0.0/8 و ::. يشير المخطط إلى سجلات IANA الكاملة للأغراض الخاصة، لذا يرجى استخدام قائمة محدثة في بيئة الإنتاج.1

تتطلب عناوين IPv6 التي تم تعيينها كـ IPv4 (IPv4-mapped IPv6 literals) عناية خاصة. يقوم محلل الروابط في Node بتحويل [::ffff:10.0.0.5] إلى [::ffff:a00:5]، لذا فإن الفحص الذي يفك فقط صيغة ::ffff:10.0.0.5 المنقطة سيتجاهله. تقوم net.BlockList بمطابقة كلا الصيغتين مقابل قواعد IPv4 الخاصة بها، ويتضمن الفحص رقم 5 العنوان [::ffff:10.0.0.5].

كان رفض العنوان غير العام بالكود -32602 خياري الشخصي. يخصص جدول الأخطاء في المخطط الكود -32602 لروابط الـ callback المشوهة أو التي لا تستخدم HTTPS، ولا يحدد كوداً لهذه الحالة.1 رسالة الخطأ لا تذكر العنوان الذي تم حله، لذا فهي لا تخبر المتصلين بأي IP داخلي يشير إليه اسم المضيف.

تحدي التحقق (The verification challenge)

يذكر المخطط أنه يجب على الخادم ألا يقوم بالتسليم إلى رابط callback حتى يتم تأكيد رغبة الطرف المستلم في استقبال التسليمات. ويسمح بأربع طرق: مصافحة التحدي (challenge handshake)، أو قائمة سماح (allowlist) يحددها الخادم، أو تحقق مسبق خارج النطاق (out-of-band verification)، أو مستند معروف (well-known document) ينشره أصل المستلم.1

تصف صفحة OpenAI عملية المصافحة (handshake). يقوم الخادم بإرسال طلب POST يحتوي على {"type":"verification","challenge":"<nonce>"} موقع، وتقوم نقطة النهاية (endpoint) برد {"challenge":"<nonce>"} في استجابة 2xx، ثم يقارن الخادم التحدي في وقت ثابت قبل تفعيل التسليم.31

يتم تخزين التحقق في الذاكرة المؤقتة (cached) لكل (principal, url): بمجرد اجتياز نقطة نهاية أحد المبادئ (principals)، لا تحتاج اشتراكاته الأخرى في ذلك الرابط (URL) إلى تحدٍ جديد، كما أن تحقق أحد المبادئ لا يغطي أبداً تحقق مبدأ آخر.1 تذكر صفحة OpenAI الاحتفاظ بهذا التخزين المؤقت "لفترة محدودة"؛ بينما يحتفظ خادمي بكل نجاح لمدة 24 ساعة.3

توضح الفحوصات 10 و 11 عمل الذاكرة المؤقتة. اشتراك ثانٍ لـ Alice في نفس الرابط أرسل 0 من طلبات POST للتحدي، بينما أرسل اشتراك Bob الأول هناك طلباً واحداً.

تساعد الذاكرة المؤقتة فقط بعد النجاح: في خادمي، نقطة النهاية التي لا ترد بالتحدي تتلقى تحدياً جديداً في كل محاولة اشتراك. في هذه الحالة، يشير المخطط إلى أن طلبات POST للتحدي يجب أن تكون محدودة المعدل (rate-limited) لكل مضيف وجهة، وهو ما لا يفعله العرض التوضيحي الخاص بي.1

تحمل أغلفة التحكم مثل التحدي webhook-id بصيغة msg_<type>_<random>، حتى يتمكن المستلمون من إزالة تكرار محاولات إعادة الإرسال.1 نقطة النهاية التي يمكن الوصول إليها ولكنها لا ترد بالتحدي تعطي الخطأ -32015 CallbackEndpointError مع تعيين data.reason إلى challenge_failed؛ أما نقطة النهاية التي لا يمكن الوصول إليها فتحصل على نفس الرمز مع connection_refused أو timeout أو tls_error.1

يتعامل خادمي مع أي إجابة غير 2xx على التحدي على أنها challenge_failed، ويقوم بتعيين أخطاء الشبكة التي لا يمكن تصنيفها إلى connection_refused.

الخطوة 1: طرق الأحداث في خادم MCP

هذا هو ملف server.mjs الكامل الذي أنتج المخرجات أدناه. وهو يقدم حدثاً واحداً، comment.created، والذي يأتي اسمه ووصفه وفلتر document_id من مثال OpenAI.3

// server.mjs — MCP Events webhook mode, written by hand: the SDK releases I checked on 2026-09-30 ship no events/* methods
import http from "node:http";
import https from "node:https";
import crypto from "node:crypto";
import dns from "node:dns";
import net from "node:net";
import { Webhook } from "standardwebhooks";

const DEV_ALLOW_LOOPBACK = process.env.DEV_ALLOW_LOOPBACK === "1"; // local demo only: exempts http://127.0.0.1
const MAX_BODY = 262_144;                                            // 256 KiB, per delivery
const MAX_REQUEST = 1_048_576;                                       // cap for incoming JSON-RPC bodies
const TTL_DEFAULT = 3_600_000, TTL_MIN = 60_000, TTL_MAX = 86_400_000;

const EVENTS = [{
  name: "comment.created",
  description: "A new review comment was added to the specified document.",
  delivery: ["webhook"],
  inputSchema: { type: "object", properties: { document_id: { type: "string" } }, required: ["document_id"] },
  payloadSchema: { type: "object", properties: { document_id: { type: "string" }, comment_id: { type: "string" }, text: { type: "string" } } },
}];

const subs = new Map();      // id -> subscription (use a real database in production)
const verified = new Map();  // "principal url" -> time of its last successful challenge
const VERIFY_TTL = 86_400_000;                                              // re-challenge after 24 hours
const TOKENS = new Map([["token-alice", "alice"], ["token-bob", "bob"]]);   // stand-in for your OAuth layer

class RpcError extends Error { constructor(code, message, data) { super(message); this.code = code; this.data = data; } }
class Blocked extends Error {}   // callback resolves to a non-public address
class TooLarge extends Error {}  // body over MAX_BODY

// Canonical JSON: recursively sorted keys, so {"a":1,"b":2} and {"b":2,"a":1} key the same subscription
const canon = (v) => Array.isArray(v) ? `[${v.map(canon).join(",")}]`
  : v && typeof v === "object" ? `{${Object.keys(v).sort().map(k => `${JSON.stringify(k)}:${canon(v[k])}`).join(",")}}`
  : JSON.stringify(v);
const subId = (principal, url, name, args) =>
  "sub_" + crypto.createHash("sha256").update(canon([principal, url, name, args ?? {}])).digest("hex").slice(0, 24);

function checkSecret(secret) {
  if (typeof secret !== "string" || !secret.startsWith("whsec_")) throw new RpcError(-32602, "delivery.secret must start with whsec_");
  const b64 = secret.slice(6);
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64) || b64.length % 4 !== 0) throw new RpcError(-32602, "delivery.secret must be standard base64");
  const n = Buffer.from(b64, "base64").length;
  if (n < 24 || n > 64) throw new RpcError(-32602, `delivery.secret decodes to ${n} bytes; must be 24-64`);
}

function checkUrl(url) {
  let u; try { u = new URL(url); } catch { throw new RpcError(-32602, "delivery.url is malformed"); }
  if (u.protocol !== "https:" && !(DEV_ALLOW_LOOPBACK && u.hostname === "127.0.0.1")) throw new RpcError(-32602, "callback URL must use https://");
}

// The design sketch's illustrative non-public ranges, plus 0.0.0.0/8 and ::. Production code needs the full IANA registries.
// net.BlockList also matches IPv4-mapped IPv6 such as ::ffff:a00:5, which the URL parser produces from [::ffff:10.0.0.5].
const blocked = new net.BlockList();
for (const [addr, bits] of [["0.0.0.0", 8], ["10.0.0.0", 8], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.168.0.0", 16]])
  blocked.addSubnet(addr, bits, "ipv4");
for (const [addr, bits] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10]]) blocked.addSubnet(addr, bits, "ipv6");
const isPrivate = (ip) => blocked.check(ip, net.isIPv6(ip) ? "ipv6" : "ipv4");

// DNS-rebinding guard: validate inside the socket's own lookup, so the connection goes to the address that was
// checked, while the Host header and TLS (SNI, certificate) still use the URL's hostname. Node may ask for all addresses.
function safeLookup(hostname, opts, cb) {
  dns.lookup(hostname, { ...opts, all: true }, (err, addrs) => {
    if (err) return cb(err);
    const bad = addrs.find(a => isPrivate(a.address));
    if (bad) return cb(new Blocked("callback resolves to a non-public address"));   // no IP in the message: not a DNS oracle
    opts.all ? cb(null, addrs) : cb(null, addrs[0].address, addrs[0].family);
  });
}

// node:http and node:https never follow redirects, so a 3xx comes back as a plain non-2xx status.
// Explicit agents keep NODE_USE_ENV_PROXY from sending the request through a proxy, around safeLookup.
const agents = { "http:": new http.Agent(), "https:": new https.Agent() };
function post(url, headers, body) {
  const u = new URL(url);
  const host = u.hostname.replace(/^\[|\]$/g, "");
  const devLoopback = DEV_ALLOW_LOOPBACK && host === "127.0.0.1";
  if (!devLoopback && net.isIP(host) && isPrivate(host))    // IP-literal URLs never reach lookup, so check them here
    return Promise.reject(new Blocked("callback resolves to a non-public address"));
  return new Promise((resolve, reject) => {
    let gotResponse = false;
    const req = (u.protocol === "https:" ? https : http).request(u, {
      method: "POST", headers, agent: agents[u.protocol], signal: AbortSignal.timeout(10_000),   // 10-second deadline per attempt
      ...(devLoopback ? {} : { lookup: safeLookup }),
    }, res => {
      gotResponse = true;
      let text = ""; res.setEncoding("utf8");
      res.on("data", c => {                          // keep at most 4 KiB of the answer
        text += c;
        if (text.length > 4096) { res.destroy(); resolve({ status: res.statusCode, text: "" }); }
      });
      res.on("end", () => resolve({ status: res.statusCode, text }));
      res.on("error", reject);
    });
    req.on("upgrade", (res, socket) => { gotResponse = true; socket.destroy(); resolve({ status: res.statusCode, text: "" }); });  // a 101 answer
    req.on("error", reject);
    req.on("close", () => { if (!gotResponse) reject(new Error("connection closed without a response")); });
    req.end(body);
  });
}

// Each call signs with a fresh timestamp, so every retry attempt is re-signed.
async function signedPost(sub, webhookId, bodyObj) {
  const body = JSON.stringify(bodyObj);
  const size = Buffer.byteLength(body);
  if (size > MAX_BODY) throw new TooLarge(`payload ${size} bytes > ${MAX_BODY}`);
  const signedAt = new Date();
  return post(sub.url, {
    "Content-Type": "application/json",
    "Content-Length": size,
    "webhook-id": webhookId,
    "webhook-timestamp": String(Math.floor(signedAt.getTime() / 1000)),
    "webhook-signature": new Webhook(sub.secret).sign(webhookId, signedAt, body),
    "X-MCP-Subscription-Id": sub.id,
  }, body);
}

async function verifyCallback(sub) {
  const challenge = crypto.randomBytes(24).toString("base64url");
  let res;
  try { res = await signedPost(sub, `msg_verification_${crypto.randomBytes(8).toString("hex")}`, { type: "verification", challenge }); }
  catch (e) {
    if (e instanceof Blocked) throw new RpcError(-32602, e.message);   // error code is my choice, see the post
    // Unreachable endpoint: timeout, TLS failure, or (as a catch-all for other network errors) connection_refused
    const reason = e.name === "AbortError" ? "timeout" : /CERT|TLS|SSL|UNABLE_TO_VERIFY|EPROTO/.test(e.code ?? "") ? "tls_error" : "connection_refused";
    throw new RpcError(-32015, "CallbackEndpointError", { reason });
  }
  // Reachable endpoint that does not echo the challenge in a 2xx body: challenge_failed
  let echoed = ""; try { echoed = String(JSON.parse(res.text).challenge ?? ""); } catch {}
  const a = Buffer.from(echoed), b = Buffer.from(challenge);
  const ok = res.status >= 200 && res.status < 300 && a.length === b.length && crypto.timingSafeEqual(a, b);
  if (!ok) throw new RpcError(-32015, "CallbackEndpointError", { reason: "challenge_failed" });
}

const methods = {
  "events/list": async () => ({ events: EVENTS }),

  "events/subscribe": async (p, principal) => {
    if (!EVENTS.some(e => e.name === p.name)) throw new RpcError(-32011, "NotFound", { kind: "event" });
    if (p.delivery?.mode !== "webhook") throw new RpcError(-32014, "Unsupported", { feature: "deliveryMode", value: p.delivery?.mode });
    if (typeof p.arguments?.document_id !== "string") throw new RpcError(-32602, "arguments.document_id is required");
    checkSecret(p.delivery.secret);
    checkUrl(p.delivery.url);
    if (p.ttlMs != null && !Number.isFinite(p.ttlMs)) throw new RpcError(-32602, "ttlMs must be a number or null");
    // Placeholder: the sketch says the server MUST check that `principal` may subscribe with these arguments.
    const id = subId(principal, p.delivery.url, p.name, p.arguments);
    const ttl = p.ttlMs === undefined ? TTL_DEFAULT : Math.min(Math.max(p.ttlMs ?? TTL_MAX, TTL_MIN), TTL_MAX);
    const sub = { id, name: p.name, args: p.arguments, url: p.delivery.url, secret: p.delivery.secret, expiresAt: Date.now() + ttl };
    const vkey = `${principal} ${sub.url}`;                 // verification is cached per (principal, url)
    if (!(Date.now() - (verified.get(vkey) ?? 0) < VERIFY_TTL)) { await verifyCallback(sub); verified.set(vkey, Date.now()); }
    subs.set(id, sub);                                       // same key again = refresh: new TTL, secret replaced
    return { id, refreshBefore: new Date(sub.expiresAt).toISOString(), cursor: null, truncated: false };
  },

  // Idempotent with an empty result, per OpenAI's page (the sketch's error table would return -32011 instead)
  "events/unsubscribe": async (p, principal) => {
    subs.delete(subId(principal, p.delivery?.url, p.name, p.arguments));
    return {};
  },
};

// Called by your app when something happens upstream. The same eventId goes to every matching subscription.
export async function emit(name, data, { eventId = `evt_${crypto.randomUUID()}`, retries = 4, backoffMs = 250 } = {}) {
  const timestamp = new Date().toISOString(), results = [];
  for (const sub of subs.values()) {
    if (sub.name !== name || sub.args.document_id !== data.document_id || sub.expiresAt < Date.now()) continue;
    const event = { eventId, name, timestamp, data, cursor: null };
    let status = "error", attempts = 0;
    while (attempts < retries) {
      attempts++;
      try {
        status = (await signedPost(sub, eventId, event)).status;   // webhook-id is the eventId on every attempt
        if ((status >= 200 && status < 300) || status === 410 || status === 413) break;   // done, or non-retryable
      } catch (e) { status = e.message; if (e instanceof TooLarge || e instanceof Blocked) break; }
      if (attempts < retries) await new Promise(r => setTimeout(r, backoffMs * 2 ** (attempts - 1)));
    }
    results.push({ sub: sub.id, status, attempts });
  }
  return results;
}

async function readBody(req, limit) {             // throws past the limit, which drops the connection
  const chunks = []; let size = 0;
  for await (const c of req) { size += c.length; if (size > limit) throw new TooLarge("request body too large"); chunks.push(c); }
  return Buffer.concat(chunks);
}

async function handle(req, res) {
  const raw = await readBody(req, MAX_REQUEST);
  let msg = null;
  const reply = (o) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ jsonrpc: "2.0", id: msg?.id ?? null, ...o })); };
  try {
    msg = JSON.parse(raw.toString("utf8"));
    if (!msg || typeof msg !== "object" || Array.isArray(msg)) { msg = null; throw new RpcError(-32600, "Invalid Request"); }
    // OpenAI: serve these methods on the same authenticated MCP endpoint as your tools
    const principal = TOKENS.get(/^Bearer (\S+)$/.exec(req.headers.authorization ?? "")?.[1]);
    if (!principal) throw new RpcError(-32012, "Forbidden");
    if (!Object.hasOwn(methods, msg.method)) throw new RpcError(-32601, "Method not found");
    reply({ result: await methods[msg.method](msg.params ?? {}, principal) });
  } catch (e) {
    const code = typeof e.code === "number" ? e.code : e instanceof SyntaxError ? -32700 : -32603;
    reply({ error: { code, message: e.message, ...(e.data && { data: e.data }) } });
  }
}

export function startServer(port) {
  return http.createServer((req, res) => handle(req, res).catch(() => res.destroy())).listen(port);   // e.g. client aborted mid-body
}

دالة emit() هي ما يستدعيه تطبيقك عند حدوث شيء في المصدر. تقوم بإرسال eventId واحد إلى كل اشتراك مطابق وغير منتهي الصلاحية. وفي محاولات إعادة الإرسال، تحتفظ بهذا المعرف (ID)، كما تطلب صفحة OpenAI: "استخدم معرف حدث فريد وحافظ عليه عبر محاولات إعادة الإرسال."3

إرسال eventId واحد إلى عدة اشتراكات هو ما يحدث عندما يكون eventId هو المعرف الثابت للمصدر، كما يفضل المخطط.1 يوضح الخطأ الشائع 1 أدناه ماذا يعني ذلك بالنسبة للمستلمين.

يتم توقيع كل محاولة مرة أخرى بطابع زمني جديد. يتطلب المخطط ذلك حتى لا يتم رفض محاولات إعادة الإرسال بواسطة نافذة الحداثة (freshness window) ذات الـ 5 دقائق الخاصة بالمستلم.1 كما أن لكل محاولة مهلة زمنية مدتها 10 ثوانٍ، يتم تعيينها باستخدام AbortSignal.timeout(10_000) كما في مثال OpenAI.3

كيف يتم توقيع webhooks الخاصة بـ MCP Events؟

كل عملية تسليم تحمل ترويسات Standard Webhooks الثلاثة، webhook-id، و webhook-timestamp و webhook-signature، بالإضافة إلى X-MCP-Subscription-Id. بالنسبة للأحداث، يكون webhook-id هو الـ eventId الخاص بالحدث، ويسمح X-MCP-Subscription-Id للمستقبل باختيار السر (secret) الصحيح قبل تحليل محتوى الجسم (body).1

التوقيع هو v1, يليه ترميز base64 لـ HMAC-SHA256 على webhook-id.webhook-timestamp.body، باستخدام مفتاح هو السر الذي تم فك ترميزه بـ base64 بعد البادئة whsec_.1 تقوم دالة sign() في حزمة standardwebhooks ببناء هذا السلسلة النصية بالضبط، وتقوم دالة verify() برفض الطوابع الزمنية التي مضى عليها أكثر من 5 دقائق أو التي تسبق الوقت الحالي بأكثر من 5 دقائق.8

ينص المخطط على أن المستقبل "يجب أن يرفض عمليات التسليم التي يكون فيها webhook-timestamp أقدم من 5 دقائق"، لذا فإن الإعداد الافتراضي للمكتبة يتطابق مع ذلك.1 أثناء تدوير السر (secret rotation)، قد تحمل الترويسة عدة توقيعات مفصولة بمسافات، ويقبل المستقبل عملية التسليم إذا تم التحقق من أي واحد منها.1

تحتوي كل عملية تسليم على حدث واحد. تضع صفحة OpenAI حداً أقصى لجسم الطلب عند 256 KiB (262,144 بايت)، وقد يرفض المستقبلون الجسم الأكبر من ذلك باستخدام رمز 413، ويجب على الخوادم عدم إعادة المحاولة في حالة رموز 410 أو 413.31

الخطوة 2: مستقبل الـ webhook

يلعب هذا الملف دور جانب العميل. عندما يكون ChatGPT هو العميل، تكون نقطة نهاية الاستدعاء (callback endpoint) تابعة لـ ChatGPT وليس لك.3 لا تزال بحاجة إلى واحدة لاختبار خادمك، وستحتاج إلى واحدة حقيقية إذا قمت ببناء مضيف وكيل (agent host) خاص بك.

// receiver.mjs — the client side: verify the received body, answer the challenge, dedupe per subscription
import http from "node:http";
import { Webhook } from "standardwebhooks";

async function readBody(req, limit) {             // throws past the limit, which drops the connection
  const chunks = []; let size = 0;
  for await (const c of req) { size += c.length; if (size > limit) throw new Error("body too large"); chunks.push(c); }
  return Buffer.concat(chunks);
}

export function startReceiver(port, secret, { log = [], failNext = 0 } = {}) {
  const wh = new Webhook(secret);  // one shared secret keeps the demo short; see Pitfall 1 for why production needs one per subscription
  const seen = new Set();
  const state = { log, failNext, verifications: 0 };
  async function handle(req, res) {
    if (Number(req.headers["content-length"]) > 262_144) { res.writeHead(413, { connection: "close" }).end(); return; }
    const raw = await readBody(req, 262_144);      // verify the body as received; never parse and re-stringify first
    let body;
    try { body = wh.verify(raw, req.headers); }
    catch (e) { state.log.push({ rejected: e.message }); res.writeHead(400).end(); return; }
    if (body.type) {                               // a top-level "type" marks a control envelope
      if (body.type === "verification") {
        state.verifications++;
        res.setHeader("content-type", "application/json");
        res.end(JSON.stringify({ challenge: body.challenge })); return;
      }
      state.log.push({ control: body.type }); res.writeHead(204).end(); return;
    }
    if (state.failNext > 0) { state.failNext--; res.writeHead(503).end(); return; }   // simulate an outage
    const id = req.headers["webhook-id"];
    // One eventId can reach several subscriptions at this URL, so dedupe on (subscription, id), not id alone.
    // X-MCP-Subscription-Id is not signed: this key is only safe when each subscription has its own secret.
    const key = process.env.NAIVE_DEDUPE === "1" ? id : `${req.headers["x-mcp-subscription-id"]}:${id}`;
    if (seen.has(key)) { state.log.push({ duplicate: id }); res.writeHead(200).end(); return; }
    seen.add(key);
    state.log.push({ accepted: id, sub: req.headers["x-mcp-subscription-id"], data: body.data });
    res.writeHead(204).end();                      // production: persist or enqueue before acknowledging
  }
  const server = http.createServer((req, res) => handle(req, res).catch(() => res.destroy())).listen(port);
  return { server, state };
}

هناك تفصيلان يحملان معظم القيمة: يتم التحقق من التوقيع مقابل الجسم كما تم استلامه، قبل أي تحليل لـ JSON، وتعتمد عملية إزالة التكرار (de-duplication) على معرف الاشتراك بالإضافة إلى webhook-id. يوضح القسم التالي سبب أهمية كليهما، ولماذا يحتاج الثاني إلى سر لكل اشتراك.

ثلاثة أخطاء شائعة للمستقبل، تم اختبارها

الخطأ 1: إزالة التكرار بناءً على webhook-id وحده تؤدي إلى فقدان الأحداث

تعني عمليات إعادة المحاولة أن نفس عملية التسليم يمكن أن تصل أكثر من مرة، وينص المخطط على أن المستقبل "يجب أن يزيل التكرار بناءً على webhook-id".1 تقدم مواصفات Standard Webhooks نصيحة مماثلة.8

ومع ذلك، عندما يكون eventId هو المعرف الثابت للمصدر (upstream)، فإن حدثاً واحداً من المصدر يصل إلى كل اشتراك مطابق بنفس الـ webhook-id. إذا كان اثنان من هذه الاشتراكات يتم تسليمهما إلى نفس عنوان URL، فإن المستقبل الذي يعتمد على webhook-id وحده سيعتبر الثاني تكراراً.

في الاختبارين 8 و 11، اشترك كل من Alice و Bob في doc_123 على نفس رابط الـ callback وحصلا على معرفات اشتراك (subscription IDs) مختلفة. مع تفعيل NAIVE_DEDUPE=1، والاعتماد على مفاتيح webhook-id فقط، أنتج الاختباران 12 و 13 ما يلي:

12 emit doc_123                      [{"sub":"sub_045c6f33dc958d6167267857","status":204,"attempts":1},{"sub":"sub_3fe4e80b802025bef4790597","status":200,"attempts":1}]
13 receiver kept                     ["sub_045c6f33"]

احتفظ المستلم بعملية تسليم واحدة وأجاب على الأخرى بـ 200 باعتبارها مكررة. رأى الخادم استجابة 2xx ولم يقم بإعادة المحاولة، وبالتالي فقد اشتراك Bob الحدث دون ظهور خطأ في أي من الطرفين.

أدى استخدام مفاتيح تعتمد على X-MCP-Subscription-Id بالإضافة إلى webhook-id إلى حل المشكلة، واحتفظ المستلم بكلا عمليتي التسليم. أما مسألة ما إذا كان اشتراكان يتشاركان في نفس الرابط فتعتمد على العميل، لكن البروتوكول يسمح بذلك: فالرابط هو مجرد جزء واحد من الأجزاء الأربعة لمفتاح الاشتراك.1

هذا الإصلاح له شرط. إن X-MCP-Subscription-Id غير مغطى بالتوقيع، ويعطي العرض التوضيحي (demo) كل اشتراك نفس السر (secret). في اختبار منفصل، تم قبول عملية تسليم موقعة (204)، وتم الرد عليها كمكررة (200) عند إعادة إرسالها دون تغيير، وتم قبولها مرة أخرى (204) عند إعادة إرسالها بـ X-MCP-Subscription-Id مختلف.

لذا، امنح كل اشتراك سره الخاص واختره بناءً على X-MCP-Subscription-Id، وهو الغرض الذي يحدده المخطط لهذا الرأس (header).1 حينها، فإن أي عملية إعادة إرسال برأس مُعدل ستختار السر الخطأ وتفشل في التحقق.

هناك تفصيل صغير: يصل التحدي (challenge) قبل أن يخبر رد الاشتراك العميل بـ id الجديد، لذا لا يمكن للمستلم اختيار سر التحدي بناءً على ذلك الرأس بعد. يجب عليه التحقق من التحدي مقابل السر الذي أرسله للتو، على سبيل المثال عن طريق إعطاء كل اشتراك رابط callback خاص به، وتسجيل الـ id__ عند وصول الرد.

الخطأ الشائع 2: إعادة تسلسل الجسم تكسر التوقيع

يغطي التوقيع البايتات المرسلة بالضبط. المخطط واضح وصريح: "يجب على المستلمين حساب HMAC على الجسم الخام (raw body)، وليس أبداً على كائن JSON تمت إعادة تسلسله".1 أطر العمل التي تقوم بتحليل JSON أولاً وتمنحك كائناً تجعل من المغري استخدام JSON.stringify() مرة أخرى.

يوضح الاختبار 21 سبب فشل ذلك. تقوم دالة json.dumps() في Python بترميز الأحرف غير التابعة لـ ASCII افتراضياً، لذا فإن خادم Python الذي يستخدم الإعدادات الافتراضية يرسل café كـ caf\u00e9.9 بينما تقوم دالة JSON.stringify() في Node بكتابة café، لذا فإن إعادة التسلسل تغير البايتات، ويفشل التحقق برسالة "No matching signature found".

لقد قمت بمحاكاة الجسم بأسلوب Python في Node بدلاً من تشغيل خادم Python. تم تأكيد الترميز نفسه باستخدام Python 3.10.12: حيث طبعت json.dumps({'t':'café'}) النتيجة {"t": "caf\u00e9"}.

الخطأ الشائع 3: عمليات التسليم القديمة والمعاد إرسالها

في الاختبار 20، تم توقيع جسم صالح بطابع زمني يعود إلى 6 دقائق مضت، ورفضه المستلم برسالة "Message timestamp too old". وفي الاختبار 18، تمت إعادة إرسال webhook-id مقبول مسبقاً، وأجاب المستلم بـ 200 دون معالجته مرتين.

أجب عن المكررات بـ 2xx أو بـ 410 Gone. يحدد المخطط الرمز 410 للحالة التي "يتعرف فيها المستلم على الحدث كقديم أو تمت معالجته بالفعل"، ويجب على الخوادم عدم إعادة محاولته. أما الردود الأخرى غير 2xx، باستثناء 413، فيتم إعادة محاولتها.1

الخطوة 3: تشغيل الاختبار الكامل

احفظ الملفات الثلاثة في src/، وقم بتثبيت التبعية الواحدة، ثم قم بتشغيل العرض التوضيحي:

npm init -y && npm pkg set type=module
npm install standardwebhooks@1.1.1
DEV_ALLOW_LOOPBACK=1 node src/demo.mjs

هذا هو ملف demo.mjs كما تم تشغيله:

// demo.mjs — run with: DEV_ALLOW_LOOPBACK=1 node src/demo.mjs
import crypto from "node:crypto";
import { Webhook } from "standardwebhooks";
import { startServer, emit } from "./server.mjs";
import { startReceiver } from "./receiver.mjs";

const secret = "whsec_" + crypto.randomBytes(32).toString("base64");
const srv = startServer(8787);
const rx = startReceiver(9797, secret);
const CALLBACK = "http://127.0.0.1:9797/mcp-events/cb_1";

let n = 0;
const rpc = async (method, params, token = "token-alice") => (await (await fetch("http://127.0.0.1:8787/mcp", {
  method: "POST",
  headers: { "content-type": "application/json", ...(token && { authorization: `Bearer ${token}` }) },
  body: JSON.stringify({ jsonrpc: "2.0", id: ++n, method, params }),
})).json());
const sub = (over = {}) => ({ name: "comment.created", arguments: { document_id: "doc_123" },
  delivery: { mode: "webhook", url: CALLBACK, secret }, cursor: null, ...over });
const at = (url, s = secret) => sub({ delivery: { mode: "webhook", url, secret: s } });
const show = (label, v) => console.log(label.padEnd(36), JSON.stringify(v.error ?? v.result ?? v));

show("1 events/list names", { result: (await rpc("events/list")).result.events.map(e => e.name) });
show("2 no bearer token", await rpc("events/subscribe", sub(), null));
show("3 secret 16 bytes", await rpc("events/subscribe", at(CALLBACK, "whsec_" + crypto.randomBytes(16).toString("base64"))));
show("4 plain-http public URL", await rpc("events/subscribe", at("http://example.com/cb")));
show("5 https URL, IP literals", { result: (await Promise.all(["https://10.0.0.5/cb", "https://[::ffff:10.0.0.5]/cb"]
  .map(u => rpc("events/subscribe", at(u))))).map(r => `${r.error.code} ${r.error.message}`) });
show("6 https URL, localhost via DNS", await rpc("events/subscribe", at("https://localhost:9797/cb")));
show("7 wrong secret at receiver", await rpc("events/subscribe", at(CALLBACK, "whsec_" + crypto.randomBytes(32).toString("base64"))));
const first = await rpc("events/subscribe", sub());
show("8 subscribe ok", first);
const again = await rpc("events/subscribe", sub({ ttlMs: 120000 }));
show("9 same key again (refresh)", { result: { sameId: again.result.id === first.result.id, refreshBefore: again.result.refreshBefore } });
let v = rx.state.verifications;
const other = await rpc("events/subscribe", sub({ arguments: { document_id: "doc_456" } }));
show("10 alice, doc_456, same URL", { result: { newId: other.result.id !== first.result.id, verificationPosts: rx.state.verifications - v } });
v = rx.state.verifications;
const bob = await rpc("events/subscribe", sub(), "token-bob");
show("11 bob, same URL + arguments", { result: { newId: bob.result.id !== first.result.id, verificationPosts: rx.state.verifications - v } });

show("12 emit doc_123", { result: await emit("comment.created", { document_id: "doc_123", comment_id: "c_1", text: "Add rollout dates?" }) });
show("13 receiver kept", { result: rx.state.log.filter(l => l.accepted).map(l => l.sub.slice(0, 12)) });
show("14 emit doc_999 (no match)", { result: await emit("comment.created", { document_id: "doc_999", comment_id: "c_2", text: "x" }) });
rx.state.failNext = 2;
show("15 receiver 503 x2 then ok", { result: await emit("comment.created", { document_id: "doc_123", comment_id: "c_3", text: "retry me" }) });
show("16 oversized event", { result: await emit("comment.created", { document_id: "doc_123", comment_id: "c_4", text: "x".repeat(300_000) }) });

// Receiver-side checks, sent straight at the callback
const post = async (headers, body) => (await fetch(CALLBACK, { method: "POST", headers, body })).status;
const good = JSON.stringify({ eventId: "evt_x", name: "comment.created", timestamp: new Date().toISOString(), data: { document_id: "doc_123", text: "café" }, cursor: null });
const signer = new Webhook(secret);
const hdr = (id, when, body) => ({ "content-type": "application/json", "webhook-id": id, "webhook-timestamp": String(Math.floor(when / 1000)),
  "webhook-signature": signer.sign(id, new Date(when), body), "X-MCP-Subscription-Id": first.result.id });
show("17 valid delivery", { result: await post(hdr("evt_x", Date.now(), good), good) });
show("18 same webhook-id replayed", { result: await post(hdr("evt_x", Date.now(), good), good) });
show("19 body tampered after signing", { result: await post(hdr("evt_y", Date.now(), good), good.replace("café", "cafe")) });
show("20 signed 6 minutes ago", { result: await post(hdr("evt_z", Date.now() - 360_000, good), good) });
// Python's json.dumps() escapes non-ASCII by default; a receiver that parses and re-stringifies changes the bytes
const pyStyle = good.replace(/[\u0080-\uffff]/g, c => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
const reSer = JSON.stringify(JSON.parse(pyStyle));
show("21 re-serialized bytes equal?", { result: { sameBytes: reSer === pyStyle, verifyAfterReserialize: (() => { try { signer.verify(reSer, hdr("evt_p", Date.now(), pyStyle)); return "pass"; } catch (e) { return e.message; } })() } });

const unsub = { name: "comment.created", arguments: { document_id: "doc_123" }, delivery: { mode: "webhook", url: CALLBACK } };
show("22 unsubscribe", await rpc("events/unsubscribe", unsub));
show("23 unsubscribe again", await rpc("events/unsubscribe", unsub));
show("24 emit after alice unsubscribes", { result: (await emit("comment.created", { document_id: "doc_123", comment_id: "c_5", text: "?" })).map(r => r.sub.slice(0, 12)) });
srv.close(); rx.server.close();

وهذه هي المخرجات، معروضة كصورة، مع الأمر في السطر الأول:

Output of demo.mjs on Node v22.23.2 with standardwebhooks 1.1.1, rendered from the captured stdout: 24 checks covering auth errors, secret and URL validation including an IPv4-mapped IPv6 literal and a localhost URL rejected during DNS lookup, a CallbackEndpointError with challenge_failed, idempotent refresh, a cached verification, filtered delivery, a retry that succeeds on the third attempt, a refused 300,194-byte event, tampered, stale and re-serialized deliveries rejected, and idempotent unsubscribe

ما توضحه كل مجموعة من الاختبارات:

الاختباراتما تم اختبارهالنتيجة
2الاشتراك بدون ترويسة Authorization-32012 Forbidden
3سر (Secret) يتم فك تشفيره إلى 16 بايت-32602، أقل من الحد الأدنى وهو 24 بايت
4رابط URL عام يعمل ببروتوكول Plain-HTTP-32602
5, 6روابط HTTPS على عناوين IP صريحة 10.0.0.5 و [::ffff:10.0.0.5]؛ ورابط HTTPS على localhost-32602 للثلاثة؛ localhost ليس عنوان IP صريح، لذا تم رفضه داخل lookup
7المستقبل يمتلك سراً مختلفاًيرفض المستقبل توقيع التحدي، لذا لا يتم إرجاع شيء: -32015 مع challenge_failed
8, 9الاشتراك، ثم التكرار مع ttlMs: 120000نفس الـ id؛ ينتقل refreshBefore إلى دقيقتين
10أليس تشترك في doc_456 على نفس الرابطid جديد، 0 من طلبات challenge POSTs
11بوب يشترك باستخدام رابط أليس ومعطياتهاid جديد، 1 من طلبات challenge POST
14حدث لـ doc_999لا يوجد اشتراك مطابق، لا يوجد تسليم
15المستقبل يعيد 503 مرتينينجح تسليم أليس في المحاولة الثالثة
16حدث بحجم 300,194 بايتتم الرفض قبل الإرسال، لا توجد إعادة محاولة
19تغيير محتوى الجسم بعد التوقيعالمستقبل يعيد 400
22–24إلغاء الاشتراك مرتين، ثم الإصدار{} في المرتين؛ فقط اشتراك بوب لا يزال يستقبل

تتكرر معرفات الاشتراك (subscription IDs) عبر عمليات التشغيل لأن كل منها عبارة عن SHA-256 مقتطع للمفتاح، وليس قيمة عشوائية. يقدم المخطط ذلك كمثال على id حتمي.1

يقوم العرض التجريبي بإعادة المحاولة مع تراجع (backoff) قدره 250 مللي ثانية يتضاعف، لذا ينتهي الاختبار في ثوانٍ. ينص المخطط على تحديد عدد المحاولات والوقت المنقضي، "على سبيل المثال، 3-5 محاولات موزعة على ما لا يزيد عن 10-15 دقيقة"، لذا قم بزيادة فترة التراجع في بيئة الإنتاج.1

هل تدعم MCP SDKs أحداث MCP Events بعد؟

ليس في الحزم التي قمت بقياسها. في 30 سبتمبر 2026، قمت بتثبيت سبع حزم من npm و PyPI وبحثت في كل ملف مثبت عن ست سلاسل نصية صريحة: events/list، و events/poll، و events/stream، و events/subscribe، و events/unsubscribe و notifications/events.5

لم تجد أي حزمة أي تطابق للستة جميعاً، وكذلك كان الحال عند البحث في أشجار التبعيات المثبتة بالكامل، والتي تشمل mcp-types 2.2.0 و fastmcp-slim 4.0.10. وكعنصر تحكم إيجابي، فإن نفس البحث عن tools/list وجد تطابقات في ملفات كل حزمة:

الحزمةمستودع المصدرالإصدار (تاريخ النشر)سلاسل events/ملفات tools/list
@modelcontextprotocol/sdk (npm)modelcontextprotocol/typescript-sdk1.31.0 (28 سبتمبر 2026)022
@modelcontextprotocol/server (npm)modelcontextprotocol/typescript-sdk2.2.0 (28 سبتمبر 2026)010
@modelcontextprotocol/client (npm)modelcontextprotocol/typescript-sdk2.2.0 (28 سبتمبر 2026)012
@modelcontextprotocol/core (npm)modelcontextprotocol/typescript-sdk2.2.0 (28 سبتمبر 2026)06
mcp (PyPI)modelcontextprotocol/python-sdk2.2.0 (7 سبتمبر 2026)08
fastmcp (PyPI; الكود يشحن في fastmcp-slim)PrefectHQ/fastmcp4.0.10 (25 سبتمبر 2026)011
fastmcp (npm)punkpeye/fastmcp4.22.1 (30 سبتمبر 2026)013

تدرج ميثاق مجموعة العمل "التنفيذ المرجعي في SDKs من الفئة الأولى" كبند عمل نشط، وتشمل معايير نجاحه "تنفيذات مرجعية في اثنين على الأقل من SDKs من الفئة الأولى".6 وحتى يتم شحن هذه التنفيذات، تظل طرق الـ webhook مسؤوليتك في الكتابة.

يغطي هذا الإصدارات المنشورة في تاريخ واحد. لم يتم فحص الفروع غير المنشورة أو طلبات السحب (pull requests).

قائمة مراجعة الإنتاج لـ MCP Events

  • تخزين الاشتراكات بما يتوافق مع فترات الصلاحية (TTLs) التي تمنحها. يسمح المخطط الأولي للخادم الذي يمنح فترات صلاحية قصيرة بالاحتفاظ بالاشتراكات في الذاكرة، ولكن صفحة OpenAI تطلب من تكاملات ChatGPT "الاحتفاظ بحالة الاشتراك طوال فترة الصلاحية التي تمنحها، بما في ذلك بعد إعادة تشغيل الخادم".31 الـ Map الخاصة بي هي للعرض التوضيحي فقط.
  • التحقق من الأذونات عند الاشتراك وأثناء التسليم. يتطلب المخطط الأولي التحقق من الأذونات وقت الاشتراك، وتطلب منك OpenAI "إعادة التحقق من وصول المستخدم خلال فترة صلاحية الاشتراك وإيقاف التسليم إذا تم إلغاء الوصول".31
  • التحقق من عناوين الاستدعاء (callback addresses) عند الاتصال. تحقق من العنوان الذي تم حله داخل الاتصال، كما تفعل post()، ولا تتبع عمليات إعادة التوجيه أبدًا.31
  • تحديد معدل طلبات POST للتحدي لكل مضيف وجهة. يشير المخطط الأولي إلى أن طلبات POST للتحقق يجب أن تكون محدودة المعدل لكل مضيف وجهة؛ العرض التوضيحي الخاص بي لا يفعل ذلك.1
  • الحفاظ على استقرار eventId عبر محاولات إعادة الإرسال، وتوقيع كل محاولة مرة أخرى، وعدم إعادة المحاولة أبدًا في حال حدوث خطأ 410 أو 413.31
  • التسليم لكل اشتراك بشكل مستقل. تقوم دالة emit() الخاصة بي بإنهاء تسليم اشتراك واحد، بما في ذلك محاولات إعادة الإرسال، قبل البدء في الاشتراك التالي، لذا فإن نقطة نهاية واحدة بطيئة تؤخر البقية.
  • تحديد أحجام جسم الطلب (body) في كلا الاتجاهين. يقرأ الخادم الخاص بي 1 ميجابايت بحد أقصى من طلب JSON-RPC ويتوقف عن قراءة إجابة الـ callback بعد 4,096 حرفًا، ويرفض المستقبل أي تسليمات تتجاوز 256 كيلوبايت.
  • احترام ttlMs ضمن الحدود. يذكر المخطط أن "المنح من بضع دقائق إلى حوالي يوم واحد تغطي معظم عمليات النشر".1 يقوم الخادم الخاص بي بتقييد الاقتراحات الرقمية لتكون بين 60 ثانية و24 ساعة، ويمنح 24 ساعة عندما يطلب العميل null، ويرفض أي ttlMs غير رقمي باستخدام -32602.
  • التخطيط لتدوير الأسرار (secret rotation). يؤدي التحديث بسر جديد إلى استبدال السر القديم، ويذكر المخطط أنه ينبغي (SHOULD) للخوادم التوقيع باستخدام كل من الأسرار القديمة والجديدة لفترة سماح قصيرة.1 الخادم الخاص بي لا يقوم بالتوقيع المزدوج: في اختبار منفصل، حصل تسليم بعد التدوير على 400 من مستقبل لا يزال يحتفظ بالسر القديم.
  • تأكيد استلام ما تم تخزينه فقط. يذكر المخطط أن نقطة النهاية "لا ينبغي (SHOULD NOT) أن تعيد 2xx حتى يتم حفظ الحدث بشكل دائم أو توجيهه".1 يقوم المستقبل الخاص بي بالتأكيد بعد إدخال السجل في الذاكرة.
  • في المستقبل، تحقق من جسم الطلب قبل تحليله، واستخدم سرًا منفصلاً لكل اشتراك، وقم بإزالة التكرار لكل اشتراك. انظر الأخطاء الشائعة 1 و2.
  • تطوير مخططات الأحداث بشكل تراكمي. يذكر المخطط أن "التغيير الجذري ينبغي (SHOULD) بدلاً من ذلك أن يُنشر تحت اسم حدث جديد، ويُقدم جنباً إلى جنب مع الحدث القديم لفترة انتقال".1
  • تشير ميثاق مجموعة العمل إلى أن SEP-1686، مقترح المهام (Tasks)، "يحدد إشعارات إكمال المهام بنمط webhook كاعتبار مستقبلي؛ وتملك مجموعة العمل هذه الآلية".6 بالنسبة لاستدعاءات الأدوات التي تستغرق وقتاً طويلاً، انظر درس تعليمي حول ملحق MCP Tasks.

    لمعرفة ما تغير بشكل عام في مواصفات 2026-07-28، انظر MCP يصبح stateless. هناك تكامل آخر لـ Standard Webhooks، مع خطأ توقيع مختلف، مغطى في Claude inference hooks.

    حدود هذه الاختبارات

    كل شيء تم تشغيله على جهاز واحد عبر HTTP على loopback. تم التحقق من سلوك Node الذي تعتمد عليه post() بالنسبة لـ TLS في سكربت منفصل مقابل خادم محلي، لكن post() نفسها لم تقم أبداً بإنشاء اتصال TLS. لم يتم اختبار فشل الشهادات، وهجمات DNS rebinding، ومهلات الشبكة (network timeouts) بشكل كامل من البداية للنهاية.

    لم أتصل بـ ChatGPT، لذا فإن كيفية تحديث الاشتراكات والإجابة على التحديات موصوفة فقط كما تذكر صفحة OpenAI. لم يتم بناء تسليم الـ Poll والـ push، لأن تكامل ChatGPT لا يدعمهما.3

    يقوم الخادم بمصادقة المتصلين باستخدام خريطة توكنات (token map) ثابتة، ولا يتحقق من الأذونات لكل مستند، ولا يضع حداً لمعدل التحديات (rate-limit)، ولا يقوم بالتوقيع المزدوج أثناء تدوير الأسرار. يستخدم المستقبل التجريبي سراً واحداً لكل اشتراك.

    مخطط التصميم هو مسودة وقد يتغير قبل أن تعتمد مجموعة العمل SEP مقبولاً.167

    الخلاصة

    نظام أحداث MCP في وضع Webhook بسيط: ثلاث طرق JSON-RPC، وتحدي مُوقع يتم تخزينه مؤقتاً لكل مسؤول (principal) ورابط URL بمجرد نجاحه، وطلب POST مُوقع واحد لكل حدث. تذكر OpenAI أن دعم ChatGPT متاح لجميع الخطط، بينما لا توفر أي من حزم SDK التي فحصتها هذه الطرق حتى الآن.25

    قم بكتابة جانب الخادم (server side) بنفسك في الوقت الحالي. تحقق من الأسرار (secrets)، وافحص عناوين الاستدعاء (callback addresses) عند الاتصال، وحافظ على استقرار معرفات الأحداث (event IDs). ثم اختبر المستقبل الخاص بك للتأكد من إزالة التكرار في الروابط المشتركة (shared-URL de-duplication)، والأسرار الخاصة بكل اشتراك، والتحقق قبل التحليل (parsing)، وذلك قبل أن تعتمد الأحداث الحقيقية على ذلك.

    Footnotes

    1. MCP Events design sketch (docs/design-sketch-proposal.md) — modelcontextprotocol/experimental-ext-triggers-events on GitHub, status "Draft proposal", author Peter Alexander, dated 2026-02-19; raw file read on 2026-09-30. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 ↩26 ↩27 ↩28 ↩29 ↩30 ↩31 ↩32 ↩33 ↩34 ↩35 ↩36 ↩37 ↩38 ↩39 ↩40 ↩41 ↩42 ↩43 ↩44 ↩45 ↩46 ↩47 ↩48 ↩49 ↩50 ↩51 ↩52

    2. DevDay 2026 Recap — OpenAI, published September 29, 2026, fetched 2026-09-30 ("MCP events for plugin automations"; "Available to all plans."). ↩ ↩2 ↩3 ↩4

    3. MCP Events — Plugins, OpenAI Developers, fetched 2026-09-30 (requirements; unsupported features; the three methods on the authenticated MCP endpoint; who calls events/subscribe and answers the challenge; subscribe, refresh and unsubscribe rules; connection-time address validation; delivery headers, retries, the 256 KiB limit and 410/413 handling; the comment.created example). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25

    4. OpenAI teases 20+ announcements at DevDay, watch live — 9to5Mac, published September 29, 2026, fetched 2026-09-30 ("It kicks off at 10 am PT/1 pm ET"). ↩ ↩2

    5. Author's measurement, 2026-09-30: seven packages installed from npm and PyPI; versions, publish dates and source repositories from the npm registry (npm view) and PyPI's JSON API; each installed package directory searched with grep -rlF for the six strings and the tools/list control, with compiled __pycache__ copies excluded. The counts are numbers of files containing the string. For fastmcp on PyPI, the importable fastmcp package is installed by its dependency fastmcp-slim 4.0.10 (published the same day), so that directory is the one counted. The full installed dependency trees (the venv's site-packages and both node_modules folders) were also searched for the six strings. ↩ ↩2 ↩3 ↩4

    6. Triggers and Events Charter — Model Context Protocol, fetched 2026-09-30 (changelog entry "2026-03-24 | Initial charter"; leads Clare Liguori, Amazon Web Services, and Peter Alexander, Anthropic; active work items, success criteria and related groups). ↩ ↩2 ↩3 ↩4

    7. modelcontextprotocol/experimental-ext-triggers-events — GitHub, raw README read on 2026-09-30. ↩ ↩2 ↩3

    8. standardwebhooks 1.1.1 on npm (published 2026-08-28; repository standard-webhooks/standard-webhooks), dist/index.js read after install on 2026-09-30: sign() returns v1, plus base64 HMAC-SHA256 over the message ID, timestamp and payload joined by dots; verify() accepts any matching v1 signature in a space-separated list; WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60. Also the Standard Webhooks specification, read 2026-09-30 (secrets of 24 to 64 bytes with a whsec_ prefix, space-delimited signatures for rotation, webhook-id as an idempotency key). ↩ ↩2 ↩3 ↩4

    9. json — JSON encoder and decoder, Python documentation (ensure_ascii defaults to true), confirmed locally with Python 3.10.12 on 2026-09-30. ↩

    الأسئلة الشائعة

    أحداث MCP (MCP Events) هي مسودة لتوسيع بروتوكول سياق النموذج (Model Context Protocol) تتيح للخادم إخطار العميل عند حدوث شيء ما في المصدر (upstream). تقوم الخوادم بإدراج أنواع الأحداث باستخدام events/list، ويضيف وضع الـ webhook كلاً من events/subscribe و events/unsubscribe، مع توقيع عمليات التسليم وفقاً لمعايير Standard Webhooks.1