DEV Community

Cover image for Eventos MCP explicados: construye y prueba un servidor MCP con webhooks para ChatGPT
Roobia
Roobia

Posted on Originally published at apidog.com

Eventos MCP explicados: construye y prueba un servidor MCP con webhooks para ChatGPT

Los Eventos MCP permiten que tu servidor MCP envíe actualizaciones a ChatGPT en el momento en que ocurren, sin obligar a ningún agente a sondear cambios. Desde el OpenAI DevDay del 29 de septiembre de 2026, ChatGPT es compatible con la especificación propuesta de Eventos MCP en la versión de protocolo 2026-07-28 (MCP 2.0), en todos los planes. Tu servidor debe añadir events/list, events/subscribe y events/unsubscribe; anunciar events en server/discover; superar un desafío de verificación de devolución de llamada; y firmar cada entrega con HMAC de Webhooks Estándar. ChatGPT solo acepta entregas mediante webhook.

Prueba Apidog hoy

Esta guía muestra cómo implementar, proteger y probar el ciclo completo. Si eres nuevo en el protocolo, empieza con qué es MCP. Enviarás llamadas JSON-RPC y simularás devoluciones de llamada con Apidog.

Eventos MCP de un vistazo

Elemento Lo que espera ChatGPT
Protocolo MCP 2.0, versión 2026-07-28
Capacidad "events": {} en las capacidades de server/discover
Métodos events/list, events/subscribe, events/unsubscribe, en el mismo endpoint autenticado que tus herramientas
Entrega Solo webhook: sin sondeo, transmisión ni notificaciones gap o terminated
Firma HMAC-SHA256 de Webhooks Estándar
Encabezados webhook-id, webhook-timestamp, webhook-signature, X-MCP-Subscription-Id
Secreto Prefijo whsec_ y base64 que decodifica a 24-64 bytes, suministrado por ChatGPT
Límite de carga útil 256 KiB (262.144 bytes), un evento por solicitud
ID de suscripción Determinista: principal, URL de devolución de llamada, nombre del evento y argumentos
Devoluciones de llamada HTTPS, verificación por desafío, sin direcciones privadas ni redirecciones

Fuentes: la guía de Eventos MCP de OpenAI y el borrador de esquema de diseño de Eventos MCP.

Por qué usar eventos en lugar de sondeo

Sin eventos, un agente que vigila comentarios de revisión debe llamar periódicamente a una herramienta y comparar resultados. Eso consume solicitudes cuando no hay cambios y añade latencia cuando sí los hay.

Con Eventos MCP, el flujo se invierte:

  1. Tu sistema detecta un cambio.
  2. Tu servidor MCP evalúa las suscripciones activas.
  3. El servidor entrega el evento al webhook de ChatGPT.
  4. ChatGPT puede continuar el flujo del agente con datos actualizados.

Las compensaciones son las habituales de webhooks vs. sondeo, aplicadas dentro de MCP.

El resumen del DevDay de OpenAI usa un tablero de proyectos: el usuario pide a ChatGPT que vigile nuevas tareas y, cuando llega una, ChatGPT lee los documentos vinculados y redacta un plan. La documentación también incluye estos casos:

  • Convertir informes de errores de un canal en borradores de solicitudes de extracción mediante message.created, filtrado por channel_id.
  • Aplicar comentarios de revisión a un documento mediante comment.created, filtrado por document_id.

La especificación procede del Grupo de Trabajo de Triggers y Eventos de MCP (repositorio) y sigue siendo experimental. Fija tu implementación a 2026-07-28. Para el resto del lanzamiento, consulta el centro DevDay 2026.

1. Anuncia la capacidad y define tus eventos

Primero, anuncia events en la respuesta de server/discover:

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

Después implementa events/list. Para cada evento, devuelve:

  • name: identificador estable, por ejemplo, comment.created.
  • description: explica claramente cuándo se emite.
  • delivery: ["webhook"]: ChatGPT solo admite este modo.
  • inputSchema: JSON Schema de los argumentos de suscripción, como document_id.
  • payloadSchema: JSON Schema de data en cada entrega.

Recomendaciones de implementación:

  • Usa nombres de eventos estables.
  • Aplica los filtros en tu servidor, no en el cliente.
  • Expón únicamente eventos que la cuenta autenticada pueda consultar.
  • Define esquemas estrictos para rechazar argumentos inválidos antes de crear una suscripción.

2. Implementa events/subscribe

