skmtcdocs

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.yml with ingress rules, the quick tunnel uses them and answers 404. Then add --config with the path of an empty file.
  • Deno 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

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:

    // 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:

    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:

    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/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 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:

  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

HeaderValue
content-typeapplication/json
user-agentSKMTC-Hub-MCP-Events/1
x-mcp-subscription-idThe id of the subscription.
webhook-idThe eventId of the body. For the challenge, msg_verification_ and a random value.
webhook-timestampThe time of the try, in unix seconds.
webhook-signaturev1, 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

FieldValue
eventIdThe id of the event. It is the same on each try and the same as webhook-id.
namedev.skmtc.api.revision.v1.
timestampThe time when Hub recorded the event.
dataThe 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.
cursorAlways 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 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

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

ttlMsLifetime
Not in the params30 minutes
null1 hour
Less than 600001 minute
More than 36000001 hour
From 60000 to 3600000The 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 responseWhat Hub does
Any 2xxThe delivery succeeded.
408, 429, any 5xx, a timeout or a network errorHub tries again later.
Any 3xxThe delivery fails. Hub does not follow the redirect.
410 or 413That delivery fails. The subscription does not change, and it gets the next event.
Any other 4xxThe 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

CodeMessageWhen
-32602The reasonThe params, the arguments, the URL or the secret are not correct.
-32011NotFounddata.kind is event: the event name is not known. data.kind is api: the API does not exist or is not public.
-32015CallbackEndpointErrorYour endpoint did not pass the challenge. data.reason gives the cause.
-32603The subscription could not be stored. Retry the request.An error occurred in Hub. Try again.
-32601Method not foundThe server does not offer MCP events.

data.reason of CallbackEndpointError has one of these values:

data.reasonCause
challenge_failedA 2xx without the correct challenge, or a 1xx or 3xx status.
http_4xxA 4xx status.
http_5xxA 5xx status.
timeoutNo answer in 10 seconds.
connection_refusedNo connection, a DNS failure, a TLS failure, or an address that is not public.

On this page