IA

MCP Events en ChatGPT: haz que tu servidor MCP avise solo

· 9 min de lectura

Qué es MCP Events y cómo crear un servidor MCP que avise a ChatGPT por webhook: events/subscribe, firma, verificación y un ejemplo con repo en GitHub.

Hasta ahora, en MCP siempre preguntaba la IA: tú le pedías algo a ChatGPT y él llamaba a una tool de tu servidor. Con MCP Events es al revés: tu servidor avisa a ChatGPT cuando pasa algo, y ChatGPT reacciona solo.

Por ejemplo: "Avísame cuando aparezca un Pokémon de tipo fuego y dime contra qué tipos es fuerte". Tú lo pides una vez y ChatGPT se queda vigilando.

Lo vamos a hacer sobre el mismo servidor de PokéAPI de la serie. Todo el código está en un único commit: f9b5da3 en mcp-ui-openai-pokemon.

La idea en una frase

Tu servidor MCP ya no solo responde: también avisa.

Piénsalo como las notificaciones del móvil. Antes tenías que abrir la app para ver si había algo nuevo. Ahora la app te avisa, y además ChatGPT sabe qué hacer con el aviso porque tú se lo dijiste al suscribirte.

MCP Events: de preguntar a ChatGPT a que tu servidor avise solo

Antes de empezar: dónde funciona

  • Solo en ChatGPT, en los chats de trabajo (Work) de la web, en la app de escritorio con Cloud seleccionado y en dots.

  • Es experimental. No forma parte del núcleo de MCP: es un borrador de extensión del grupo de trabajo de Triggers & Events. Puede cambiar.

  • Tu servidor se conecta a ChatGPT como plugin, igual que en la app de ChatGPT con MCP.

  • Funciona tanto con servidores MCP 2.0 como con los anteriores (HTTP con initialize). Mi servidor sigue en el SDK v1 y funciona.

  • Necesitas guardar las suscripciones (en el ejemplo, un fichero JSON) y que tu servidor pueda hacer peticiones HTTPS hacia fuera.

Cómo funciona, en 5 pasos

Flujo de MCP Events: aparece un Pokémon, tu servidor MCP avisa a ChatGPT
  1. Tu servidor dice qué eventos tiene (events/list). En el ejemplo: pokemon.appeared, "ha aparecido un Pokémon salvaje".

  2. El usuario le dice a ChatGPT qué vigilar y qué hacer: "avísame de los de tipo fuego y dime contra qué son fuertes".

  3. ChatGPT se suscribe (events/subscribe) y le da a tu servidor una URL donde avisarle y una clave secreta para firmar los avisos.

  4. Cuando pasa algo, tu servidor manda un POST firmado a esa URL.

  5. ChatGPT recibe el evento en el chat y hace lo que le pidió el usuario.

¿Y qué es "cuando pasa algo"? En una app real sería un pedido nuevo, un comentario o un cambio de estado. Como en PokéAPI no "pasa" nada, el ejemplo tiene un endpoint que lo simula: POST /dev/wild-pokemon?name=charmander hace aparecer un Charmander.

El código, archivo a archivo

El commit añade tres archivos y toca dos:

Archivo

Qué hace

src/events.ts

Define el evento y responde a events/list, events/subscribe y events/unsubscribe

src/event-subscriptions.ts

Guarda las suscripciones en data/subscriptions.json

src/webhook-delivery.ts

Manda el POST firmado y bloquea direcciones privadas

src/server.ts

Una línea: registerPokemonEvents(server)

src/http.ts

El endpoint que simula que aparece un Pokémon

Paso 1: decir que tu servidor tiene eventos

El servidor anuncia la capacidad events y registra los tres métodos nuevos. El SDK de TypeScript no los trae (es una extensión), pero deja registrar métodos propios con setRequestHandler y un schema de zod:

export function registerPokemonEvents(mcpServer: McpServer) {
  const server = mcpServer.server;

  // `events` no está en los tipos del SDK: es una extensión.
  server.registerCapabilities({ events: {} } as Record<string, object>);

  server.setRequestHandler(EventsListRequestSchema, async () => ({
    events: [POKEMON_APPEARED],
  }));

  // events/subscribe y events/unsubscribe, igual (ver commit)
}

Donde EventsListRequestSchema es simplemente:

