MCP events
Subscribe to API changes on SKMTC Hub over MCP events with the AI SDK, and check the signature of each event that Hub sends to your HTTPS endpoint.
The MCP server of SKMTC Hub, 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. 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 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
- 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.ymlwith ingress rules, the quick tunnel uses them and answers 404. Then add--configwith the path of an empty file. - Deno 2, to run the example as it is. The AI SDK
package
@ai-sdk/mcp2.0.74 has MCP events. The API is experimental, so a later version can change it. - The API as
owner/slug, as in its URLhttps://skmtc.dev/{owner}/apis/{slug}.
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.
-
Start the tunnel to port 8000, and copy its HTTPS URL.
-
Save this program as
main.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() }) -
Run the program. Replace
https://YOUR-TUNNEL-HOSTwith the URL of your tunnel, andOWNER/SLUGwith the API:PUBLIC_URL=https://YOUR-TUNNEL-HOST SKMTC_API=OWNER/SLUG deno run --allow-net --allow-env --allow-read main.tsThe program prints the subscription:
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:
sub_a257706f82c58a7d55fa09e65876c6ca whe_5d0b6f1e2a9c4d3b8e7f6a5b4c3d2e1f 2026-10-13T09:40:12.000Z
{"breaking":1,"warning":0,"info":3} https://skmtc.dev/acme/apis/petstore/revisions/3b4f1c2d9e8a/changelogTo 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 and Errors.
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:
- Remove the
whsec_prefix from your secret. Decode the remainder from base64. The result is the HMAC key. - Make the signed content:
<webhook-id>.<webhook-timestamp>.<body>. Use the raw request body. Do not parse the JSON first. - Calculate the HMAC-SHA256 of the signed content with the key. Encode the result as base64.
- Compare the result with the value after
v1,inwebhook-signature. Use a comparison in constant time. - Refuse a request whose
webhook-timestampis more than five minutes from your clock. - For an event, make sure that
x-mcp-subscription-idis theidthat subscribe gave you. Do not do this check for the challenge. On a first subscribe, the challenge comes before subscribe gives you theid.
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
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.
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
With this secret, your code must calculate the signature of the request in The request. The body is the body of that request, as Hub sends it: one line, with no line break at the end.
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
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:
{"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
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 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
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
idof the subscription andrefreshBefore, 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
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
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
| 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
| 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. |