Blog · · The Expose team
How we built MCP Events, so an AI agent hears when a webhook fails
MCP Events is a brand new draft extension of the Model Context Protocol. Here is how we implemented webhook delivery of events in the Expose MCP server, what we had to decide ourselves, and what we learned making it work with ChatGPT.
The Model Context Protocol lets an AI agent call tools. What it could not do until now is the other direction: tell the agent that something happened. If you wanted an agent to notice a failing webhook, it had to ask "any new requests?" again and again.
MCP Events changes that. It is a draft extension of the protocol (SEP-3415) in which a server describes event types, a client subscribes to them, and the server delivers each event to the client. The Expose MCP server supports it with one event, request.completed, and ChatGPT can subscribe to it today. You can say "tell me when a Stripe webhook to my tunnel fails" and it will.
This post walks through how we built it: the three new methods, how a request travels from a tunnel server to an agent, and the decisions the draft leaves to the server.
The shape of the extension#
A server that supports events adds three JSON-RPC methods next to tools/list and tools/call:
events/listreturns the event types, each with a name, a description for the model, a JSON Schema for the subscription arguments and one for the payload.events/subscribecreates a subscription, or refreshes an existing one.events/unsubscribeends it.
We use laravel/mcp, and registering the methods is three lines in the server class:
$this->addMethod('events/list', ListEvents::class);
$this->addMethod('events/subscribe', SubscribeToEvent::class);
$this->addMethod('events/unsubscribe', UnsubscribeFromEvent::class);
The server also has to say that it supports events when the client initializes. Here we met the first surprise of working with a draft: the SEP declares the extension under extensions, while ChatGPT reads a top-level events capability from an earlier draft. So we declare both:
$capabilities['events'] = (object) [];
$capabilities['extensions']['io.modelcontextprotocol/events'] = (object) [];
One event, described for a model#
Our single event type is request.completed: a request to one of your tunnels was answered by your local site, or ended without an answer. Its definition is what events/list returns, and like a tool description, it is written for the model that decides when to use it:
return [
'name' => 'request.completed',
'title' => 'Request completed',
'description' => 'Fires when an HTTP request to one of the user\'s Expose tunnels has been answered by their local site, or ended without an answer. Use this when the user wants to hear about requests as they happen, for example "tell me when a Stripe webhook to my tunnel fails". Filter with status "error" when only failures matter...',
'delivery' => ['webhook'],
'inputSchema' => [
'type' => 'object',
'properties' => [
'tunnel' => ['type' => 'string'],
'method' => ['type' => 'string'],
'status' => ['type' => 'string', 'pattern' => '^([1-5]xx|[1-5][0-9]{2}|error)$'],
'path' => ['type' => 'string'],
],
'required' => ['tunnel'],
],
'payloadSchema' => [/* tunnel, request_id, started_at, method, path, status, duration_ms, ... */],
];
Two things in there turned out to matter.
The filters are the same as the tool's. expose.list_requests already filters by method, status and path. The event takes the same arguments with the same meaning, so a model that learned one knows the other, and a coding agent without event support can fall back to the tool with identical filters.
The payload is a summary. An event carries the method, path, status, duration and content types, and no headers or bodies. A webhook body can hold customer data, and an event is pushed to a URL without anyone asking for that particular request. If the agent wants to look closer, it passes the event's request_id to expose.get_request, which goes through the normal tool path with its own scope check and credential redaction.
events/list works before the user has signed in, just like tools/list, so a client can show what the server offers on its connector page.
Subscribing#
A client subscribes with its own webhook URL and a secret it generated:
{
"jsonrpc": "2.0",
"id": 7,
"method": "events/subscribe",
"params": {
"name": "request.completed",
"arguments": { "tunnel": "shop.eu-1.sharedwithexpose.com", "status": "error", "path": "/stripe" },
"delivery": {
"mode": "webhook",
"url": "https://client.example.com/mcp-events",
"secret": "whsec_..."
},
"ttlMs": 86400000
}
}
A subscription is its key#
The draft identifies a subscription by the tuple of principal, callback URL, event name and arguments. We turned that into a deterministic id:
public static function keyFor(User $user, string $url, string $name, array $arguments): string
{
return 'sub_'.substr(hash('sha256', json_encode([$user->getKey(), $url, $name, $arguments], JSON_UNESCAPED_SLASHES)), 0, 32);
}
This makes events/subscribe idempotent. Subscribing twice with the same parameters refreshes the one subscription instead of creating a second, which is how clients are meant to keep a subscription alive. For it to work, equal arguments have to produce equal keys, so we normalize before hashing: the tunnel becomes a lowercase host whether the client sent a host or a URL, the method is uppercased, and the keys are sorted.
Subscriptions expire#
Nothing tells a server that a chat was deleted or a client went away. So every subscription has an end: the client suggests a lifetime with ttlMs, we grant at most 24 hours, and the response says when to refresh:
{ "id": "sub_3f9a...", "refreshBefore": "2026-11-12T22:00:00Z", "cursor": null, "truncated": false }
Expired subscriptions stop getting deliveries immediately and are deleted by Laravel's model:prune. A refresh can also rotate the secret. For 15 minutes after a rotation we sign every delivery with both the new and the old secret, so events that are already on their way still verify.
Proving the callback URL wants the events#
The callback URL is chosen by the caller. Without a check, anyone with an Expose account could point a stream of signed POST requests at somebody else's server. So before the first delivery to a URL, the endpoint has to pass a handshake: we send a signed message with a random challenge, and the endpoint has to answer 2xx and echo the challenge.
$challenge = Str::random(43);
$body = json_encode(['type' => 'verification', 'challenge' => $challenge]);
$result = $this->sender->send($url, $id, 'msg_verification_'.Str::random(24), $body, [$secret]);
// ...the response has to be successful and contain {"challenge": "<the same value>"}
An endpoint can only echo the challenge if it parses our message, and it should only do that after verifying the signature with the secret it handed out itself. A pass is remembered per user and URL for a day, so refreshes and further subscriptions to the same client don't repeat it.
Callback URLs are an SSRF risk#
A server that sends HTTP requests to URLs supplied by users has to assume some of them point inward: https://169.254.169.254/, an internal hostname, or a public name that resolves to a private address the second time it is looked up. Our sender handles it in one place:
- Only
httpsURLs, without credentials in them. - The host is resolved on every send, and every address it resolves to has to be a public one.
- The connection is pinned to the address we checked, with curl's
CURLOPT_RESOLVE, so a DNS answer can't change between the check and the request. - Redirects are not followed.
Http::timeout(10)
->connectTimeout(5)
->withOptions([
'allow_redirects' => false,
'curl' => [CURLOPT_RESOLVE => ["{$host}:{$port}:{$address}"]],
])
From a tunnel to the agent#
Now the interesting part. The requests happen on our tunnel servers, which are separate machines around the world running a Go server, and the subscriptions live in the Laravel application behind expose.dev. An event has to cross from one to the other without slowing down anyone's tunnel.
Servers only report what somebody watches#
Reporting every request of every tunnel to the platform would be a lot of traffic for nothing, because almost no tunnel has a subscriber at a given moment. Instead, each tunnel server polls the platform every ten seconds for a watch list: the hosts that have a live subscription.
{ "watches": [ { "site": "shop.eu-1.sharedwithexpose.com", "token_hashes": ["9f2c..."] } ] }
The answer is the same for every server, so it is cached for a few seconds, and a new subscription reaches the servers within about 15 seconds. A server then reports the requests of watched tunnels in batches, about once a second, to a second endpoint. Both endpoints are authenticated with a token that only our servers have.
A subdomain is not an identity#
Look at token_hashes in the watch list. A subscription names a tunnel by its public host, but hosts change hands: you stop sharing, and later somebody else opens a tunnel on the same subdomain. Your subscription must not see their requests.
So the watch list pairs each host with SHA-256 hashes of the subscribers' Expose tokens. A server reports a request only when the tunnel was opened with one of those tokens, and it sends the hash along. The platform checks it again when it matches requests to subscriptions:
foreach ($this->events as $event) {
foreach ($bySite[$event['site']] ?? [] as $subscription) {
if (! in_array($event['token_hash'], $tokenHashes[$subscription->user_id] ?? [], true)
|| ! RequestCompleted::matches($subscription->arguments, $event['request'])) {
continue;
}
$this->deliver($subscription, $event);
}
}
A subscription is tied to the user who opens the tunnel, and the host only narrows down which of their tunnels is meant. It also means a subscription survives a reconnect: when your tunnel comes back on the same host, the events continue.
Everything after the report happens on the queue#
The endpoint that receives a batch validates it, dispatches a job and answers 202. Matching and delivery happen on the queue, so a slow subscriber can never make a tunnel server wait. One job fans a batch out to the matching subscriptions, and each delivery is its own job with its own retries.
Every event can start an agent run#
A human who gets too many notifications mutes them. An agent that gets an event may start working, and that costs somebody money. A busy tunnel can easily get hundreds of requests a minute, so each subscription is limited to 30 deliveries a minute:
if (! RateLimiter::attempt("mcp-events:{$subscription->id}", $limit, fn () => true, 60)) {
// The next refresh reports the dropped events as truncated.
$subscription->increment('dropped_events');
return;
}
Dropping silently would be dishonest, so the count is kept and the next refresh answers with "truncated": true. The event's description also tells the model about the limit and suggests the error filter, which is the better fix: subscribe to what you care about.
Delivery#
Deliveries follow Standard Webhooks, which the draft builds on. Each one is a POST with three headers, and the signature is an HMAC-SHA256 over the id, the timestamp and the exact bytes of the body:
public static function signature(string $secret, string $webhookId, string $timestamp, string $body): string
{
$key = base64_decode(substr($secret, strlen('whsec_')), true);
return 'v1,'.base64_encode(hash_hmac('sha256', "{$webhookId}.{$timestamp}.{$body}", $key, true));
}
The body looks like this:
{
"eventId": "evt_01k9x3m6q8...",
"name": "request.completed",
"timestamp": "2026-11-11T21:14:03Z",
"data": {
"tunnel": "shop.eu-1.sharedwithexpose.com",
"request_id": "b7c1...",
"started_at": "2026-11-11T21:14:03Z",
"method": "POST",
"path": "/stripe/webhook",
"status": 500,
"duration_ms": 182,
"request_content_type": "application/json",
"response_content_type": "text/html"
},
"cursor": null
}
A few details that are easy to get wrong:
- Serialize once. The event is turned into JSON when it is created, and that string is stored in the job, signed and sent. Encoding it again for a retry could produce different bytes and a different signature.
- Sign every attempt again. Receivers reject old timestamps, so a retry gets a fresh timestamp and signature. It keeps its
webhook-id, which is what receivers deduplicate on. - The webhook id is the event id. The clients we tested require
webhook-idto equaleventId, so each delivery gets one id that serves as both. - Retry, then give up. Failed deliveries are retried after 30, 90, 240 and 480 seconds with some jitter, about 14 minutes in total. A
410means the receiver doesn't want the event, and a413would fail the same way again, so neither is retried. - Suspend dead endpoints. When every delivery to a subscription has failed for an hour, it is suspended. The next refresh reactivates it and reports what went wrong in
deliveryStatus, with a category such astimeout,tls_errororhttp_5xx, so the client learns about the problem from the server instead of from silence.
request.completed has no replay. Our tunnel servers keep recent requests in memory only, so there is no log to resume from, and cursor is always null. An event that can't be delivered within those 14 minutes is gone, and that is fine for "tell me when something fails".
Building against a draft#
SEP-3415 is not final, and the one client we could test against was built on an earlier revision. A few places where we follow the client in front of us instead of the text:
- The capability is declared twice, as shown above.
- The SEP's error numbers are provisional. ChatGPT expects
-32015when the callback endpoint fails verification, where the SEP has-32027. events/unsubscribesucceeds when there is no such subscription, as ChatGPT expects. The SEP would answer with a not-found error.- The SEP requires an
Mcp-Nameheader on these requests. Clients that predate it don't send one, so we check it when it is there and don't insist on it.
Each of these is a comment in the code that says which behaviour comes from where, so we can remove them one by one as the draft settles.
What we would tell someone building this#
- Write the event description like a tool description. The model decides when to subscribe from that text alone. Ours names the trigger phrase, the filter to prefer and the rate limit.
- Push summaries, pull details. Keep payloads small and free of sensitive content, and let the agent fetch the rest through a tool that has proper authorization.
- Tie subscriptions to identities, not names. Anything that can be reassigned, such as a subdomain, needs an owner check at delivery time.
- Treat the callback URL as hostile. Verify it wants the events, pin the address, don't follow redirects.
- Be honest about loss. Rate limits and failed deliveries will drop events. Say so in the next response.
Try it#
Connect the Expose MCP server at https://expose.dev/mcp to ChatGPT, share a site with expose share http://localhost:3000, and ask it to tell you when a request to your tunnel fails. The MCP Events page has the full reference for filters, payload and limits, and Expose for agentic engineering shows how coding agents use the same data through tools.