Cuando un usuario pide a ChatGPT que supervise algo, ChatGPT llama a events/subscribe:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "events/subscribe",
  "params": {
    "name": "comment.created",
    "arguments": {
      "document_id": "doc_123"
    },
    "delivery": {
      "mode": "webhook",
      "url": "https://receiver.example.com/mcp-events/callback_123",
      "secret": "whsec_<base64-encoded-signing-key>"
    },
    "cursor": null
  }
}
Enter fullscreen mode Exit fullscreen mode

Antes de aceptar la suscripción, valida este checklist:

  1. Autoriza al principal autenticado para acceder al evento y a los recursos incluidos en arguments.
  2. Verifica que name exista en tu resultado de events/list.
  3. Valida arguments contra el inputSchema.
  4. Exige delivery.mode: "webhook".
  5. Verifica que la URL use HTTPS.
  6. Rechaza URLs privadas, locales o no públicas.
  7. Valida que el secreto comience por whsec_ y que el valor base64 decodifique a entre 24 y 64 bytes.
  8. Ejecuta el desafío de verificación antes de enviar datos reales.
  9. Guarda propietario, filtros, URL, secreto y expiración.

Si todo es correcto, responde:

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

Usa IDs deterministas

Genera el ID a partir de una identidad estable:

principal autenticado + callback URL + nombre del evento + argumentos canónicos
Enter fullscreen mode Exit fullscreen mode

El esquema de diseño sugiere usar un SHA-256 truncado. La clave es que el mismo usuario, evento, URL y filtros produzcan siempre el mismo ID.

Trata la suscripción como un upsert idempotente

Si llega otra llamada con la misma identidad:

  • Actualiza el registro existente.
  • No crees una suscripción duplicada.
  • Compara los argumentos como JSON canónico para evitar que el orden de las claves cree IDs distintos.

Por ejemplo, estos objetos deben representar la misma suscripción:

{ "document_id": "doc_123", "include_resolved": false }
Enter fullscreen mode Exit fullscreen mode
{ "include_resolved": false, "document_id": "doc_123" }
Enter fullscreen mode Exit fullscreen mode

Renueva antes de refreshBefore

ChatGPT vuelve a llamar a events/subscribe antes de refreshBefore, usando la misma identidad y el último cursor guardado.

Tu servidor debe:

  • Devolver una nueva expiración.
  • Reemplazar el secreto si llega uno nuevo.
  • Firmar con ambas claves durante un periodo corto si rotas el secreto.
  • Devolver cursor: null para eventos que no puedas reproducir.

3. Verifica la devolución de llamada antes de enviar datos

No entregues eventos de aplicación inmediatamente después de crear una suscripción. Primero envía un desafío firmado:

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

Para esta solicitud:

  • Genera un webhook-id único, por ejemplo msg_verification_123.
  • Firma el cuerpo con los mismos encabezados que usarás para eventos reales.
  • Usa un desafío aleatorio, de un solo uso y de vida corta.
  • Espera una respuesta 2xx con el mismo desafío.

La respuesta esperada es:

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

Compara el desafío en tiempo constante y activa la entrega solo si coincide.

Si falla, devuelve el error JSON-RPC -32015 (CallbackEndpointError) con un motivo en data.reason, por ejemplo:

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

También puedes usar timeout cuando el receptor no responda a tiempo.

Guarda en caché una verificación correcta por principal y URL durante un periodo limitado. Así, una renovación de suscripción no necesita repetir el desafío inmediatamente.

Protege las solicitudes salientes contra SSRF

El desafío existe porque el suscriptor proporciona el secreto. Sin esta comprobación, alguien podría configurar la URL de una víctima y provocar entregas no deseadas.

Aplica estas reglas a cada solicitud saliente:

  • Permite solo HTTPS.
  • Resuelve y valida la dirección en el momento de conexión.
  • Bloquea direcciones privadas, locales y no públicas.
  • No sigas redirecciones.
  • Vuelve a validar la IP si la resolución DNS cambia.

4. Entrega y firma los eventos

Cuando ocurre un evento que coincide con una suscripción, realiza un POST a la URL de devolución de llamada:

{
  "eventId": "evt_456",
  "name": "comment.created",
  "timestamp": "2026-10-01T12:05:00Z",
  "data": {
    "document_id": "doc_123",
    "comment_id": "comment_456",
    "text": "Can we add the rollout dates to this section?",
    "url": "https://docs.example.com/doc_123#comment_456"
  },
  "cursor": null
}
Enter fullscreen mode Exit fullscreen mode

Incluye estos encabezados:

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

Reglas importantes:

  • webhook-id debe ser igual a eventId.
  • webhook-timestamp debe contener segundos Unix en el momento de firmar.
  • Serializa el cuerpo una sola vez.
  • Firma y envía exactamente los mismos bytes.
  • Envía un solo evento por solicitud.
  • Mantén la carga útil por debajo de 256 KiB.

