# MCP events



The MCP server of [SKMTC Hub](https://skmtc.dev), `https://mcp.skmtc.dev/mcp`,
sends an event to your HTTPS endpoint when an API changes. Hub sends the event
when a new revision becomes the latest revision of the API. The diff with the
revision before it must find a change. This page tells you how to subscribe
from an MCP client, receive the event, and check its signature.

The server uses the webhook mode of the MCP events extension,
[SEP-3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415).
SEP-3415 is a draft, so the method names, the error codes and the
capability key can change.

You can subscribe only to a public API. The server has no sign-in, so a
subscription to a private API gets `NotFound`.

[API change webhooks](/docs/api-change-webhooks) send the same event as a
CloudEvent. A person adds an API change webhook on the page of the API, and
it can watch a private API that the person can read.

## Before you start [#before-you-start]

* An HTTPS URL that is public and that sends requests to your receiver. To
  receive events on your computer, put a tunnel in front of the receiver, for
  example `cloudflared tunnel --url http://localhost:8000`. If you have a
  `~/.cloudflared/config.yml` with ingress rules, the quick tunnel uses them
  and answers 404. Then add `--config` with the path of an empty file.
* [Deno](https://deno.com) 2, to run the example as it is. The AI SDK
  package `@ai-sdk/mcp` 2.0.74 has MCP events. The API is experimental, so a
  later version can change it.
* The API as `owner/slug`, as in its URL `https://skmtc.dev/{owner}/apis/{slug}`.

## Subscribe with the AI SDK [#subscribe-with-the-ai-sdk]

The AI SDK subscribes with `experimental_events.subscribe` and receives with
`experimental_createMCPEventWebhook`. The webhook handler answers the
verification challenge and checks the signature of each request. It calls
`onEvent` only for an event whose signature is correct.

1. Start the tunnel to port 8000, and copy its HTTPS URL.

2. Save this program as `main.ts`:

   ```ts
   // main.ts
   import {
     createMCPClient,
     experimental_createMCPEventWebhook as createMCPEventWebhook,
     type Experimental_MCPEventStore as MCPEventStore,
     type Experimental_MCPEventSubscription as MCPEventSubscription
   } from 'npm:@ai-sdk/mcp@2.0.74'

   const publicUrl = Deno.env.get('PUBLIC_URL')?.replace(/\/+$/, '')
   if (!publicUrl) throw new Error('Set PUBLIC_URL to the HTTPS URL of your tunnel')
   const api = Deno.env.get('SKMTC_API') ?? 'stripe/spec3'
   const mcpUrl = Deno.env.get('MCP_URL') ?? 'https://mcp.skmtc.dev/mcp'

   // The SDK keeps each subscription and its signing secret here.
   // Use a durable, private store in production.
   const subscriptions = new Map<string, MCPEventSubscription>()
   const store: MCPEventStore = {
     get: async (key) => subscriptions.get(key),
     getById: async (id) => [...subscriptions.values()].find((item) => item.id === id),
     set: async (subscription) => {
       subscriptions.set(subscription.key, subscription)
     },
     update: async (key, patch) => {
       const subscription = subscriptions.get(key)
       if (subscription) subscriptions.set(key, { ...subscription, ...patch })
     },
     delete: async (key) => {
       subscriptions.delete(key)
     }
   }

   const handleEvent = createMCPEventWebhook({
     store,
     // Hub can send an event more than one time. Use the subscription id and
     // the event id to find duplicates.
     onEvent: async ({ subscription, event }) => {
       console.log(subscription.id, event.eventId, event.timestamp)
       console.log(JSON.stringify(event.data.counts), event.data.changesUrl)
     }
   })

   Deno.serve({ port: 8000 }, (request) =>
     new URL(request.url).pathname === '/mcp-events'
       ? handleEvent(request)
       : new Response(null, { status: 404 })
   )

   const client = await createMCPClient({
     transport: { type: 'http', url: mcpUrl },
     experimental_events: { store }
   })
   const subscribed = await client.experimental_events.subscribe({
     name: 'dev.skmtc.api.revision.v1',
     arguments: { api },
     delivery: { mode: 'webhook', url: `${publicUrl}/mcp-events` },
     ttlMs: 3_600_000
   })
   console.log(`Subscribed ${subscribed.id} until ${subscribed.refreshBefore}`)

   // A subscription stops at refreshBefore. Refresh it before that time.
   const refresh = setInterval(() => {
     client.experimental_events
       .refresh({ id: subscribed.id })
       .catch((error) => console.error('The refresh failed. It runs again later.', error))
   }, 20 * 60 * 1000)

   Deno.addSignalListener('SIGINT', async () => {
     clearInterval(refresh)
     await client.experimental_events.unsubscribe({ id: subscribed.id })
     await client.close()
     Deno.exit()
   })
   ```

3. Run the program. Replace `https://YOUR-TUNNEL-HOST` with the URL of your
   tunnel, and `OWNER/SLUG` with the API:

   ```sh
   PUBLIC_URL=https://YOUR-TUNNEL-HOST SKMTC_API=OWNER/SLUG deno run --allow-net --allow-env --allow-read main.ts
   ```

   The program prints the subscription:

   ```text
   Listening on http://0.0.0.0:8000/ (http://localhost:8000/)
   Subscribed sub_a257706f82c58a7d55fa09e65876c6ca until 2026-10-13T10:40:12.000Z
   ```

When the API gets a new revision with a change, the program prints the
subscription, the event, the counts and the link to the changes:

```text
sub_a257706f82c58a7d55fa09e65876c6ca whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f 2026-10-13T09:40:12.000Z
{"breaking":1,"warning":0,"info":3} https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a/changelog
```

To stop the program and remove the subscription, push Ctrl+C.

If your endpoint does not pass the challenge, the subscribe call stops with
an error. See [The verification challenge](#the-verification-challenge) and
[Errors](#errors).

## Check a delivery without an SDK [#check-a-delivery-without-an-sdk]

Each request to your endpoint has a Standard Webhooks signature. To check a
request without the AI SDK, do these steps:

1. Remove the `whsec_` prefix from your secret. Decode the remainder from
   base64. The result is the HMAC key.
2. Make the signed content: `<webhook-id>.<webhook-timestamp>.<body>`. Use the
   raw request body. Do not parse the JSON first.
3. Calculate the HMAC-SHA256 of the signed content with the key. Encode the
   result as base64.
4. Compare the result with the value after `v1,` in `webhook-signature`. Use a
   comparison in constant time.
5. Refuse a request whose `webhook-timestamp` is more than five minutes from
   your clock.
6. For an event, make sure that `x-mcp-subscription-id` is the `id` that
   subscribe gave you. Do not do this check for the challenge. On a first
   subscribe, the challenge comes before subscribe gives you the `id`.

### Headers [#headers]

| Header                  | Value                                                                                 |
| ----------------------- | ------------------------------------------------------------------------------------- |
| `content-type`          | `application/json`                                                                    |
| `user-agent`            | `SKMTC-Hub-MCP-Events/1`                                                              |
| `x-mcp-subscription-id` | The `id` of the subscription.                                                         |
| `webhook-id`            | The `eventId` of the body. For the challenge, `msg_verification_` and a random value. |
| `webhook-timestamp`     | The time of the try, in unix seconds.                                                 |
| `webhook-signature`     | `v1,` and the base64 HMAC-SHA256 of the signed content.                               |

### The request [#the-request]

Hub sends one HTTP `POST` for each event. The body is one SEP-3415
`EventOccurrence`, as JSON. Hub sends the body on one line; this page adds
line breaks.

```http
POST /mcp-events HTTP/1.1
content-type: application/json
user-agent: SKMTC-Hub-MCP-Events/1
x-mcp-subscription-id: sub_a257706f82c58a7d55fa09e65876c6ca
webhook-id: whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f
webhook-timestamp: 1791884413
webhook-signature: v1,Drm1qB8sQep72WcJkLM4CRMmscUAGPDlftXHIF+3GKA=

{
  "eventId": "whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f",
  "name": "dev.skmtc.api.revision.v1",
  "timestamp": "2026-10-13T09:40:12.000Z",
  "data": {
    "api": {
      "id": "api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6",
      "owner": "acme",
      "slug": "petstore",
      "name": "Petstore",
      "url": "https://skmtc.dev/acme/apis/petstore"
    },
    "revision": {
      "id": "ver_0c9e8d7f6a5b4c3d2e1f0a9b8c7d6e5f",
      "contentHash": "3b4f1c2d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c",
      "version": "v2",
      "publishedAt": "2026-10-13T09:12:00.000Z",
      "retrievedAt": "2026-10-13T09:40:05.000Z",
      "url": "https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a"
    },
    "previousRevision": {
      "id": "ver_7a6b5c4d3e2f41a0b9c8d7e6f5a4b3c2",
      "contentHash": "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d",
      "version": "v2",
      "publishedAt": null,
      "retrievedAt": "2026-10-06T14:02:51.000Z",
      "url": "https://skmtc.dev/acme/apis/petstore/revisions/9e8d7c6b5a4f"
    },
    "summary": {
      "diff": true,
      "details": {
        "endpoints": { "added": 1, "deleted": 0, "modified": 2 },
        "schemas": { "added": 0, "deleted": 1, "modified": 1 }
      }
    },
    "counts": { "breaking": 1, "warning": 0, "info": 3 },
    "changesUrl": "https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a/changelog"
  },
  "cursor": null
}
```

### Check your code with an example [#check-your-code-with-an-example]

With this secret, your code must calculate the signature of the request in
[The request](#the-request). The body is the body of that request, as Hub
sends it: one line, with no line break at the end.

```text
secret:            whsec_9WIZHy/xSjnjTWaH6bk12Dg2ZsBRFVhq/sbchQ7QJM4=
webhook-id:        whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f
webhook-timestamp: 1791884413
body:              {"eventId":"whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f","name":"dev.skmtc.api.revision.v1","timestamp":"2026-10-13T09:40:12.000Z","data":{"api":{"id":"api_8f2c1d4e5b6a47c9a0e3f1b2c3d4e5f6","owner":"acme","slug":"petstore","name":"Petstore","url":"https://skmtc.dev/acme/apis/petstore"},"revision":{"id":"ver_0c9e8d7f6a5b4c3d2e1f0a9b8c7d6e5f","contentHash":"3b4f1c2d9e8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c","version":"v2","publishedAt":"2026-10-13T09:12:00.000Z","retrievedAt":"2026-10-13T09:40:05.000Z","url":"https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a"},"previousRevision":{"id":"ver_7a6b5c4d3e2f41a0b9c8d7e6f5a4b3c2","contentHash":"9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d","version":"v2","publishedAt":null,"retrievedAt":"2026-10-06T14:02:51.000Z","url":"https://skmtc.dev/acme/apis/petstore/revisions/9e8d7c6b5a4f"},"summary":{"diff":true,"details":{"endpoints":{"added":1,"deleted":0,"modified":2},"schemas":{"added":0,"deleted":1,"modified":1}}},"counts":{"breaking":1,"warning":0,"info":3},"changesUrl":"https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a/changelog"},"cursor":null}
webhook-signature: v1,Drm1qB8sQep72WcJkLM4CRMmscUAGPDlftXHIF+3GKA=
```

### The verification challenge [#the-verification-challenge]

Before the server stores a subscription, Hub sends a challenge to your
endpoint. Hub sends a new challenge on each subscribe and on each refresh.
The challenge has the same headers and the same signature scheme. Its body
is:

```text
{"type":"verification","challenge":"3380a243-8cee-4271-a3cd-807f50c1ab26"}
```

Your endpoint must answer within 10 seconds with a `2xx` status and the JSON
body `{"challenge":"<the same value>"}`. A redirect fails the challenge.

## Reference [#reference]

### The event [#the-event]

| Field       | Value                                                                                                                                                                                                                             |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventId`   | The id of the event. It is the same on each try and the same as `webhook-id`.                                                                                                                                                     |
| `name`      | `dev.skmtc.api.revision.v1`.                                                                                                                                                                                                      |
| `timestamp` | The time when Hub recorded the event.                                                                                                                                                                                             |
| `data`      | The revision event data: the API, the revision, the revision before it, the summary, the counts at each severity and the `changesUrl`. [API change webhooks](/docs/api-change-webhooks#revision-event-data) describes each field. |
| `cursor`    | Always `null`. The event has no replay.                                                                                                                                                                                           |

`events/list` gives the event type with its `inputSchema`, `{ "api": "owner/slug" }`,
and its `payloadSchema`, the JSON Schema of `data`. `server/discover` lists
`"events": { "listChanged": false }` in `capabilities`.

Hub does not send an event when the diff finds no change. A refetch of a
document that did not change sends no event.

### Subscribe [#subscribe]

`events/subscribe` takes `name`, `arguments`, and
`delivery: { mode: "webhook", url, secret }`.

* The URL must use HTTPS and must have a public host. It must not contain a
  user name or a password. It can have 2048 characters at most.
* The secret is `whsec_` and the base64 of 24 to 64 random bytes. The AI SDK
  makes one for you.
* The result gives the `id` of the subscription and `refreshBefore`, the time
  when the subscription stops.

A subscribe with the same URL, name, arguments and secret is a refresh. It
keeps the same `id` and gives a new `refreshBefore`. A subscribe with a
different secret makes a different subscription.

### TTL and refresh [#ttl-and-refresh]

The server grants the lifetime from `ttlMs`. It never refuses a `ttlMs`.

| `ttlMs`               | Lifetime   |
| --------------------- | ---------- |
| Not in the params     | 30 minutes |
| `null`                | 1 hour     |
| Less than 60000       | 1 minute   |
| More than 3600000     | 1 hour     |
| From 60000 to 3600000 | The value  |

After `refreshBefore`, Hub sends no more events to the subscription. To keep
the subscription, subscribe again before that time.

### Unsubscribe [#unsubscribe]

`events/unsubscribe` takes `name`, `arguments` and `delivery: { url }`, with
no secret. It removes each subscription with that URL, name and arguments.
The result has `removed`, the number of subscriptions that it removed. When no
subscription matched, the result has `removed: 0` and no error.

### Responses and retries [#responses-and-retries]

| Your response                                   | What Hub does                                                                      |
| ----------------------------------------------- | ---------------------------------------------------------------------------------- |
| Any 2xx                                         | The delivery succeeded.                                                            |
| 408, 429, any 5xx, a timeout or a network error | Hub tries again later.                                                             |
| Any 3xx                                         | The delivery fails. Hub does not follow the redirect.                              |
| 410 or 413                                      | That delivery fails. The subscription does not change, and it gets the next event. |
| Any other 4xx                                   | The delivery fails. Hub does not try again.                                        |

Answer within 10 seconds. Hub tries a delivery again for not more than 24
hours after its first try. Each try has a new `webhook-timestamp` and a new
signature, but the same `webhook-id`.

Delivery is at least once, and Hub does not guarantee the order of
deliveries. Use `previousRevision` to find out if you missed an event.

If the owner makes the API private or deletes it, the subscription stops.
Hub sends it no more events, and a refresh gets `NotFound`.

### Errors [#errors]

| Code     | Message                                                    | When                                                                                                                |
| -------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `-32602` | The reason                                                 | The params, the arguments, the URL or the secret are not correct.                                                   |
| `-32011` | `NotFound`                                                 | `data.kind` is `event`: the event name is not known. `data.kind` is `api`: the API does not exist or is not public. |
| `-32015` | `CallbackEndpointError`                                    | Your endpoint did not pass the challenge. `data.reason` gives the cause.                                            |
| `-32603` | `The subscription could not be stored. Retry the request.` | An error occurred in Hub. Try again.                                                                                |
| `-32601` | Method not found                                           | The server does not offer MCP events.                                                                               |

`data.reason` of `CallbackEndpointError` has one of these values:

| `data.reason`        | Cause                                                                          |
| -------------------- | ------------------------------------------------------------------------------ |
| `challenge_failed`   | A `2xx` without the correct `challenge`, or a `1xx` or `3xx` status.           |
| `http_4xx`           | A `4xx` status.                                                                |
| `http_5xx`           | A `5xx` status.                                                                |
| `timeout`            | No answer in 10 seconds.                                                       |
| `connection_refused` | No connection, a DNS failure, a TLS failure, or an address that is not public. |
