دليل أحداث MCP: بناء خادم Webhook (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

ما ستتعلمه
- ما هي 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();
وهذه هي المخرجات، معروضة كصورة، مع الأمر في السطر الأول:

ما توضحه كل مجموعة من الاختبارات:
| الاختبارات | ما تم اختباره | النتيجة |
|---|---|---|
| 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-sdk | 1.31.0 (28 سبتمبر 2026) | 0 | 22 |
@modelcontextprotocol/server (npm) | modelcontextprotocol/typescript-sdk | 2.2.0 (28 سبتمبر 2026) | 0 | 10 |
@modelcontextprotocol/client (npm) | modelcontextprotocol/typescript-sdk | 2.2.0 (28 سبتمبر 2026) | 0 | 12 |
@modelcontextprotocol/core (npm) | modelcontextprotocol/typescript-sdk | 2.2.0 (28 سبتمبر 2026) | 0 | 6 |
mcp (PyPI) | modelcontextprotocol/python-sdk | 2.2.0 (7 سبتمبر 2026) | 0 | 8 |
fastmcp (PyPI; الكود يشحن في fastmcp-slim) | PrefectHQ/fastmcp | 4.0.10 (25 سبتمبر 2026) | 0 | 11 |
fastmcp (npm) | punkpeye/fastmcp | 4.22.1 (30 سبتمبر 2026) | 0 | 13 |
تدرج ميثاق مجموعة العمل "التنفيذ المرجعي في 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()الخاصة بي بإنهاء تسليم اشتراك واحد، بما في ذلك محاولات إعادة الإرسال، قبل البدء في الاشتراك التالي، لذا فإن نقطة نهاية واحدة بطيئة تؤخر البقية.
ttlMs ضمن الحدود. يذكر المخطط أن "المنح من بضع دقائق إلى حوالي يوم واحد تغطي معظم عمليات النشر".1 يقوم الخادم الخاص بي بتقييد الاقتراحات الرقمية لتكون بين 60 ثانية و24 ساعة، ويمنح 24 ساعة عندما يطلب العميل null، ويرفض أي ttlMs غير رقمي باستخدام -32602.400 من مستقبل لا يزال يحتفظ بالسر القديم.2xx حتى يتم حفظ الحدث بشكل دائم أو توجيهه".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
-
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
-
DevDay 2026 Recap — OpenAI, published September 29, 2026, fetched 2026-09-30 ("MCP events for plugin automations"; "Available to all plans."). ↩ ↩2 ↩3 ↩4
-
MCP Events — Plugins, OpenAI Developers, fetched 2026-09-30 (requirements; unsupported features; the three methods on the authenticated MCP endpoint; who calls
events/subscribeand answers the challenge; subscribe, refresh and unsubscribe rules; connection-time address validation; delivery headers, retries, the 256 KiB limit and410/413handling; thecomment.createdexample). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 ↩20 ↩21 ↩22 ↩23 ↩24 ↩25 -
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
-
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 withgrep -rlFfor the six strings and thetools/listcontrol, with compiled__pycache__copies excluded. The counts are numbers of files containing the string. Forfastmcpon PyPI, the importablefastmcppackage is installed by its dependencyfastmcp-slim4.0.10 (published the same day), so that directory is the one counted. The full installed dependency trees (the venv's site-packages and bothnode_modulesfolders) were also searched for the six strings. ↩ ↩2 ↩3 ↩4 -
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
-
modelcontextprotocol/experimental-ext-triggers-events — GitHub, raw README read on 2026-09-30. ↩ ↩2 ↩3
-
standardwebhooks1.1.1 on npm (published 2026-08-28; repository standard-webhooks/standard-webhooks),dist/index.jsread after install on 2026-09-30:sign()returnsv1,plus base64 HMAC-SHA256 over the message ID, timestamp and payload joined by dots;verify()accepts any matchingv1signature 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 awhsec_prefix, space-delimited signatures for rotation,webhook-idas an idempotency key). ↩ ↩2 ↩3 ↩4 -
json — JSON encoder and decoder, Python documentation (
ensure_asciidefaults to true), confirmed locally with Python 3.10.12 on 2026-09-30. ↩