Para recursos grandes, envía un resumen y ofrece una herramienta MCP para leer el contenido completo.

Además:

  • Trata todo texto creado por usuarios como datos, no como instrucciones para el modelo.
  • Reintenta fallos transitorios con retroceso exponencial limitado.
  • Conserva el mismo ID de evento en cada reintento.
  • Firma de nuevo cada intento porque cambia la marca de tiempo.
  • No reintentes respuestas 410 ni 413.
  • Asume que los eventos pueden llegar desordenados.
  • Diseña las herramientas de escritura para que sean idempotentes.

Consulta la guía de diseño de webhooks fiables para revisar estrategias de reintento.

5. Verifica firmas de Webhooks Estándar

Según la especificación de Webhooks Estándar, debes firmar este contenido:

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

La clave HMAC-SHA256 es el secreto base64 decodificado después de eliminar el prefijo whsec_.

El encabezado webhook-signature puede contener una o más firmas con el formato:

v1,<base64>
Enter fullscreen mode Exit fullscreen mode

Las firmas se separan mediante espacios.

El borrador MCP indica que el receptor debe:

  • Rechazar marcas de tiempo de más de 5 minutos.
  • Eliminar duplicados usando webhook-id.

Puedes usar este receptor estricto para pruebas locales con Node 18+:

// receiver.mjs: receptor estricto de Webhooks Estándar para pruebas locales (Node 18+)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.WEBHOOK_SECRET; // whsec_...
const MAX_BYTES = 256 * 1024;
const TOLERANCE_S = 5 * 60;
const seen = new Set();

