mcp-events-outpost-demo
OfficialEnables ChatGPT to subscribe to MCP Events webhooks from the server, so it can receive and react to events like order.created without a user in the loop. Handles subscription verification, filtering, signing, delivery, and retries via Hookdeck Outpost.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-events-outpost-demosubscribe me to order.created events over $100"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
MCP Events with Hookdeck Outpost
A working demo of an MCP server that sends MCP Events webhooks, with Hookdeck Outpost doing the delivery.
MCP Events is a draft MCP extension that lets an agent subscribe to things happening behind an MCP server, so it can react without a user in the loop. ChatGPT is the first real subscriber, and it uses webhook delivery only (OpenAI's implementer guide). This repo is for MCP server builders who want to offer MCP Events without building webhook delivery themselves.
Status: demo code, not production-ready. Tested end to end against managed Outpost, and with ChatGPT as the subscriber, on 2026-10-01. See Known issues and What's demo-only.

How it works
An agent subscribes. It calls
events/subscribeon the MCP server with the event it wants (order.created), optional filters (orders of 100 USD or more), a callback URL, and a signing secret it chose.The server checks the agent wants the deliveries. It POSTs a signed challenge to the callback URL, and the agent echoes it back.
The server hands the subscription to Outpost. It creates an Outpost webhook destination with the callback URL, the secret, and a filter built from the subscription's arguments.
Something happens. When an order is placed, the server publishes one event to Outpost.
Outpost delivers it. Outpost filters, signs (Standard Webhooks), delivers, and retries. The agent verifies the signature and acts.
The agent refreshes its subscription before it expires, and unsubscribes when it's done. The server deletes the destination when either happens or the subscription lapses.
sequenceDiagram
participant A as Agent (subscriber)
participant S as MCP server (this repo)
participant O as Hookdeck Outpost
A->>S: 1. Subscribe to order.created, total >= 100 USD
S->>A: 2. Signed challenge
A-->>S: Challenge echoed
S->>O: 3. Create a destination for the subscription
Note over S: An order is placed
S->>O: 4. Publish order.created
O->>A: 5. Signed webhook (filtered, retried)The quickstart uses a test subscriber (src/client/) as the agent. ChatGPT works as the subscriber too: see Try it with ChatGPT.
Related MCP server: hookray-mcp
Quickstart
Prerequisites
Node.js 22 or later.
A Hookdeck account with a managed Outpost project, and an API key from the project's Settings > Secrets.
1. Install and configure
npm install
cp .env.example .env # then set OUTPOST_API_KEY2. Prepare your Outpost project
npm run outpost:check -- --applyThis turns on Standard Webhooks signing (DESTINATIONS_WEBHOOK_MODE=standard), adds the order.created topic, and sets a retry schedule within MCP Events' guidance if you don't have one. These are project-wide settings, so use a project you don't share with other webhook traffic. Run it without -- --apply to check without changing anything.
3. Run it
Outpost delivers from the internet, so the subscriber's receiver needs a public URL. The tunnel provides one. Use four terminals:
npm run tunnel # 1. public URL for the receiver
npm run server # 2. the MCP server
npm run client -- --min-total 100 --currency USD # 3. the test subscriberThen place some orders in a fourth terminal:
npm run order -- --total 150 --currency USD # delivered
npm run order -- --total 20 --currency USD # filtered out by Outpost
npm run order -- --total 500 --currency EUR # filtered out by OutpostThe client output looks like this:
[client] receiver listening on :4000, callback URL https://<random>.trycloudflare.com/mcp-events/...
[client] connected, protocol 2026-07-28
[client] events/list offers: order.created
[client] answered verification challenge (msg_verification_...)
[client] subscribed sub_..., refreshBefore 2026-10-01T18:33:11.306Z
[client] waiting for events. Place an order with `npm run order`. Ctrl+C to unsubscribe and exit.
[client] event order.created evt_ord_..._...: {"orderId":"ord_...","total":150,"currency":"USD",...}Only the 150 USD order arrives. You can also see the destination in your Outpost project, under tenant mcp_alice.
4. Stop
Press Ctrl+C in the client. It unsubscribes, and the server deletes the destination. Then stop the tunnel and the server.
Next: use ChatGPT as the subscriber
To have ChatGPT subscribe instead of the test client, follow Try it with ChatGPT. It takes a ChatGPT Plus account (or above) with Developer mode, and a second tunnel for the MCP server.
Troubleshooting
subscribe failedwith code-32602andhttps_required. The client found no tunnel URL, so it usedhttp://localhost:4000. Startnpm run tunnelbefore the client.subscribe failedwith code-32015. The server couldn't complete the verification challenge through the tunnel;data.reasonsays why (for examplehttp_5xxorconnection_refused). Check the tunnel is still running. If it was killed without exiting cleanly,.tunnel-urlmay point at a dead URL: delete it and restart the tunnel.outpost:checkreports settings that need changing. Run it with-- --apply, or set them in the Hookdeck dashboard (see Outpost project setup).Subscribed, but no events arrive. Check the order matches the filters you subscribed with, and that the project is in Standard mode with the
order.createdtopic.OUTPOST_API_KEY is not set. Copy.env.exampleto.envand set the key.
Going deeper
Architecture: components and the full message flow.
How MCP Events maps onto Outpost and Tenancy and publishing: the design choices.
Configuration: Outpost project setup, client options, environment variables.
Outpost: what it handles and what's open and What was verified: what this demo taught us about Outpost.
Try it with ChatGPT: steps, results, and what ChatGPT actually sends.
Architecture
src/server/: the MCP server (TypeScript, official MCP SDK v2, Streamable HTTP). One event type,order.created, from a fake demo store.src/client/: a test subscriber. An MCP client plus a webhook receiver that answers verification challenges and verifies deliveries with thestandardwebhookslibrary.test/: unit tests and an end-to-end test that runs the whole flow offline against a mock Outpost.
flowchart TB
subgraph Subscriber["Test subscriber (src/client)"]
MC[MCP client]
RX[Webhook receiver]
end
subgraph Server["MCP server (src/server)"]
MCP["/mcp<br/>events/list, events/subscribe,<br/>events/unsubscribe"]
SUB[Subscription service<br/>verification, TTL sweeper]
STORE[(Local JSON store)]
DEMO["/demo/orders<br/>fake store"]
end
subgraph Outpost["Hookdeck Outpost (managed)"]
T[Tenant per principal]
D[Webhook destination<br/>per subscription]
end
MC -- "Bearer token, MCP 2026-07-28" --> MCP
MCP --> SUB
SUB --> STORE
SUB -- "1. signed verification challenge" --> RX
SUB -- "2. admin API: upsert tenant + destination" --> T
T --- D
DEMO -- "3. publish order.created per tenant" --> Outpost
D -- "4. Standard Webhooks delivery<br/>(filter, retries, signing)" --> RXThe full message flow, including refresh and unsubscribe:
sequenceDiagram
autonumber
participant C as Subscriber
participant S as MCP server
participant O as Outpost
C->>S: events/subscribe {name, arguments, delivery: {url, secret}, ttlMs}
S->>C: POST {"type":"verification","challenge":"..."} (signed with the client's secret)
C-->>S: 200 {"challenge":"..."}
S->>O: PUT /tenants/mcp_<principal>
S->>O: POST /tenants/{t}/destinations (id = subscription id, secret, filter, X-MCP-Subscription-Id header)
S-->>C: {id, refreshBefore, cursor: null, truncated: false}
Note over S,O: an order is placed
S->>O: POST /publish {id: eventId, tenant_id, topic: order.created, data: MCP event envelope}
O->>C: POST {eventId, name, timestamp, data, cursor} + webhook-id/-timestamp/-signature + X-MCP-Subscription-Id
C-->>O: 200
C->>S: events/subscribe (same key, before refreshBefore, optional new secret)
S->>O: PATCH destination (expiry metadata, plus secret and previous_secret on rotation)
C->>S: events/unsubscribe {name, arguments, delivery: {url}}
S->>O: DELETE destinationHow MCP Events maps onto Outpost
MCP Events | Outpost |
Authenticated principal | Tenant |
Webhook subscription | Webhook destination whose |
Subscription id ( | Destination |
|
|
|
|
Secret rotation on refresh |
|
Event name | Topic |
Subscription | Destination |
Event occurrence |
|
| Outpost's Standard Webhooks mode sends the event id as |
Retries with a fresh timestamp and signature per attempt | Outpost retries |
| Derived from the destination's |
Outpost sends the published data as the HTTP body, unchanged. So the server publishes the whole MCP envelope {eventId, name, timestamp, data, cursor} as Outpost's data, and the delivered body is exactly what MCP Events expects. The catch is that destination filters see the envelope, so the order fields sit under data.data:
{ "data": { "data": { "total": { "$gte": 100 }, "currency": "USD" } } }The server does the parts Outpost doesn't: the verification handshake, callback URL checks, deterministic ids and idempotent upsert, and expiring subscriptions (see What your MCP server handles).
Tenancy and publishing
Outpost's publish API takes exactly one tenant_id, and each principal is its own tenant. So when an order is placed, the demo store publishes one copy per tenant that has a live order.created subscription, and Outpost fans each copy out to that tenant's matching destinations, applying their filters. Outpost's idempotency key is the event id across the whole project, so each tenant's copy gets its own eventId (evt_<orderId>_<tenant hash>).
The more Outpost-native alternative is a single tenant for the whole MCP server, so one publish fans out to every subscription. That makes publishing O(1), but it puts every principal's subscriptions in one tenant, so you lose per-principal isolation, the tenant portal, and per-tenant metrics, and you run into MAX_DESTINATIONS_PER_TENANT (20 by default, in the open source build and on a new managed project; managed exposes it in the Config API). One tenant per principal is the better fit for a multi-user server like one ChatGPT connects to, and the per-tenant loop is cheap.
MCP protocol version and SDK
SDK:
@modelcontextprotocol/server2.2.0,@modelcontextprotocol/client2.2.0,@modelcontextprotocol/node2.1.0. This is the v2 line of the official TypeScript SDK, which replaces the monolithic@modelcontextprotocol/sdkpackage. The latest v1 (@modelcontextprotocol/sdk1.31.0) does not serve the 2026-07-28 revision.Protocol: the server serves 2026-07-28 (what ChatGPT requires) through the SDK's
createMcpHandler, which also answersserver/discover. 2025-era clients fall back to the SDK's stateless legacy serving. The end-to-end test asserts that the test client negotiates2026-07-28.The SDK has no MCP Events support. The server declares
capabilities.events(cast, because the SDK's capability type doesn't know it) and registersevents/list,events/subscribe, andevents/unsubscribeas custom request handlers. On the client side the SDK's typedgetServerCapabilities()drops the unknowneventskey, so the test client readsserver/discoverdirectly.
Configuration
Outpost project setup
npm run outpost:check -- --apply does steps 2, 3, and 5 for you. To do them by hand:
API key. In the Hookdeck dashboard, open your Outpost project and go to Settings > Secrets. Create or copy an API key. This is
OUTPOST_API_KEY.Topic. Add
order.createdto the project's topics.Standard Webhooks mode. Set
DESTINATIONS_WEBHOOK_MODEtostandardin Hookdeck Destinations settings. LeaveDESTINATIONS_WEBHOOK_HEADER_PREFIXunset (Standard mode defaults it towebhook-), and leave theDESTINATIONS_WEBHOOK_DISABLE_DEFAULT_*options off.Check it.
npm run outpost:checkreads the managed Config API (GET /config) and reports both settings. With-- --applyit sets the ones that are missing (it appendsorder.createdto your existing topics rather than replacing them).Retry schedule. MCP Events suggests 3 to 5 attempts over no more than 10 to 15 minutes. Outpost's
RETRY_SCHEDULEsetting is a list of delays in seconds, and its length sets the number of retries.outpost:check -- --applysets30,120,600(4 attempts within about 12.5 minutes) if no schedule is set. Without one, the managed default allows up to 10 retries.
API base URL: https://api.outpost.hookdeck.com/2025-07-01, authenticated with Authorization: Bearer <API key>. It's configurable with OUTPOST_API_BASE_URL (for example, to point at a self-hosted Outpost).
The tunnel
npm run tunnel uses the cloudflared package (a dev dependency), which downloads the cloudflared binary on first run. A quick tunnel needs no Cloudflare account and gets a random https://<random>.trycloudflare.com URL each time. The script writes that URL to .tunnel-url (gitignored) and removes it on exit. npm run client uses it when PUBLIC_CALLBACK_URL is empty, so there's nothing to copy. Set PUBLIC_CALLBACK_URL only to use a different public URL.
The MCP server itself stays on localhost; only the receiver needs to be public. To expose another port instead, for example the MCP server so ChatGPT can connect to it, pass --port: npm run tunnel -- --port 3000 prints the /mcp URL and doesn't touch .tunnel-url. Run two tunnels to expose both.
Client options
Flag | Effect |
| Subscription arguments (filters) |
| Suggested TTL. The server grants between |
| Generate a new secret on every refresh, to watch dual-signed deliveries |
| Accept deliveries older than 5 minutes (signatures are still verified) |
| Don't unsubscribe on exit, to watch the sweeper remove the destination after the TTL |
| Log every |
Environment variables
Everything is documented in .env.example. MCP_TOKENS maps demo bearer tokens to principals (dev-token-alice=alice,dev-token-bob=bob).
Without Outpost reaching you
With ALLOW_LOCAL_CALLBACKS=true and no tunnel, the client uses http://localhost:4000/... as its callback. Verification works (the server can reach localhost), but managed Outpost can't deliver to your laptop. Use this only with the mock Outpost, which is what the tests do.
Tests
npm test # vitest: unit + end-to-end, offline
npm run typecheck # tsc --noEmittest/secret.test.ts:whsec_validation (24 to 64 bytes, strict base64) and generation.test/identity.test.ts: subscription id derivation (deterministic, key-order-insensitive) and tenant ids.test/signing.test.ts: signatures interoperate with thestandardwebhookslibrary, including dual signatures during rotation, tampering, the 5-minute freshness window, and matching each signature entry to its secret.test/callback.test.ts: SSRF rules (special-purpose IPv4 and IPv6 ranges, https-only, connect-time blocking of loopback), and verification (echo, wrong echo, 4xx, 5xx, redirects not followed, timeout, connection refused).test/events.test.ts: argument validation and argument-to-filter mapping, evaluated the way Outpost evaluates filters.test/tunnel-url.test.ts: the.tunnel-urlfile the client reads whenPUBLIC_CALLBACK_URLis empty.test/e2e.test.ts: the MCP server, the test subscriber, andtest/mock-outpost.ts(a small HTTP server implementing the Outpost endpoints the demo calls, which signs and delivers like Outpost's Standard Webhooks mode, retries every non-2xx, and evaluates filters). It proves subscribe, challenge, publish, signed delivery, verification, filtering, dedup, forged and stale rejection, refresh with secret rotation and dual-signing, unsubscribe, the sweeper, idempotent upsert, and the JSON-RPC error codes (-32602,-32011,-32014,-32015).
Known issues
Secret rotation on a busy destination. On managed Outpost, if a destination gets a delivery at least once a minute, deliveries keep being signed with the old secret after a rotation, until the destination is idle for a minute. A subscriber that rotates twice in that time stops accepting deliveries. Fixed in Outpost by #1085, not yet released or deployed to managed. Details in Open Outpost issues, item 2.
What's demo-only
Bearer token auth.
MCP_TOKENSmaps static tokens to principals, for the test client. A real server uses OAuth, with the token's subject as the principal (see Auth: what this demo skips).ANONYMOUS_PRINCIPAL. Lets requests with no token act as one fixed principal, for probing clients that don't do OAuth yet. Anyone who can reach the server can then subscribe as that principal. Leave it empty outside a short test.Local JSON store (
data/subscriptions.json). Fine for one process. It holds subscription metadata and a hash of each secret; the secrets themselves live only in Outpost. A real server would use a database.ALLOW_LOCAL_CALLBACKS. Allowshttp://and private or loopback callback addresses. Never enable it on a server that is reachable from the internet.No replay.
cursoris alwaysnulland missed events can't be recovered through the protocol. If a client supplies a non-null cursor the server returnstruncated: true.No
ttlMs: nullgrants. A request for no expiry gets the default finite TTL, which the sketch allows./demo/ordersis unauthenticated and the orders live in memory.The verification cache is in memory, so a restart re-verifies on the next subscribe. Verification POSTs are not rate-limited per host.
Outpost: what it handles and what's open
Running MCP Events on Outpost splits the work three ways: what Outpost does as the delivery layer, what the platform running Outpost provides, and what the MCP server does because it's specific to MCP Events. Only the last group of items below are asks of Outpost itself. Findings come from Outpost's source (main as of 2026-10-01), its OpenAPI spec, and live tests against a managed project (see What was verified).
What Outpost handles
Signing: Standard Webhooks mode with admin-set
whsec_secrets,webhook-idequal to the event id and stable across retries, and a fresh timestamp and signature on every attempt.Secret rotation: dual-signing with
previous_secret(v1,<new> v1,<old>) untilprevious_secret_invalid_at(but see the rotation issue below).Filtering: destination filters evaluate the subscription's
arguments, so non-matching events are never sent.Per-subscription destinations: caller-chosen destination ids (the subscription id) and per-destination custom headers (
X-MCP-Subscription-Id).Retries and logs: a configurable retry schedule, and delivery attempts you can query and inspect in the dashboard.
Outpost also sends webhook-topic (and any publish metadata) as headers. That's harmless: the MCP delivery profile only requires the four headers it lists.
What your MCP server handles
These are specific to MCP Events or easy for the server to own. The demo implements each one in src/server/subscriptions.ts and src/server/callback.ts.
The endpoint verification challenge. MCP Events requires proving the endpoint wants deliveries before activating a subscription, and the challenge format and echo rules are MCP-specific. The server also has to return
-32015 CallbackEndpointErrorsynchronously fromevents/subscribe. So the server sends the signedverificationchallenge itself and only then creates the destination.Subscription expiry. Subscriptions are TTL-scoped and refreshed by the client. The server runs a sweeper (
SWEEP_INTERVAL_MS, 30 seconds by default) that deletes expired destinations. An expired subscription can still receive an event until the sweep runs; a destinationexpires_atin Outpost would close that window, but it isn't needed.Fan-out across principals. Outpost's publish API takes one
tenant_idand its event ids are idempotency keys across the whole project, so the server publishes one copy per tenant with a live subscription, each with its own event id (see Tenancy and publishing).deliveryStatuson refresh. Assembled from the destination'sdisabled_atand its latest attempt. IfALERT_AUTO_DISABLE_DESTINATIONis on, a refresh re-enables the destination, which matches the spec's "a successful refresh reactivates delivery".Callback URL checks at subscribe time (https only, public addresses, no redirects) on the subscribe and verification requests.
Poll mode, if you want it. Outpost only stores events that matched a destination, so an
events/pollimplementation should keep its own event log. ChatGPT doesn't use poll, and this demo doesn't implement it.
What the platform running Outpost handles
Delivery-time SSRF protection. MCP Events wants the delivery path to block non-public addresses at connect time and never follow redirects. An MCP server can't enforce that for connections Outpost makes, and Outpost's own webhook client follows redirects (Go's default, up to 10) and has no private-address blocklist. The fix is egress infrastructure: route deliveries through an SSRF-filtering proxy. Outpost #1100 (merged 2026-09-30) adds
DESTINATIONS_PROXY_URL, an HTTP CONNECT proxy for webhook, RabbitMQ, and Kafka destinations, and reports a proxy deny (for example an Envoy RBAC rule acting as the egress SSRF gate) as anetwork_unreachableattempt. It replaces the webhook-onlyDESTINATIONS_WEBHOOK_PROXY_URL, now deprecated. #1100 isn't in a release yet (the latest is v1.5.0, 2026-09-23), so it isn't available on managed Outpost as of 2026-10-01.
Open Outpost issues
410and413are retried. Outpost retries every failed attempt while the retry budget lasts, whatever the status code (any status of 400 or above is a failed attempt, and the retry decision only checks the attempt count). MCP Events and OpenAI say410 Goneand413 Payload Too Largemust not be retried. Nothing outside Outpost can stop those retries, so this needs an option to treat some status codes as final.Rotated secrets aren't picked up by a busy destination. Found live on managed, root cause read in the source. Outpost's delivery path caches a publisher per destination, and the publisher holds the signing secrets. The cache key (
MakePublisherKeyininternal/destregistry/registry.go) hashes the destination id,config, andtype, but notcredentials. Entries live fordefaultPublisherTTL(1 minute), and every cache hit resets that minute (internal/lru/lru.go). So after a credentials-onlyPATCH, a destination that gets at least one delivery a minute keeps signing with the old secret, indefinitely:After a rotation, deliveries 15 and 35 seconds later carried one signature, from the previous secret only, although
GETreturned the newsecret,previous_secret, andprevious_secret_invalid_at.After the cache had been idle for over a minute, the next delivery was correctly dual-signed.
A subscriber that rotates again while the stale publisher is still warm stops accepting deliveries: neither of its two accepted secrets is the one Outpost is still using. The MCP server can't see this happening.
previous_secret_invalid_atitself is applied at signing time (destwebhook/signature.go), so the old signature does drop out on schedule once the publisher has the new credentials. This is hookdeck/outpost#1084, fixed onmainby #1085 (merged 2026-09-28, credentials now part of the key). The latest server release, v1.5.0 (2026-09-23), predates the fix, and managed Outpost still showed the bug on 2026-10-01. Until the fix is deployed, a server-side workaround would be to change something inconfigon rotation, such as a custom header carrying a secret version, to force a new cache key; the demo doesn't do this.API sharp edges around publish idempotency. Outpost matches destinations before checking the event id, which leaves two surprises (both reproduced on managed):
Publishing an event id already used (for any tenant) returns
202withduplicate: trueand isn't delivered, butdestination_idsstill lists the matching destinations. A caller reading onlydestination_idswould think it was delivered.A publish that matched no destination doesn't record its id, so publishing the same id again later returns
duplicate: false.
The managed version isn't exposed by the API. The dashboard shows it (v1.5.0 at the time of testing), but there's no way to check from code which fixes are live.
What was verified and what was assumed about managed Outpost
Verified from Outpost's OpenAPI spec, docs, and Go source:
Base URL
https://api.outpost.hookdeck.com/2025-07-01andAuthorization: Bearer <API key>(OpenAPIservers, the managed curl quickstart).Endpoints:
PUT /tenants/{id},POST/GET/PATCH/DELETE /tenants/{id}/destinations[/{id}],PUT .../enable,GET .../attempts,POST /publish(requirestenant_id),GET/PATCH /config(managed only).Admin keys can set
credentials.secret,previous_secret, andprevious_secret_invalid_at; tenants can't. Standard mode requireswhsec_+ base64.config.custom_headersis a JSON string; names must be letters, digits, hyphens, or underscores and can't overridecontent-type,content-length,host,connection, oruser-agent.Filters use
simplejsonmatchoperators ($gte,$eq,$in,$or, ...) against{id, topic, time, metadata, data}.Standard mode headers and signature:
webhook-id= event id,webhook-timestamp,webhook-signature: v1,<base64 HMAC of id.timestamp.body>, one entry per valid secret. Mode is set withDESTINATIONS_WEBHOOK_MODE=standardthrough the dashboard or Config API.Caller-supplied destination ids are accepted; a duplicate id returns 400 "destination already exists". Publish idempotency is keyed on the event id alone.
Verified live against managed Outpost (2026-10-01), using the admin API directly with throwaway tenants and the same destination payload the server builds. Deliveries went to an unresolvable .invalid host, so these checks cover the API and attempt records, not the bytes on the wire:
npm run outpost:check -- --applysetsDESTINATIONS_WEBHOOK_MODE=standardandTOPICS=order.createdthroughPATCH /config. A new project starts withdefaultmode and no topics.Tenant upsert (
PUTreturns201), destination create with a caller-chosensub_...id,config.custom_headersas a JSON string,metadata, and awhsec_secret thatGETreturns byte-for-byte. A duplicate id returns400 {"message":"destination already exists"}, which the server's idempotent upsert relies on.Filters. With
{"data":{"data":{"total":{"$gte":100},"currency":"USD"}}},POST /publishreturned the destination indestination_idsfor 150 USD and for exactly 100 USD, and returned[]for 20 USD and 500 EUR. Filtered-out events create no attempts. Same behavior as the mock.Rotation storage.
PATCHwithcredentials: {secret, previous_secret, previous_secret_invalid_at}is accepted, andGETreturns all three.Attempt ordering.
GET .../attemptsdefaults toorder_by: time, dir: desc, and?limit=1returns the newest attempt. Attemptcodeis a string such asdns_errororconnection_refused(an HTTP status for HTTP failures). The server maps codes it doesn't recognize, includingdns_error, toconnection_refused.Retries. With
eligible_for_retry: trueand the managed defaults (RETRY_INTERVAL_SECONDS=30,MAX_RETRY_LIMIT=10, noRETRY_SCHEDULE), the second attempt came 30 seconds after the first (later attempts weren't observed). Up to 10 retries is more than the 3 to 5 attempts MCP Events suggests, so setRETRY_SCHEDULE(see Outpost project setup, step 5).Destination limit. A new managed project has
MAX_DESTINATIONS_PER_TENANT=20. Destination 21 returns400 {"message":"maximum number of destinations per tenant reached"}. That matches the server's/maximum number of destinations/icheck, so a principal over the limit gets-32013 ResourceExhaustedas intended (not the generic 500 the open source handler suggested).Project-wide event ids: see Open Outpost issues, item 3.
Verified end to end on managed (2026-10-01), with the MCP server on localhost and the test subscriber's receiver behind a cloudflared quick tunnel, following the Quickstart:
The client negotiated
2026-07-28, the server's verification challenge reached the receiver through the tunnel, and the subscription was created.Standard mode delivery. Deliveries passed the
standardwebhookslibrary's signature and 5-minute freshness checks.X-MCP-Subscription-Idfromcustom_headersmatched the subscription,webhook-idequaled the body'seventId, and the body parsed as the published envelope.Filters on the wire. 150 USD was delivered; 20 USD and 500 EUR returned
destination_ids: []and were never sent.deliveryStatuson refresh carriedlastDeliveryAtfrom the attempts API.Rotation, with
SECRET_ROTATION_GRACE_MS=120000,--rotate-secret, and--debug: Outpost sendsv1,<current> v1,<previous>(current first) during the grace window, and onlyv1,<current>afterprevious_secret_invalid_at, including from a warm cached publisher. But a busy destination doesn't pick up the rotation at all until its cached publisher expires (Open Outpost issues, item 2).
Still assumed:
Delivery-time SSRF behavior on managed Outpost (see What the platform running Outpost handles).
Retry timing beyond the second attempt, and
410/413handling on managed (Open Outpost issues, item 1, is from the source).More generally, that the rest of managed Outpost behaves like the local source checkout (last commit July 2026).
Where the spec and OpenAI's guide differ
The demo follows OpenAI's guide where they disagree:
Capability shape. The sketch shows
"events": {"listChanged": true}; OpenAI shows"events": {}in theserver/discoverresponse. This server advertises{}and does not sendlist_changed.Unsubscribing an unknown subscription. The sketch says
-32011 NotFound; OpenAI says unsubscribe is idempotent and returns{}. This server returns{}.Persistence. The sketch lets short-TTL servers keep subscriptions in memory; OpenAI asks servers to retain them across restarts. This server persists metadata to disk, and Outpost holds the destinations.
Control envelopes. ChatGPT doesn't support
gaporterminated, and this server doesn't send them.delivery.modeon unsubscribe. OpenAI includes it, the sketch doesn't; both are accepted.
Try it with ChatGPT
ChatGPT can subscribe to this server's order.created event, receive deliveries from Outpost, and run a task for each one. This was tested on 2026-10-01 with a ChatGPT Plus account, using developer mode and No Authentication, so it's a dev-only setup: anyone with the tunnel URL can use the server. See Auth: what this demo skips for what a real server needs.
Steps
Start the server with the anonymous principal and request logging:
ANONYMOUS_PRINCIPAL=chatgpt LOG_MCP_REQUESTS=true npm run serverExpose it:
npm run tunnel -- --port 3000. Copy the/mcpURL it prints.In ChatGPT, go to Plugins, choose Add > Create MCP App, paste the URL, choose No Authentication, and create it. This needs Developer mode (Plus or above). OpenAI's docs place the toggle under Settings > Security and login, but it's been seen under Settings > Plugins, at the bottom of the page. If Create MCP App appears in the Add menu, it's already on.
Start a Work chat (MCP Events don't run in plain chats), type
@, pick the app, and ask it to subscribe, for example: "Subscribe to new orders of 100 USD or more. When one arrives, summarise the order in one sentence." ChatGPT may ask for an existing order ID first; create one withnpm run order -- --total 50 --currency USDand give it theorderId.
The subscription becomes a task with the MCP event as its trigger:

Place orders with
npm run order. Each delivery shows in the Outpost dashboard under Deliveries (filter by tenantmcp_chatgpt), and the task runs appear under Scheduled in ChatGPT, not in the chat.

Delete the task in Scheduled to unsubscribe. Then stop the tunnel.
Results against OpenAI's checklist
Checklist step | Result |
| Called on connect, along with |
Subscribe from a Work chat |
|
Callback verification | The server's signed challenge was echoed; the Outpost destination was created in tenant |
Matching event delivered with a 2xx | 150 USD order: Outpost got |
ChatGPT acts as instructed | The task called |
Non-matching event not delivered | 20 USD order: filtered out by Outpost, never sent, no task run |
Stop monitoring | Deleting the task sent |
Not tested yet: refresh before refreshBefore (and across a server restart), duplicates, bursts and batching, revoked access, and invalid signatures.
What ChatGPT sends (not in OpenAI's docs)
Callback URL:
https://connectors.api.openai.com/webhook/mcp-events/<32 hex>.Secret: a
whsec_secret of 32 bytes.No
ttlMs: ChatGPT doesn't suggest a lifetime, so the server'sSUBSCRIPTION_DEFAULT_TTL_MSsets how often it has to refresh.cursor: nullon subscribe.No auth needed: MCP Events worked against a server connected with No Authentication. OpenAI's docs describe subscriptions in terms of an authenticated principal, but don't say this is required.
An access check before subscribing: ChatGPT wanted to call a tool with a real resource ID (
get_order) to "verify access" before activating the subscription, and asked the user for an order ID when it couldn't find one. The server doesn't require this; the model chose to.Work chats run through Codex: tool and event calls made from the Work chat carried the user agent
openai-mcp/1.0.0 (Codex).Results go to Scheduled: each delivery becomes a task run in ChatGPT's Scheduled view, with a link back to the chat.
Auth: what this demo skips
This demo connects ChatGPT with No Authentication, which is enough to show MCP Events and Outpost working together, and it's only safe while the server is short-lived and its URL isn't shared. A real MCP server needs OAuth, so that each user is their own principal (and, here, their own Outpost tenant) and access can be revoked.
What that involves:
The requirements. ChatGPT's plugin auth guide and the MCP authorization spec (2026-07-28): the MCP server is an OAuth resource server that publishes RFC 9728 protected resource metadata and validates token audience (RFC 8707); an authorization server issues the tokens and supports
S256PKCE; ChatGPT registers as a client through a Client ID Metadata Document (CIMD, preferred) or dynamic client registration (DCR).A stable URL. The public
/mcpURL becomes the token audience, so it can't change between runs. A quick tunnel won't do; use a static domain or a deployment.The resource-server side is small. The MCP SDK v2 has
requireBearerAuth, anOAuthTokenVerifierinterface, and helpers that serve the protected resource metadata. Verify JWTs against the authorization server's JWKS (for example withjose) and use the token'ssubas the principal, where this demo usesMCP_TOKENS.Choosing the authorization server is the real decision:
Run your own. For example, Cloudflare's
workers-oauth-providerin the same Worker as the MCP server, with CIMD enabled, which is how ChatGPT then registers. This avoids depending on a provider's CIMD and DCR support.Use a hosted identity provider. OpenAI's docs link Auth0 and Stytch. Check how the provider handles CIMD, DCR, and the RFC 8707
resourceparameter before committing. Auth0, for example, needs its Resource Parameter Compatibility Profile turned on, and supports CIMD clients only by manual import.
Project layout
src/
shared/ secret validation and generation, canonical JSON, Standard Webhooks signing, tunnel URL file
server/
index.ts entry point (npm run server)
app.ts HTTP routing: /mcp (bearer auth), /demo/orders, sweeper
mcp.ts MCP server: events capability, events/* handlers, get_order tool
subscriptions.ts subscribe, refresh, rotate, unsubscribe, sweep (Outpost-backed)
callback.ts callback URL rules (SSRF) and the verification challenge
outpost.ts minimal Outpost admin API client
events.ts event catalog and arguments-to-filter mapping
identity.ts subscription id, tenant id
demo-store.ts fake store that publishes order.created
store.ts JSON-file subscription store
errors.ts MCP Events JSON-RPC error codes
client/
index.ts entry point (npm run client)
subscriber.ts MCP client + webhook receiver
scripts/
order.ts npm run order
tunnel.ts npm run tunnel (dev quick tunnel to the receiver)
outpost-check.ts npm run outpost:check
test/ unit tests, end-to-end test, mock OutpostLicense
This server cannot be deployed
Maintenance
Related MCP Connectors
Webhooks for AI agents: send events, manage endpoints, inspect and retry deliveries.
- webhook.coOAuthco.webhook
Receive, inspect, replay and deliver webhooks — with signature verification and agent triggers.
Webhook URLs for AI agents: receive, wait for, replay, sign and verify webhooks (Stripe, GitHub…).
Zero-setup MCP gateway securely connecting AI to your tools with authentication and workflows
Related MCP Servers
- AlicenseBqualityDmaintenanceEnables seamless and secure connectivity between MCP hosts (like Claude Desktop, Cline, or Cursor) and productivity tools through WayStation's no-code integration hub.7156 npm63MIT
- AlicenseAqualityDmaintenanceEnables AI agents to create disposable webhook URLs, capture incoming HTTP requests, inspect headers and bodies, and replay them against local or remote endpoints, streamlining the webhook handler development loop.59 npmMIT
- AlicenseAqualityDmaintenanceEnables AI agents to create callback endpoints, wait for async webhook results, and verify signatures, eliminating the need for polling.832 npm2MIT
- AlicenseNot gradedqualityAmaintenanceA webhook management and delivery service with MCP tools for endpoints, deliveries, relay, and incoming webhooks, enabling autonomous agents to send, track, and relay webhooks.MIT