@gtmi/mcp-status-callback
v2.1.0
Published
Public HTTPS callback URLs for local development via a Twilio-account-authenticated WSS relay. No ngrok, no local HTTP listener.
Readme
@gtmi/mcp-status-callback
Public HTTPS callback URLs for local development via a Twilio-account-authenticated WSS relay. No ngrok, no local HTTP listener.
The client opens an outbound WSS to a callback relay (see packages/relay for the reference deploy) and receives a public URL of the shape:
https://<relayHost>/callback/<AccountSid>/<sqid>
https://<relayHost>/callback/<AccountSid>/named/<subscriptionName>Twilio (or any webhook source) POSTs to that URL; the relay forwards the request as a JSON frame over the open WS, the client invokes your onCallback, and the relay returns 200 to Twilio after you ack.
Installation
pnpm add @gtmi/mcp-status-callbackRequirements
- Node.js 22 or higher
- A Twilio Account SID + API Key + Secret
- A running relay. By default the client targets the reference relay at
callback-relay.fly.dev, which is access-controlled — only allowlisted Twilio accounts may connect. If your account isn't allowlisted you'll get a403on connect; deploy your own relay (see docs/FLY-DEPLOY.md) and pointMCP_CALLBACK_RELAY_HOSTat it.
Usage
import { CallbackHandler } from '@gtmi/mcp-status-callback';
const handler = new CallbackHandler({
twilioAccountSid: process.env.TWILIO_ACCOUNT_SID!,
twilioApiKey: process.env.TWILIO_API_KEY!,
twilioApiSecret: process.env.TWILIO_API_SECRET!,
subscriptionName: 'sms-status', // optional — omit for ephemeral mode
logger: console,
onCallback: async ({ queryParameters, body }) => {
// handle callback (async allowed — the 200 to Twilio waits for you)
},
});
const url = await handler.start();
// e.g. https://callback-relay.fly.dev/callback/AC.../named/sms-statusUsing an OAuth Bearer token instead of an API Key
If you already authenticate to the Twilio REST API with an Account OAuth token, you can reuse
it here instead of provisioning a separate API Key — pass a token-provider function via
authorization and omit twilioApiKey/twilioApiSecret:
const handler = new CallbackHandler({
twilioAccountSid: process.env.TWILIO_ACCOUNT_SID!,
authorization: async () => 'Bearer ' + (await provider.getToken()),
onCallback: async ({ queryParameters, body }) => {
// handle callback
},
});authorization is called fresh on every connect and reconnect, so returning a token from your
own refresh logic keeps the WSS session authenticated across reconnects without any extra
plumbing in this package. The client never mints or refreshes tokens itself — that stays with
whatever already manages your Twilio OAuth credential (e.g. the twilio SDK's
ClientCredentialProvider).
Using it inside an MCP server
This is the primary use case. An MCP server running on your laptop needs a public URL so Twilio can POST status callbacks (delivery receipts, call events, …) back to it. The subtlety is timing: a tool call like "send an SMS" returns immediately — the message is only queued — but the delivery status arrives seconds to minutes later. So you don't return the status from the tool; you collect callbacks as they arrive and feed them back to the model through an MCP resource (or a resource-updated / logging notification).
LLM / MCP client
│ (calls your tool, e.g. "send an SMS")
▼
┌─────────────────────┐ ① start() ⇒ public URL ┌──────────────────────┐
│ │ ───────────────────────────▶ │ callback-relay │
│ your MCP server │ outbound WSS (open) │ (Fly.io) │
│ • CallbackHandler │ ◀─────────────────────────── │ │
│ • onCallback() │ ④ callback frame over WSS │ pairs POSTs ⇆ WSS │
└─────────┬───────────┘ └──────────▲───────────┘
│ ② messages.create({ statusCallback: url }) │
▼ │ ③ POST status
┌──────────┐ │ (queued→delivered)
│ Twilio │ ───────── sends SMS, then POSTs status ─────────┘
└──────────┘
⑤ onCallback stores each event → your server exposes it back to the model
(e.g. an MCP resource: twilio://status-callbacks/recent)import { CallbackHandler } from '@gtmi/mcp-status-callback';
import twilio from 'twilio';
const twilioClient = twilio(process.env.TWILIO_API_KEY!, process.env.TWILIO_API_SECRET!, {
accountSid: process.env.TWILIO_ACCOUNT_SID!,
});
// A small buffer of recent callbacks the server exposes back to the model.
const recent: unknown[] = [];
const callbacks = new CallbackHandler({
twilioAccountSid: process.env.TWILIO_ACCOUNT_SID!,
twilioApiKey: process.env.TWILIO_API_KEY!,
twilioApiSecret: process.env.TWILIO_API_SECRET!,
subscriptionName: 'twilio-mcp', // stable URL across restarts
onCallback: async ({ body }) => {
recent.unshift(body); // e.g. { MessageStatus: 'delivered', MessageSid: 'SM…' }
recent.length = Math.min(recent.length, 50);
},
});
// ① Open the WSS once on startup and keep the public URL.
const callbackUrl = await callbacks.start();
// ② A tool that sends an SMS, wiring Twilio's statusCallback to our URL.
server.registerTool('send_sms', schema, async ({ to, from, body }) => {
const msg = await twilioClient.messages.create({ to, from, body, statusCallback: callbackUrl });
return { content: [{ type: 'text', text: `queued ${msg.sid}` }] };
});
// ③–⑤ Twilio POSTs each status → relay → onCallback → `recent`.
// Surface the async results back to the model as a resource it can read on demand.
server.registerResource('twilio://status-callbacks/recent', async () => ({
contents: [{ uri: 'twilio://status-callbacks/recent', text: JSON.stringify(recent, null, 2) }],
}));The
server.registerTool/registerResourcecalls are illustrative — use your MCP SDK's real API. The wiring is the point:start()once → putcallbackUrlon your Twilio calls → collectonCallbackevents → hand them back through a resource or notification (never straight out of the tool call, since the status lands long after the tool returns).
API
new CallbackHandler(options)
twilioAccountSid(required) — Twilio AccountSid (AC...).twilioApiKey— Twilio API Key SID (SK...). Required unlessauthorizationis provided.twilioApiSecret— Twilio API Key Secret. Required unlessauthorizationis provided.authorization(optional) —() => string | Promise<string>. Returns the fullAuthorizationheader value (e.g."Bearer eyJ…"). Invoked fresh on every connect/reconnect. Takes precedence overtwilioApiKey/twilioApiSecretwhen present.subscriptionName(optional) — Stable name. Sets the URL to.../named/<name>and survives reconnects. Omit for ephemeral (fresh sqid per session).relayHost(optional) — Host of the callback relay. Falls back toprocess.env.MCP_CALLBACK_RELAY_HOST, thencallback-relay.fly.dev(the reference relay, access-controlled to allowlisted accounts). Point this at your own relay if you self-host. Do not include a scheme.onCallback(required) —(data: CallbackData) => void | Promise<void>. Awaited before the relay is acked, so throwing/hanging is visible upstream.logger(optional) —{ info, warn, error }. Defaults to a silent no-op logger.
Methods
start(): Promise<string>— Opens the WSS to the relay. Resolves to the public callback URL from the relay's hello frame.stop(): Promise<void>— Closes the WS and stops any pending reconnect.getPublicUrl(): string | null— Current public URL, ornullif not started.
Types
CallbackData—{ queryParameters: Record<string, unknown>; body: unknown }Logger—{ info(msg): void; warn(msg): void; error(msg | Error): void }
Behavior
- Reconnect. If the relay drops the WS, the client reconnects with exponential backoff up to 30s. Ephemeral mode gets a new sqid (URL changes); named mode reclaims the same URL.
- Body normalization.
application/x-www-form-urlencodedbodies (Twilio's default) are normalized to a plain JSON object on the relay side before the frame is forwarded.bodyis always a JSON object. - Async-aware ack. If your
onCallbackreturns a Promise, the ack frame (and Twilio's 200) waits for it to resolve. Throwing sends{ ok: false, error }and the relay returns 500 to Twilio.
Migrating from v1.x (ngrok)
- Remove
NGROK_AUTH_TOKEN/NGROK_CUSTOM_DOMAINfrom your env. - Add
TWILIO_ACCOUNT_SID,TWILIO_API_KEY,TWILIO_API_SECRET. - Constructor field renames:
ngrokAuthToken→twilioAccountSid+twilioApiKey+twilioApiSecret;customDomain→subscriptionName(semantically closer — a stable URL you control). onCallbackandloggerare unchanged.- If you can't migrate, the
1.xline stays on npm and its source lives atdeshartman/mcp-status-callback.
Credits
The move from an ngrok tunnel to a Twilio-authenticated relay is inspired by the internal Twilio project cai-relay. Credit to that team for the pattern this package builds on.
Contributing
From the repo root:
pnpm install
pnpm typecheck
pnpm testSmoke run against a live relay:
pnpm dlx tsx packages/client/examples/smoke.tsLicense
MIT