const EventsListRequestSchema = z.object({
  method: z.literal("events/list"),
  params: z.object({ cursor: z.string().optional() }).passthrough().optional(),
});

Paso 2: definir el evento

Cada evento tiene nombre, descripción, qué filtros acepta (inputSchema) y qué datos manda (payloadSchema):

export const POKEMON_APPEARED = {
  name: "pokemon.appeared",
  description:
    "A wild Pokémon appeared. Optionally filter by type (e.g. fire, water, electric). The payload includes id, name, types and spriteUrl; call get_pokemon for full details.",
  delivery: ["webhook"],
  inputSchema: {
    type: "object",
    properties: {
      type: { type: "string", description: "Only notify for Pokémon of this type…" },
    },
    additionalProperties: false,
  },
  payloadSchema: {
    type: "object",
    properties: {
      id: { type: "number" },
      name: { type: "string" },
      types: { type: "array", items: { type: "string" } },
      spriteUrl: { type: ["string", "null"] },
    },
    required: ["id", "name", "types", "spriteUrl"],
  },
};

Igual que con las tools, la descripción importa: es lo que lee ChatGPT para saber cuándo usar el evento. Fíjate en que le digo que, si quiere más detalle, llame a get_pokemon. Así el evento va ligero y la información completa sale de una tool que ya existe.

Paso 3: guardar la suscripción

Cuando el usuario pide vigilar algo, ChatGPT llama a events/subscribe:

{
  "method": "events/subscribe",
  "params": {
    "name": "pokemon.appeared",
    "arguments": { "type": "fire" },
    "delivery": {
      "mode": "webhook",
      "url": "https://…/mcp-events/callback_123",
      "secret": "whsec_…"
    },
    "cursor": null
  }
}

El servidor comprueba el filtro, la clave (tiene que empezar por whsec_) y la URL; verifica el callback (paso 4), guarda la suscripción y responde con un id y la fecha de caducidad:

{
  "id": "sub_e8426c52074ead947125b97f",
  "refreshBefore": "2026-11-02T21:50:41.189Z",
  "cursor": null,
  "truncated": false
}

Antes de esa fecha, ChatGPT vuelve a llamar a events/subscribe para renovarla.

Un detalle importante: si ChatGPT pide la misma suscripción dos veces, no hay que crear dos. Por eso el id no es aleatorio: sale de un hash de usuario + URL + evento + filtro. Una petición repetida actualiza la suscripción que ya existe.

Paso 4: verificar la URL antes de mandar nada

Antes de enviar eventos reales, el servidor comprueba que al otro lado está quien dice estar. Manda un "reto" firmado:

{ "type": "verification", "challenge": "valor-aleatorio-de-un-solo-uso" }

ChatGPT responde devolviendo el mismo valor. Si coincide, la suscripción queda activa. Si no, events/subscribe devuelve el error -32015 con el motivo (challenge_failed, timeout…).

Es como confirmar el email cuando te registras en una web.

Paso 5: mandar el evento

Cuando aparece un Pokémon, el servidor busca las suscripciones cuyo filtro encaja (un Charmander le llega a quien vigila fire; un Pikachu, no) y manda a cada una:

{
  "eventId": "evt_abed49a3-b9a5-4e1b-9df1-f60cc840f3a7",
  "name": "pokemon.appeared",
  "timestamp": "2026-11-01T21:50:44.880Z",
  "data": {
    "id": 4,
    "name": "charmander",
    "types": ["fire"],
    "spriteUrl": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png"
  },
  "cursor": null
}

El aviso va firmado con el estándar Standard Webhooks, que tiene librería para Node (npm install standardwebhooks):

const signature = new Webhook(secret).sign(eventId, signedAt, body);

// cabeceras del POST
"webhook-id": eventId,
"webhook-timestamp": String(Math.floor(signedAt.getTime() / 1000)),
"webhook-signature": signature,
"X-MCP-Subscription-Id": subscription.id,

Reglas que el ejemplo ya cumple:

  • Un evento por petición, de 256 KB como máximo.

  • Si falla, reintenta (3 intentos con espera creciente), con el mismo eventId y firma nueva. Si responde 410, borra la suscripción; si responde 413, no reintenta.

  • Si cambia la clave al renovar, firma con la nueva y la vieja durante 5 minutos.

Probarlo en local (sin ChatGPT)