export function verify(raw, h, secret, now = Math.floor(Date.now() / 1000)) {
  const id = h["webhook-id"];
  const ts = h["webhook-timestamp"];
  const sigs = h["webhook-signature"];

  if (!id || !ts || !sigs) return false;

  const t = Number(ts);
  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_S) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${ts}.`)
    .update(raw)
    .digest();

  return sigs.split(" ").some((s) => {
    const [version, b64] = s.split(",");
    const got = Buffer.from(b64 ?? "", "base64");

    return (
      version === "v1" &&
      got.length === expected.length &&
      timingSafeEqual(got, expected)
    );
  });
}

if (SECRET) {
  createServer((req, res) => {
    const chunks = [];
    let size = 0;

    req.on("data", (chunk) => {
      size += chunk.length;
      if (size <= MAX_BYTES) chunks.push(chunk);
    });

    req.on("end", () => {
      if (size > MAX_BYTES) return res.writeHead(413).end();

      const raw = Buffer.concat(chunks);

      if (!verify(raw, req.headers, SECRET)) {
        return res.writeHead(401).end();
      }

      let body;
      try {
        body = JSON.parse(raw);
      } catch {
        return res.writeHead(400).end();
      }

      if (body.type === "verification") {
        res.writeHead(200, { "Content-Type": "application/json" });
        return res.end(JSON.stringify({ challenge: body.challenge }));
      }

      const id = req.headers["webhook-id"];

      if (!seen.has(id)) {
        seen.add(id);
        console.log(
          req.headers["x-mcp-subscription-id"],
          body.name,
          id
        );
      }

      res.writeHead(200).end();
    });
  }).listen(8787);
}
Enter fullscreen mode Exit fullscreen mode

Ejecuta el receptor:

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

Este receptor:

  • Devuelve 413 para cargas superiores a 256 KiB.
  • Devuelve 401 para firmas inválidas o caducadas.
  • Reproduce correctamente los desafíos.
  • Procesa cada webhook-id una sola vez.

La función verify() se verifica contra el vector de prueba de firma de la biblioteca JavaScript de Webhooks Estándar. Para más contexto, consulta verificación de firma de webhook.

Tu servidor debe bloquear direcciones privadas, por lo que no entregará a localhost de forma predeterminada. El borrador permite destinos no públicos solo si se configuran explícitamente: añade una entrada a la lista de permitidos únicamente para desarrollo o expón el receptor mediante un túnel.

6. Prueba el flujo antes de conectar ChatGPT

Puedes crear una colección de pruebas en Apidog para cubrir los modos de fallo documentados por OpenAI.

Crea un entorno con estas variables:

MCP_URL
MCP_TOKEN
CALLBACK_URL
WEBHOOK_SECRET
Enter fullscreen mode Exit fullscreen mode

Envía cada llamada JSON-RPC como POST a {{MCP_URL}} con:

Authorization: Bearer {{MCP_TOKEN}}
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: <método-json-rpc>
Enter fullscreen mode Exit fullscreen mode

El transporte HTTP transmisible requiere esos encabezados.

Cada cuerpo también necesita params._meta con:

  • io.modelcontextprotocol/protocolVersion
  • io.modelcontextprotocol/clientCapabilities

Consulta la especificación base. Los ejemplos de OpenAI omiten _meta, pero un valor ausente también devuelve -32602; sin añadirlo, una prueba de validación podría pasar por el motivo equivocado.

1. Descubrimiento

Envía server/discover y comprueba:

$.result.capabilities.events
Enter fullscreen mode Exit fullscreen mode

También verifica que:

$.result.supportedVersions
Enter fullscreen mode Exit fullscreen mode

incluya 2026-07-28.

Después llama a events/list y confirma que cada evento incluya:

{
  "delivery": ["webhook"]
}
Enter fullscreen mode Exit fullscreen mode

2. Suscripción idempotente

Envía la solicitud de suscripción usando {{CALLBACK_URL}} y {{WEBHOOK_SECRET}}.

Guarda:

$.result.id
Enter fullscreen mode Exit fullscreen mode

como SUB_ID mediante un posprocesador de extracción de variables.

Luego:

  1. Reenvía la misma solicitud sin cambios.
  2. Reenvíala con las claves de arguments en distinto orden.
  3. Comprueba que $.result.id sea igual a {{SUB_ID}} en ambos casos.

3. Validación de parámetros

Prueba estos casos:

  • Un secreto que decodifique a menos de 24 bytes.
  • Una devolución de llamada que use http://.
  • Una devolución de llamada con IP privada.

El borrador asigna estos fallos a -32602 (InvalidParams). Comprueba:

$.error.code
Enter fullscreen mode Exit fullscreen mode

4. Desafío de verificación

Crea un endpoint simulado en Apidog que devuelva:

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

Suscríbete usando la URL del mock en la nube como devolución de llamada.

Comprueba:

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

Después apunta CALLBACK_URL a tu receptor estricto y confirma que la suscripción se complete correctamente.

5. Carga útil excesiva

Genera un evento cuyo cuerpo supere los 262.144 bytes.

El comportamiento esperado es:

  • El remitente rechaza el evento antes de entregarlo.
  • Si el evento alcanza el receptor, este responde 413.
  • Los registros del servidor muestran un único intento, sin reintento.

6. Reproducción y manipulación

Copia los encabezados y el cuerpo de una entrega firmada desde el registro de salida de tu servidor y crea una nueva solicitud en Apidog.

Ejecuta estas pruebas:

Prueba Resultado esperado
Reenviar de inmediato 200 y ninguna segunda línea de registro
Cambiar un byte del cuerpo 401
Reenviar después de 5 minutos 401

Guarda estos pasos como un escenario y ejecútalo en CI con la CLI de Apidog. El manual de pruebas de servidores MCP cubre llamadas a herramientas, y cómo probar webhooks profundiza en los receptores.

Cuando las pruebas pasen, conecta el servidor a ChatGPT mediante un plugin y ejecuta la lista de verificación del ciclo de vida de OpenAI.

Preguntas frecuentes

¿Qué son los Eventos MCP?

Son una extensión experimental de MCP que permite a un servidor enviar notificaciones de eventos a un cliente, en lugar de que el cliente las sondee. ChatGPT admite el modo webhook en 2026-07-28.

¿ChatGPT admite sondeo o transmisión para Eventos MCP?

No. ChatGPT solo admite entrega por webhook y verificación de devolución de llamada. El sondeo, la transmisión y las notificaciones gap y terminated no son compatibles.

¿Qué planes de ChatGPT incluyen Eventos MCP?

El resumen del DevDay de OpenAI indica que la función está disponible para todos los planes.

¿Quién genera el secreto de firma?

El suscriptor. ChatGPT envía el secreto whsec_ en delivery.secret; tu servidor lo valida, almacena y usa para firmar. No debes generar un secreto propio.

¿En qué se diferencia de la API de Agentes?

Los Eventos MCP envían datos desde tu servidor hacia ChatGPT. La API de Agentes de OpenAI ejecuta los agentes que construyes e informa su progreso mediante transmisión o webhooks.

Siguiente paso

Añade "events": {} a tu servidor, publica un evento con filtros y ejecuta las seis comprobaciones contra un receptor local antes de conectar ChatGPT.

Descarga Apidog para guardar estas comprobaciones como un escenario que se ejecute en cada commit.

Top comments (0)