El repo trae un receptor que hace de ChatGPT: se suscribe, responde al reto y comprueba las firmas.

# Terminal 1: el servidor MCP
DEV_TRIGGER_TOKEN=secreto EVENTS_ALLOW_INSECURE_LOCALHOST=true npm run dev:http

# Terminal 2: el "ChatGPT" de pruebas, vigilando tipo fuego
npm run events:receiver -- fire

# Terminal 3: aparece un Charmander
curl -X POST 'http://localhost:8787/dev/wild-pokemon?name=charmander' \
  -H 'Authorization: Bearer secreto'

En la terminal 2 verás:

✓ Reto de verificación recibido y devuelto
events/subscribe → {"result":{"id":"sub_…","refreshBefore":"…","cursor":null,"truncated":false},…}
✓ Evento recibido: { "eventId": "evt_…", "name": "pokemon.appeared", "data": { "name": "charmander", … } }

EVENTS_ALLOW_INSECURE_LOCALHOST=true deja usar http://localhost como callback solo para esta prueba. Sin esa variable, el servidor exige HTTPS.

Probarlo en ChatGPT

  1. Arranca con DEV_TRIGGER_TOKEN=secreto npm run dev:http y abre un túnel HTTPS (ngrok http 8787).

  2. Conecta el servidor como plugin y vuelve a escanearlo: pokemon.appeared aparece junto a las tools.

  3. Abre un chat de trabajo y pide: "Avísame cuando aparezca un Pokémon de tipo fuego y dime contra qué tipos es fuerte".

  4. Haz aparecer uno: curl -X POST 'https://TU-TUNEL/dev/wild-pokemon?name=charmander' -H 'Authorization: Bearer secreto'.

Seguridad: lo que no te puedes saltar

Tu servidor va a hacer peticiones a URLs que le pasa otro. Eso tiene riesgos, y el ejemplo los cubre:

  • Solo HTTPS y sin redirecciones.

  • Nada de IPs privadas o internas. Si no lo bloqueas, alguien podría usar tu servidor para llegar a sitios de tu red que no deberían ser accesibles (un ataque llamado SSRF). El ejemplo comprueba la IP justo al conectar, no solo al guardar la URL: así no le pueden cambiar el DNS por el camino.

  • El disparador está protegido. /dev/wild-pokemon solo existe si defines DEV_TRIGGER_TOKEN, y exige ese token.

  • El texto del evento es dato, no instrucciones. Aquí solo mando nombre y tipos de un Pokémon, pero en una app real un título lo escribe cualquiera. Desconfía de lo que venga dentro.

  • Cuidado con los bucles. Si ChatGPT reacciona a un evento cambiando algo que, a su vez, genera otro evento, puedes acabar en un bucle infinito.

Lo que no cubre el ejemplo: autenticación. Es una demo y todas las suscripciones son del mismo "usuario". En un servidor real, cada suscripción va ligada al usuario autenticado y hay que dejar de enviarle eventos si pierde el acceso.

Cuándo NO usar MCP Events

MCP Events tiene sentido cuando quieres que la IA decida qué hacer con cada aviso: entender un mensaje, resumir un comentario, proponer una solución.

Si solo quieres "cuando pase X, haz Y" sin pensar (mandar un mensaje a Slack, copiar un dato a otra hoja), un webhook directo, una GitHub Action son más sencillos y más baratos.

Preguntas frecuentes

¿Qué es MCP Events?

Una extensión experimental de MCP que permite a ChatGPT suscribirse a eventos de tu servidor (un mensaje nuevo, un comentario, un cambio de estado) y reaccionar a ellos según lo que le pidió el usuario.

¿Necesito MCP 2.0?

No. Según la guía de OpenAI, funciona con servidores MCP 2.0 y también con servidores HTTP anteriores que usan initialize. El ejemplo de este post usa el SDK v1.

¿Es lo mismo que un webhook?

Por debajo, la entrega es un webhook firmado. La diferencia es que la suscripción la crea ChatGPT a través de MCP, y que quien recibe el aviso es una IA que decide qué hacer.

¿Es lo mismo que subscriptions/listen de MCP 2.0?

No. subscriptions/listen avisa de cambios en el propio servidor (por ejemplo, que ha cambiado su lista de tools). MCP Events avisa de cosas que pasan fuera, como que aparezca un Pokémon.

Enlaces