@codespar/mcp-twilio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/dist/index.js +424 -0
- package/package.json +36 -0
- package/server.json +44 -0
- package/src/index.ts +413 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @codespar/mcp-twilio
|
|
2
|
+
|
|
3
|
+
MCP server for [Twilio](https://www.twilio.com) — the global standard for programmable messaging and voice.
|
|
4
|
+
|
|
5
|
+
SMS, WhatsApp, and Voice across 180+ countries. Verify (2FA) and Lookup (phone validation) included. Fills the global messaging gap in a catalog otherwise tilted to Brazil-specific providers (Z-API, Take Blip, Zenvia, Evolution API).
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Purpose |
|
|
10
|
+
|------|---------|
|
|
11
|
+
| `send_message` | Send an SMS or WhatsApp message (prefix `To` with `whatsapp:+E164` for WhatsApp) |
|
|
12
|
+
| `get_message` | Retrieve a message by SID |
|
|
13
|
+
| `list_messages` | List messages with optional filters (To, From, DateSent) |
|
|
14
|
+
| `delete_message` | Delete a message from history |
|
|
15
|
+
| `make_call` | Place an outbound voice call driven by a TwiML `Url` |
|
|
16
|
+
| `get_call` | Retrieve a call by SID |
|
|
17
|
+
| `update_call` | Hang up or redirect an in-progress call |
|
|
18
|
+
| `start_verification` | Send a Verify (2FA) code via sms / whatsapp / call |
|
|
19
|
+
| `check_verification` | Check a Verify (2FA) code |
|
|
20
|
+
| `lookup_phone` | Validate + format + classify a phone number (Lookups v2) |
|
|
21
|
+
| `list_incoming_numbers` | List your Twilio-provisioned phone numbers |
|
|
22
|
+
| `buy_phone_number` | Provision a new phone number |
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @codespar/mcp-twilio
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Environment
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
TWILIO_ACCOUNT_SID="AC..." # required
|
|
34
|
+
TWILIO_AUTH_TOKEN="..." # required (secret)
|
|
35
|
+
TWILIO_MESSAGING_SERVICE_SID="MG..." # optional; default sender for send_message
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Authentication
|
|
39
|
+
|
|
40
|
+
HTTP Basic auth with `AccountSid:AuthToken`. The server handles this automatically — you only configure the env vars.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Authorization: Basic <base64(AccountSid:AuthToken)>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API surface
|
|
47
|
+
|
|
48
|
+
- Main Accounts API: `https://api.twilio.com/2010-04-01/Accounts/{AccountSid}` — Messages, Calls, IncomingPhoneNumbers
|
|
49
|
+
- Verify API: `https://verify.twilio.com/v2` — 2FA flows (requires a Verify Service SID passed per call)
|
|
50
|
+
- Lookups API: `https://lookups.twilio.com/v2` — phone number validation / carrier / line type
|
|
51
|
+
|
|
52
|
+
Request bodies are `application/x-www-form-urlencoded`; responses are JSON (endpoints use the `.json` suffix on the Accounts API).
|
|
53
|
+
|
|
54
|
+
## WhatsApp
|
|
55
|
+
|
|
56
|
+
Use the same `send_message` tool, but prefix numbers with `whatsapp:`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{ "To": "whatsapp:+5511999999999", "From": "whatsapp:+14155238886", "Body": "Olá" }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Sandbox numbers or approved WhatsApp-enabled senders work the same way.
|
|
63
|
+
|
|
64
|
+
## Run
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# stdio (default — for Claude Desktop, Cursor, etc)
|
|
68
|
+
npx @codespar/mcp-twilio
|
|
69
|
+
|
|
70
|
+
# HTTP (for server-to-server testing)
|
|
71
|
+
MCP_HTTP=true MCP_PORT=3000 npx @codespar/mcp-twilio
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP Server for Twilio — the global standard for programmable messaging + voice.
|
|
4
|
+
*
|
|
5
|
+
* One server covering Twilio's three most-used products:
|
|
6
|
+
* - Programmable Messaging (SMS + WhatsApp, same API, `whatsapp:` prefix)
|
|
7
|
+
* - Programmable Voice (outbound calls, hangup, redirect)
|
|
8
|
+
* - Verify (2FA one-time codes) + Lookups (phone validation)
|
|
9
|
+
*
|
|
10
|
+
* Fills the global-messaging gap in a catalog otherwise tilted to BR-specific
|
|
11
|
+
* providers (Z-API, Take Blip, Zenvia, Evolution API). Twilio ships in 180+
|
|
12
|
+
* countries on day one.
|
|
13
|
+
*
|
|
14
|
+
* Tools (12):
|
|
15
|
+
* send_message — send SMS or WhatsApp (prefix `To` with `whatsapp:+E164`)
|
|
16
|
+
* get_message — retrieve a message by SID
|
|
17
|
+
* list_messages — list messages with optional To/From/DateSent filters
|
|
18
|
+
* delete_message — delete a message from history
|
|
19
|
+
* make_call — place an outbound voice call driven by a TwiML Url
|
|
20
|
+
* get_call — retrieve a call by SID
|
|
21
|
+
* update_call — hang up or redirect an in-progress call
|
|
22
|
+
* start_verification — send a Verify (2FA) code via sms / whatsapp / call
|
|
23
|
+
* check_verification — check a Verify (2FA) code
|
|
24
|
+
* lookup_phone — validate + format + classify a phone number
|
|
25
|
+
* list_incoming_numbers — list provisioned Twilio phone numbers
|
|
26
|
+
* buy_phone_number — provision a new phone number
|
|
27
|
+
*
|
|
28
|
+
* Authentication
|
|
29
|
+
* HTTP Basic with AccountSid:AuthToken.
|
|
30
|
+
* Authorization: Basic <base64(AccountSid:AuthToken)>
|
|
31
|
+
*
|
|
32
|
+
* API surface
|
|
33
|
+
* Accounts API : https://api.twilio.com/2010-04-01/Accounts/{AccountSid}
|
|
34
|
+
* Verify API : https://verify.twilio.com/v2
|
|
35
|
+
* Lookups API : https://lookups.twilio.com/v2
|
|
36
|
+
*
|
|
37
|
+
* Request bodies are application/x-www-form-urlencoded. Responses are JSON
|
|
38
|
+
* (Accounts endpoints use the `.json` suffix).
|
|
39
|
+
*
|
|
40
|
+
* Environment
|
|
41
|
+
* TWILIO_ACCOUNT_SID required — Account SID (AC...)
|
|
42
|
+
* TWILIO_AUTH_TOKEN required — Auth Token (secret)
|
|
43
|
+
* TWILIO_MESSAGING_SERVICE_SID optional — default sender for send_message (MG...)
|
|
44
|
+
*
|
|
45
|
+
* Docs: https://www.twilio.com/docs/api
|
|
46
|
+
*/
|
|
47
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
48
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
49
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
50
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
51
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
52
|
+
const ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || "";
|
|
53
|
+
const AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || "";
|
|
54
|
+
const DEFAULT_MESSAGING_SERVICE_SID = process.env.TWILIO_MESSAGING_SERVICE_SID || "";
|
|
55
|
+
const ACCOUNTS_BASE = `https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}`;
|
|
56
|
+
async function twilioRequest(method, fullUrlOrPath, body) {
|
|
57
|
+
const url = fullUrlOrPath.startsWith("https://")
|
|
58
|
+
? fullUrlOrPath
|
|
59
|
+
: `${ACCOUNTS_BASE}${fullUrlOrPath}`;
|
|
60
|
+
const basic = Buffer.from(`${ACCOUNT_SID}:${AUTH_TOKEN}`).toString("base64");
|
|
61
|
+
const headers = {
|
|
62
|
+
"Authorization": `Basic ${basic}`,
|
|
63
|
+
"Accept": "application/json",
|
|
64
|
+
};
|
|
65
|
+
let encodedBody;
|
|
66
|
+
if (body && Object.keys(body).length > 0) {
|
|
67
|
+
const params = new URLSearchParams();
|
|
68
|
+
for (const [k, v] of Object.entries(body)) {
|
|
69
|
+
if (v === undefined || v === null)
|
|
70
|
+
continue;
|
|
71
|
+
if (Array.isArray(v)) {
|
|
72
|
+
for (const item of v)
|
|
73
|
+
params.append(k, String(item));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
params.append(k, String(v));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
encodedBody = params.toString();
|
|
80
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
81
|
+
}
|
|
82
|
+
const res = await fetch(url, { method, headers, body: encodedBody });
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`Twilio API ${res.status}: ${await res.text()}`);
|
|
85
|
+
}
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
return text ? JSON.parse(text) : {};
|
|
88
|
+
}
|
|
89
|
+
function buildQuery(params) {
|
|
90
|
+
const q = new URLSearchParams();
|
|
91
|
+
for (const [k, v] of Object.entries(params)) {
|
|
92
|
+
if (v === undefined || v === null || v === "")
|
|
93
|
+
continue;
|
|
94
|
+
q.set(k, String(v));
|
|
95
|
+
}
|
|
96
|
+
const s = q.toString();
|
|
97
|
+
return s ? `?${s}` : "";
|
|
98
|
+
}
|
|
99
|
+
const server = new Server({ name: "mcp-twilio", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
100
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
101
|
+
tools: [
|
|
102
|
+
{
|
|
103
|
+
name: "send_message",
|
|
104
|
+
description: "Send an SMS or WhatsApp message. For WhatsApp, prefix `To` (and `From`) with `whatsapp:+E164`. Supply either `From` (a Twilio phone number) OR `MessagingServiceSid` (a Messaging Service). If neither is given, falls back to env TWILIO_MESSAGING_SERVICE_SID.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
To: { type: "string", description: "Destination in E.164 (e.g. +5511999999999) or `whatsapp:+E164` for WhatsApp" },
|
|
109
|
+
From: { type: "string", description: "Twilio phone number in E.164, or `whatsapp:+E164` for WhatsApp. Omit if using MessagingServiceSid." },
|
|
110
|
+
MessagingServiceSid: { type: "string", description: "Messaging Service SID (MG...). Overrides env default. Omit if using From." },
|
|
111
|
+
Body: { type: "string", description: "Message text (UTF-8)" },
|
|
112
|
+
MediaUrl: { type: "array", items: { type: "string" }, description: "Optional list of media URLs (MMS / WhatsApp media)" },
|
|
113
|
+
StatusCallback: { type: "string", description: "Webhook URL Twilio calls on delivery-status transitions" },
|
|
114
|
+
},
|
|
115
|
+
required: ["To", "Body"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "get_message",
|
|
120
|
+
description: "Retrieve a message resource by SID (SM... or MM...).",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
Sid: { type: "string", description: "Message SID" },
|
|
125
|
+
},
|
|
126
|
+
required: ["Sid"],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "list_messages",
|
|
131
|
+
description: "List messages with optional filters. Returns Twilio's paginated list; pass PageSize to cap.",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: {
|
|
135
|
+
To: { type: "string", description: "Filter by destination (E.164 or whatsapp:+E164)" },
|
|
136
|
+
From: { type: "string", description: "Filter by sender" },
|
|
137
|
+
DateSent: { type: "string", description: "Filter by exact send date (YYYY-MM-DD). Use DateSentAfter / DateSentBefore for ranges." },
|
|
138
|
+
DateSentAfter: { type: "string", description: "Return messages sent on/after this date (YYYY-MM-DD)" },
|
|
139
|
+
DateSentBefore: { type: "string", description: "Return messages sent on/before this date (YYYY-MM-DD)" },
|
|
140
|
+
PageSize: { type: "number", description: "Max rows per page (default 50, max 1000)" },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "delete_message",
|
|
146
|
+
description: "Delete a message from history. Irreversible.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
Sid: { type: "string", description: "Message SID" },
|
|
151
|
+
},
|
|
152
|
+
required: ["Sid"],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "make_call",
|
|
157
|
+
description: "Place an outbound voice call. Twilio fetches TwiML from `Url` on connect to drive the call.",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
To: { type: "string", description: "Destination number in E.164" },
|
|
162
|
+
From: { type: "string", description: "Twilio-provisioned caller ID in E.164" },
|
|
163
|
+
Url: { type: "string", description: "HTTP(S) URL returning TwiML that drives the call" },
|
|
164
|
+
Method: { type: "string", enum: ["GET", "POST"], description: "HTTP method Twilio uses to fetch Url (default POST)" },
|
|
165
|
+
StatusCallback: { type: "string", description: "Webhook URL for call-status events" },
|
|
166
|
+
StatusCallbackEvent: { type: "array", items: { type: "string" }, description: "Events to subscribe to: initiated, ringing, answered, completed" },
|
|
167
|
+
},
|
|
168
|
+
required: ["To", "From", "Url"],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "get_call",
|
|
173
|
+
description: "Retrieve a call resource by SID (CA...).",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: {
|
|
177
|
+
Sid: { type: "string", description: "Call SID" },
|
|
178
|
+
},
|
|
179
|
+
required: ["Sid"],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "update_call",
|
|
184
|
+
description: "Modify an in-progress call. Set Status='completed' to hang up, or pass a new Url to redirect the call to fresh TwiML.",
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: {
|
|
188
|
+
Sid: { type: "string", description: "Call SID" },
|
|
189
|
+
Status: { type: "string", enum: ["canceled", "completed"], description: "canceled (before answered) or completed (hang up)" },
|
|
190
|
+
Url: { type: "string", description: "New TwiML URL — redirects the live call" },
|
|
191
|
+
Method: { type: "string", enum: ["GET", "POST"], description: "HTTP method for the new Url" },
|
|
192
|
+
},
|
|
193
|
+
required: ["Sid"],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "start_verification",
|
|
198
|
+
description: "Start a Verify (2FA) challenge. Sends a one-time code to `To` via the chosen channel. Requires a Verify Service SID (VA...).",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
ServiceSid: { type: "string", description: "Verify Service SID (VA...)" },
|
|
203
|
+
To: { type: "string", description: "Destination in E.164 (or email for email channel)" },
|
|
204
|
+
Channel: { type: "string", enum: ["sms", "whatsapp", "call", "email"], description: "Delivery channel" },
|
|
205
|
+
Locale: { type: "string", description: "Message locale (e.g. pt-br, en, es)" },
|
|
206
|
+
},
|
|
207
|
+
required: ["ServiceSid", "To", "Channel"],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "check_verification",
|
|
212
|
+
description: "Check a Verify (2FA) code against a Service SID. Returns status=approved when the code matches.",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: {
|
|
216
|
+
ServiceSid: { type: "string", description: "Verify Service SID (VA...)" },
|
|
217
|
+
To: { type: "string", description: "Destination that received the code" },
|
|
218
|
+
Code: { type: "string", description: "Code the user entered" },
|
|
219
|
+
},
|
|
220
|
+
required: ["ServiceSid", "To", "Code"],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "lookup_phone",
|
|
225
|
+
description: "Validate and normalize a phone number via Lookups v2. Optional `Fields` lets you request carrier info, line_type_intelligence, caller_name, identity_match, etc.",
|
|
226
|
+
inputSchema: {
|
|
227
|
+
type: "object",
|
|
228
|
+
properties: {
|
|
229
|
+
PhoneNumber: { type: "string", description: "Number to look up (E.164 recommended; Lookups v2 will format if possible)" },
|
|
230
|
+
Fields: { type: "string", description: "Comma-separated list of add-ons (e.g. `line_type_intelligence,caller_name`). Billed per field." },
|
|
231
|
+
CountryCode: { type: "string", description: "ISO-3166 alpha-2. Required only if PhoneNumber is not in E.164." },
|
|
232
|
+
},
|
|
233
|
+
required: ["PhoneNumber"],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "list_incoming_numbers",
|
|
238
|
+
description: "List Twilio-provisioned phone numbers on this account. Filter by PhoneNumber (partial), FriendlyName, or Beta.",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: "object",
|
|
241
|
+
properties: {
|
|
242
|
+
PhoneNumber: { type: "string", description: "Filter by partial phone number match" },
|
|
243
|
+
FriendlyName: { type: "string", description: "Filter by friendly name" },
|
|
244
|
+
PageSize: { type: "number", description: "Max rows per page (default 50, max 1000)" },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: "buy_phone_number",
|
|
250
|
+
description: "Provision a new phone number. Supply either a specific `PhoneNumber` (from AvailablePhoneNumbers search) or an `AreaCode` to let Twilio pick one.",
|
|
251
|
+
inputSchema: {
|
|
252
|
+
type: "object",
|
|
253
|
+
properties: {
|
|
254
|
+
PhoneNumber: { type: "string", description: "Exact E.164 number to buy (from AvailablePhoneNumbers search)" },
|
|
255
|
+
AreaCode: { type: "string", description: "Area code — Twilio picks any available number in it" },
|
|
256
|
+
FriendlyName: { type: "string", description: "Friendly label" },
|
|
257
|
+
VoiceUrl: { type: "string", description: "TwiML URL for incoming calls" },
|
|
258
|
+
SmsUrl: { type: "string", description: "TwiML URL for incoming SMS" },
|
|
259
|
+
StatusCallback: { type: "string", description: "Webhook URL for number status events" },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
}));
|
|
265
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
266
|
+
const { name, arguments: args } = request.params;
|
|
267
|
+
const a = (args ?? {});
|
|
268
|
+
try {
|
|
269
|
+
switch (name) {
|
|
270
|
+
case "send_message": {
|
|
271
|
+
const body = {
|
|
272
|
+
To: a.To,
|
|
273
|
+
Body: a.Body,
|
|
274
|
+
};
|
|
275
|
+
if (a.From)
|
|
276
|
+
body.From = a.From;
|
|
277
|
+
else if (a.MessagingServiceSid)
|
|
278
|
+
body.MessagingServiceSid = a.MessagingServiceSid;
|
|
279
|
+
else if (DEFAULT_MESSAGING_SERVICE_SID)
|
|
280
|
+
body.MessagingServiceSid = DEFAULT_MESSAGING_SERVICE_SID;
|
|
281
|
+
if (a.MediaUrl)
|
|
282
|
+
body.MediaUrl = a.MediaUrl;
|
|
283
|
+
if (a.StatusCallback)
|
|
284
|
+
body.StatusCallback = a.StatusCallback;
|
|
285
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", "/Messages.json", body), null, 2) }] };
|
|
286
|
+
}
|
|
287
|
+
case "get_message":
|
|
288
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/Messages/${a.Sid}.json`), null, 2) }] };
|
|
289
|
+
case "list_messages": {
|
|
290
|
+
const q = buildQuery({
|
|
291
|
+
To: a.To,
|
|
292
|
+
From: a.From,
|
|
293
|
+
DateSent: a.DateSent,
|
|
294
|
+
"DateSent>": a.DateSentAfter,
|
|
295
|
+
"DateSent<": a.DateSentBefore,
|
|
296
|
+
PageSize: a.PageSize,
|
|
297
|
+
});
|
|
298
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/Messages.json${q}`), null, 2) }] };
|
|
299
|
+
}
|
|
300
|
+
case "delete_message":
|
|
301
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("DELETE", `/Messages/${a.Sid}.json`), null, 2) }] };
|
|
302
|
+
case "make_call": {
|
|
303
|
+
const body = {
|
|
304
|
+
To: a.To,
|
|
305
|
+
From: a.From,
|
|
306
|
+
Url: a.Url,
|
|
307
|
+
};
|
|
308
|
+
if (a.Method)
|
|
309
|
+
body.Method = a.Method;
|
|
310
|
+
if (a.StatusCallback)
|
|
311
|
+
body.StatusCallback = a.StatusCallback;
|
|
312
|
+
if (a.StatusCallbackEvent)
|
|
313
|
+
body.StatusCallbackEvent = a.StatusCallbackEvent;
|
|
314
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", "/Calls.json", body), null, 2) }] };
|
|
315
|
+
}
|
|
316
|
+
case "get_call":
|
|
317
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/Calls/${a.Sid}.json`), null, 2) }] };
|
|
318
|
+
case "update_call": {
|
|
319
|
+
const body = {};
|
|
320
|
+
if (a.Status)
|
|
321
|
+
body.Status = a.Status;
|
|
322
|
+
if (a.Url)
|
|
323
|
+
body.Url = a.Url;
|
|
324
|
+
if (a.Method)
|
|
325
|
+
body.Method = a.Method;
|
|
326
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", `/Calls/${a.Sid}.json`, body), null, 2) }] };
|
|
327
|
+
}
|
|
328
|
+
case "start_verification": {
|
|
329
|
+
const body = {
|
|
330
|
+
To: a.To,
|
|
331
|
+
Channel: a.Channel,
|
|
332
|
+
};
|
|
333
|
+
if (a.Locale)
|
|
334
|
+
body.Locale = a.Locale;
|
|
335
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", `https://verify.twilio.com/v2/Services/${a.ServiceSid}/Verifications`, body), null, 2) }] };
|
|
336
|
+
}
|
|
337
|
+
case "check_verification": {
|
|
338
|
+
const body = {
|
|
339
|
+
To: a.To,
|
|
340
|
+
Code: a.Code,
|
|
341
|
+
};
|
|
342
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", `https://verify.twilio.com/v2/Services/${a.ServiceSid}/VerificationCheck`, body), null, 2) }] };
|
|
343
|
+
}
|
|
344
|
+
case "lookup_phone": {
|
|
345
|
+
const q = buildQuery({ Fields: a.Fields, CountryCode: a.CountryCode });
|
|
346
|
+
const number = encodeURIComponent(String(a.PhoneNumber ?? ""));
|
|
347
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `https://lookups.twilio.com/v2/PhoneNumbers/${number}${q}`), null, 2) }] };
|
|
348
|
+
}
|
|
349
|
+
case "list_incoming_numbers": {
|
|
350
|
+
const q = buildQuery({
|
|
351
|
+
PhoneNumber: a.PhoneNumber,
|
|
352
|
+
FriendlyName: a.FriendlyName,
|
|
353
|
+
PageSize: a.PageSize,
|
|
354
|
+
});
|
|
355
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/IncomingPhoneNumbers.json${q}`), null, 2) }] };
|
|
356
|
+
}
|
|
357
|
+
case "buy_phone_number": {
|
|
358
|
+
const body = {};
|
|
359
|
+
if (a.PhoneNumber)
|
|
360
|
+
body.PhoneNumber = a.PhoneNumber;
|
|
361
|
+
if (a.AreaCode)
|
|
362
|
+
body.AreaCode = a.AreaCode;
|
|
363
|
+
if (a.FriendlyName)
|
|
364
|
+
body.FriendlyName = a.FriendlyName;
|
|
365
|
+
if (a.VoiceUrl)
|
|
366
|
+
body.VoiceUrl = a.VoiceUrl;
|
|
367
|
+
if (a.SmsUrl)
|
|
368
|
+
body.SmsUrl = a.SmsUrl;
|
|
369
|
+
if (a.StatusCallback)
|
|
370
|
+
body.StatusCallback = a.StatusCallback;
|
|
371
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", "/IncomingPhoneNumbers.json", body), null, 2) }] };
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
async function main() {
|
|
382
|
+
if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
|
|
383
|
+
const { default: express } = await import("express");
|
|
384
|
+
const { randomUUID } = await import("node:crypto");
|
|
385
|
+
const app = express();
|
|
386
|
+
app.use(express.json());
|
|
387
|
+
const transports = new Map();
|
|
388
|
+
app.get("/health", (_req, res) => res.json({ status: "ok", sessions: transports.size }));
|
|
389
|
+
app.post("/mcp", async (req, res) => {
|
|
390
|
+
const sid = req.headers["mcp-session-id"];
|
|
391
|
+
if (sid && transports.has(sid)) {
|
|
392
|
+
await transports.get(sid).handleRequest(req, res, req.body);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (!sid && isInitializeRequest(req.body)) {
|
|
396
|
+
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
|
|
397
|
+
t.onclose = () => { if (t.sessionId)
|
|
398
|
+
transports.delete(t.sessionId); };
|
|
399
|
+
const s = new Server({ name: "mcp-twilio", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
400
|
+
server._requestHandlers.forEach((v, k) => s._requestHandlers.set(k, v));
|
|
401
|
+
server._notificationHandlers?.forEach((v, k) => s._notificationHandlers.set(k, v));
|
|
402
|
+
await s.connect(t);
|
|
403
|
+
await t.handleRequest(req, res, req.body);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
|
|
407
|
+
});
|
|
408
|
+
app.get("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
|
|
409
|
+
await transports.get(sid).handleRequest(req, res);
|
|
410
|
+
else
|
|
411
|
+
res.status(400).send("Invalid session"); });
|
|
412
|
+
app.delete("/mcp", async (req, res) => { const sid = req.headers["mcp-session-id"]; if (sid && transports.has(sid))
|
|
413
|
+
await transports.get(sid).handleRequest(req, res);
|
|
414
|
+
else
|
|
415
|
+
res.status(400).send("Invalid session"); });
|
|
416
|
+
const port = Number(process.env.MCP_PORT) || 3000;
|
|
417
|
+
app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
const transport = new StdioServerTransport();
|
|
421
|
+
await server.connect(transport);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codespar/mcp-twilio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Twilio — global SMS, WhatsApp, Voice, Verify, and Lookup across 180+ countries",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-twilio": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^25.5.0",
|
|
19
|
+
"typescript": "^5.8.0"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"twilio",
|
|
25
|
+
"sms",
|
|
26
|
+
"whatsapp",
|
|
27
|
+
"voice",
|
|
28
|
+
"verify",
|
|
29
|
+
"2fa",
|
|
30
|
+
"lookup",
|
|
31
|
+
"messaging",
|
|
32
|
+
"communication",
|
|
33
|
+
"global"
|
|
34
|
+
],
|
|
35
|
+
"mcpName": "io.github.codespar/mcp-twilio"
|
|
36
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.codespar/mcp-twilio",
|
|
4
|
+
"description": "MCP server for Twilio — global SMS, WhatsApp, Voice, Verify, and Lookup across 180+ countries",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/codespar/mcp-dev-brasil",
|
|
7
|
+
"source": "github",
|
|
8
|
+
"subfolder": "packages/communication/twilio"
|
|
9
|
+
},
|
|
10
|
+
"version": "0.1.0",
|
|
11
|
+
"packages": [
|
|
12
|
+
{
|
|
13
|
+
"registryType": "npm",
|
|
14
|
+
"identifier": "@codespar/mcp-twilio",
|
|
15
|
+
"version": "0.1.0",
|
|
16
|
+
"transport": {
|
|
17
|
+
"type": "stdio"
|
|
18
|
+
},
|
|
19
|
+
"environmentVariables": [
|
|
20
|
+
{
|
|
21
|
+
"name": "TWILIO_ACCOUNT_SID",
|
|
22
|
+
"description": "Twilio Account SID (starts with AC...). Used as username for HTTP Basic auth.",
|
|
23
|
+
"isRequired": true,
|
|
24
|
+
"format": "string",
|
|
25
|
+
"isSecret": false
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "TWILIO_AUTH_TOKEN",
|
|
29
|
+
"description": "Twilio Auth Token. Used as password for HTTP Basic auth.",
|
|
30
|
+
"isRequired": true,
|
|
31
|
+
"format": "string",
|
|
32
|
+
"isSecret": true
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "TWILIO_MESSAGING_SERVICE_SID",
|
|
36
|
+
"description": "Optional default Messaging Service SID (starts with MG...) used for send_message when From is omitted.",
|
|
37
|
+
"isRequired": false,
|
|
38
|
+
"format": "string",
|
|
39
|
+
"isSecret": false
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP Server for Twilio — the global standard for programmable messaging + voice.
|
|
5
|
+
*
|
|
6
|
+
* One server covering Twilio's three most-used products:
|
|
7
|
+
* - Programmable Messaging (SMS + WhatsApp, same API, `whatsapp:` prefix)
|
|
8
|
+
* - Programmable Voice (outbound calls, hangup, redirect)
|
|
9
|
+
* - Verify (2FA one-time codes) + Lookups (phone validation)
|
|
10
|
+
*
|
|
11
|
+
* Fills the global-messaging gap in a catalog otherwise tilted to BR-specific
|
|
12
|
+
* providers (Z-API, Take Blip, Zenvia, Evolution API). Twilio ships in 180+
|
|
13
|
+
* countries on day one.
|
|
14
|
+
*
|
|
15
|
+
* Tools (12):
|
|
16
|
+
* send_message — send SMS or WhatsApp (prefix `To` with `whatsapp:+E164`)
|
|
17
|
+
* get_message — retrieve a message by SID
|
|
18
|
+
* list_messages — list messages with optional To/From/DateSent filters
|
|
19
|
+
* delete_message — delete a message from history
|
|
20
|
+
* make_call — place an outbound voice call driven by a TwiML Url
|
|
21
|
+
* get_call — retrieve a call by SID
|
|
22
|
+
* update_call — hang up or redirect an in-progress call
|
|
23
|
+
* start_verification — send a Verify (2FA) code via sms / whatsapp / call
|
|
24
|
+
* check_verification — check a Verify (2FA) code
|
|
25
|
+
* lookup_phone — validate + format + classify a phone number
|
|
26
|
+
* list_incoming_numbers — list provisioned Twilio phone numbers
|
|
27
|
+
* buy_phone_number — provision a new phone number
|
|
28
|
+
*
|
|
29
|
+
* Authentication
|
|
30
|
+
* HTTP Basic with AccountSid:AuthToken.
|
|
31
|
+
* Authorization: Basic <base64(AccountSid:AuthToken)>
|
|
32
|
+
*
|
|
33
|
+
* API surface
|
|
34
|
+
* Accounts API : https://api.twilio.com/2010-04-01/Accounts/{AccountSid}
|
|
35
|
+
* Verify API : https://verify.twilio.com/v2
|
|
36
|
+
* Lookups API : https://lookups.twilio.com/v2
|
|
37
|
+
*
|
|
38
|
+
* Request bodies are application/x-www-form-urlencoded. Responses are JSON
|
|
39
|
+
* (Accounts endpoints use the `.json` suffix).
|
|
40
|
+
*
|
|
41
|
+
* Environment
|
|
42
|
+
* TWILIO_ACCOUNT_SID required — Account SID (AC...)
|
|
43
|
+
* TWILIO_AUTH_TOKEN required — Auth Token (secret)
|
|
44
|
+
* TWILIO_MESSAGING_SERVICE_SID optional — default sender for send_message (MG...)
|
|
45
|
+
*
|
|
46
|
+
* Docs: https://www.twilio.com/docs/api
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
50
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
51
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
52
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
53
|
+
import {
|
|
54
|
+
CallToolRequestSchema,
|
|
55
|
+
ListToolsRequestSchema,
|
|
56
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
57
|
+
|
|
58
|
+
const ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID || "";
|
|
59
|
+
const AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN || "";
|
|
60
|
+
const DEFAULT_MESSAGING_SERVICE_SID = process.env.TWILIO_MESSAGING_SERVICE_SID || "";
|
|
61
|
+
const ACCOUNTS_BASE = `https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}`;
|
|
62
|
+
|
|
63
|
+
async function twilioRequest(
|
|
64
|
+
method: string,
|
|
65
|
+
fullUrlOrPath: string,
|
|
66
|
+
body?: Record<string, unknown>
|
|
67
|
+
): Promise<unknown> {
|
|
68
|
+
const url = fullUrlOrPath.startsWith("https://")
|
|
69
|
+
? fullUrlOrPath
|
|
70
|
+
: `${ACCOUNTS_BASE}${fullUrlOrPath}`;
|
|
71
|
+
|
|
72
|
+
const basic = Buffer.from(`${ACCOUNT_SID}:${AUTH_TOKEN}`).toString("base64");
|
|
73
|
+
const headers: Record<string, string> = {
|
|
74
|
+
"Authorization": `Basic ${basic}`,
|
|
75
|
+
"Accept": "application/json",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
let encodedBody: string | undefined;
|
|
79
|
+
if (body && Object.keys(body).length > 0) {
|
|
80
|
+
const params = new URLSearchParams();
|
|
81
|
+
for (const [k, v] of Object.entries(body)) {
|
|
82
|
+
if (v === undefined || v === null) continue;
|
|
83
|
+
if (Array.isArray(v)) {
|
|
84
|
+
for (const item of v) params.append(k, String(item));
|
|
85
|
+
} else {
|
|
86
|
+
params.append(k, String(v));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
encodedBody = params.toString();
|
|
90
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const res = await fetch(url, { method, headers, body: encodedBody });
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
throw new Error(`Twilio API ${res.status}: ${await res.text()}`);
|
|
96
|
+
}
|
|
97
|
+
const text = await res.text();
|
|
98
|
+
return text ? JSON.parse(text) : {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildQuery(params: Record<string, unknown>): string {
|
|
102
|
+
const q = new URLSearchParams();
|
|
103
|
+
for (const [k, v] of Object.entries(params)) {
|
|
104
|
+
if (v === undefined || v === null || v === "") continue;
|
|
105
|
+
q.set(k, String(v));
|
|
106
|
+
}
|
|
107
|
+
const s = q.toString();
|
|
108
|
+
return s ? `?${s}` : "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const server = new Server(
|
|
112
|
+
{ name: "mcp-twilio", version: "0.1.0" },
|
|
113
|
+
{ capabilities: { tools: {} } }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
117
|
+
tools: [
|
|
118
|
+
{
|
|
119
|
+
name: "send_message",
|
|
120
|
+
description: "Send an SMS or WhatsApp message. For WhatsApp, prefix `To` (and `From`) with `whatsapp:+E164`. Supply either `From` (a Twilio phone number) OR `MessagingServiceSid` (a Messaging Service). If neither is given, falls back to env TWILIO_MESSAGING_SERVICE_SID.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
To: { type: "string", description: "Destination in E.164 (e.g. +5511999999999) or `whatsapp:+E164` for WhatsApp" },
|
|
125
|
+
From: { type: "string", description: "Twilio phone number in E.164, or `whatsapp:+E164` for WhatsApp. Omit if using MessagingServiceSid." },
|
|
126
|
+
MessagingServiceSid: { type: "string", description: "Messaging Service SID (MG...). Overrides env default. Omit if using From." },
|
|
127
|
+
Body: { type: "string", description: "Message text (UTF-8)" },
|
|
128
|
+
MediaUrl: { type: "array", items: { type: "string" }, description: "Optional list of media URLs (MMS / WhatsApp media)" },
|
|
129
|
+
StatusCallback: { type: "string", description: "Webhook URL Twilio calls on delivery-status transitions" },
|
|
130
|
+
},
|
|
131
|
+
required: ["To", "Body"],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "get_message",
|
|
136
|
+
description: "Retrieve a message resource by SID (SM... or MM...).",
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
Sid: { type: "string", description: "Message SID" },
|
|
141
|
+
},
|
|
142
|
+
required: ["Sid"],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "list_messages",
|
|
147
|
+
description: "List messages with optional filters. Returns Twilio's paginated list; pass PageSize to cap.",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
To: { type: "string", description: "Filter by destination (E.164 or whatsapp:+E164)" },
|
|
152
|
+
From: { type: "string", description: "Filter by sender" },
|
|
153
|
+
DateSent: { type: "string", description: "Filter by exact send date (YYYY-MM-DD). Use DateSentAfter / DateSentBefore for ranges." },
|
|
154
|
+
DateSentAfter: { type: "string", description: "Return messages sent on/after this date (YYYY-MM-DD)" },
|
|
155
|
+
DateSentBefore: { type: "string", description: "Return messages sent on/before this date (YYYY-MM-DD)" },
|
|
156
|
+
PageSize: { type: "number", description: "Max rows per page (default 50, max 1000)" },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "delete_message",
|
|
162
|
+
description: "Delete a message from history. Irreversible.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
type: "object",
|
|
165
|
+
properties: {
|
|
166
|
+
Sid: { type: "string", description: "Message SID" },
|
|
167
|
+
},
|
|
168
|
+
required: ["Sid"],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "make_call",
|
|
173
|
+
description: "Place an outbound voice call. Twilio fetches TwiML from `Url` on connect to drive the call.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: {
|
|
177
|
+
To: { type: "string", description: "Destination number in E.164" },
|
|
178
|
+
From: { type: "string", description: "Twilio-provisioned caller ID in E.164" },
|
|
179
|
+
Url: { type: "string", description: "HTTP(S) URL returning TwiML that drives the call" },
|
|
180
|
+
Method: { type: "string", enum: ["GET", "POST"], description: "HTTP method Twilio uses to fetch Url (default POST)" },
|
|
181
|
+
StatusCallback: { type: "string", description: "Webhook URL for call-status events" },
|
|
182
|
+
StatusCallbackEvent: { type: "array", items: { type: "string" }, description: "Events to subscribe to: initiated, ringing, answered, completed" },
|
|
183
|
+
},
|
|
184
|
+
required: ["To", "From", "Url"],
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "get_call",
|
|
189
|
+
description: "Retrieve a call resource by SID (CA...).",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
Sid: { type: "string", description: "Call SID" },
|
|
194
|
+
},
|
|
195
|
+
required: ["Sid"],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: "update_call",
|
|
200
|
+
description: "Modify an in-progress call. Set Status='completed' to hang up, or pass a new Url to redirect the call to fresh TwiML.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: "object",
|
|
203
|
+
properties: {
|
|
204
|
+
Sid: { type: "string", description: "Call SID" },
|
|
205
|
+
Status: { type: "string", enum: ["canceled", "completed"], description: "canceled (before answered) or completed (hang up)" },
|
|
206
|
+
Url: { type: "string", description: "New TwiML URL — redirects the live call" },
|
|
207
|
+
Method: { type: "string", enum: ["GET", "POST"], description: "HTTP method for the new Url" },
|
|
208
|
+
},
|
|
209
|
+
required: ["Sid"],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "start_verification",
|
|
214
|
+
description: "Start a Verify (2FA) challenge. Sends a one-time code to `To` via the chosen channel. Requires a Verify Service SID (VA...).",
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: "object",
|
|
217
|
+
properties: {
|
|
218
|
+
ServiceSid: { type: "string", description: "Verify Service SID (VA...)" },
|
|
219
|
+
To: { type: "string", description: "Destination in E.164 (or email for email channel)" },
|
|
220
|
+
Channel: { type: "string", enum: ["sms", "whatsapp", "call", "email"], description: "Delivery channel" },
|
|
221
|
+
Locale: { type: "string", description: "Message locale (e.g. pt-br, en, es)" },
|
|
222
|
+
},
|
|
223
|
+
required: ["ServiceSid", "To", "Channel"],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "check_verification",
|
|
228
|
+
description: "Check a Verify (2FA) code against a Service SID. Returns status=approved when the code matches.",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
type: "object",
|
|
231
|
+
properties: {
|
|
232
|
+
ServiceSid: { type: "string", description: "Verify Service SID (VA...)" },
|
|
233
|
+
To: { type: "string", description: "Destination that received the code" },
|
|
234
|
+
Code: { type: "string", description: "Code the user entered" },
|
|
235
|
+
},
|
|
236
|
+
required: ["ServiceSid", "To", "Code"],
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: "lookup_phone",
|
|
241
|
+
description: "Validate and normalize a phone number via Lookups v2. Optional `Fields` lets you request carrier info, line_type_intelligence, caller_name, identity_match, etc.",
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: "object",
|
|
244
|
+
properties: {
|
|
245
|
+
PhoneNumber: { type: "string", description: "Number to look up (E.164 recommended; Lookups v2 will format if possible)" },
|
|
246
|
+
Fields: { type: "string", description: "Comma-separated list of add-ons (e.g. `line_type_intelligence,caller_name`). Billed per field." },
|
|
247
|
+
CountryCode: { type: "string", description: "ISO-3166 alpha-2. Required only if PhoneNumber is not in E.164." },
|
|
248
|
+
},
|
|
249
|
+
required: ["PhoneNumber"],
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "list_incoming_numbers",
|
|
254
|
+
description: "List Twilio-provisioned phone numbers on this account. Filter by PhoneNumber (partial), FriendlyName, or Beta.",
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: {
|
|
258
|
+
PhoneNumber: { type: "string", description: "Filter by partial phone number match" },
|
|
259
|
+
FriendlyName: { type: "string", description: "Filter by friendly name" },
|
|
260
|
+
PageSize: { type: "number", description: "Max rows per page (default 50, max 1000)" },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: "buy_phone_number",
|
|
266
|
+
description: "Provision a new phone number. Supply either a specific `PhoneNumber` (from AvailablePhoneNumbers search) or an `AreaCode` to let Twilio pick one.",
|
|
267
|
+
inputSchema: {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: {
|
|
270
|
+
PhoneNumber: { type: "string", description: "Exact E.164 number to buy (from AvailablePhoneNumbers search)" },
|
|
271
|
+
AreaCode: { type: "string", description: "Area code — Twilio picks any available number in it" },
|
|
272
|
+
FriendlyName: { type: "string", description: "Friendly label" },
|
|
273
|
+
VoiceUrl: { type: "string", description: "TwiML URL for incoming calls" },
|
|
274
|
+
SmsUrl: { type: "string", description: "TwiML URL for incoming SMS" },
|
|
275
|
+
StatusCallback: { type: "string", description: "Webhook URL for number status events" },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
283
|
+
const { name, arguments: args } = request.params;
|
|
284
|
+
const a = (args ?? {}) as Record<string, unknown>;
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
switch (name) {
|
|
288
|
+
case "send_message": {
|
|
289
|
+
const body: Record<string, unknown> = {
|
|
290
|
+
To: a.To,
|
|
291
|
+
Body: a.Body,
|
|
292
|
+
};
|
|
293
|
+
if (a.From) body.From = a.From;
|
|
294
|
+
else if (a.MessagingServiceSid) body.MessagingServiceSid = a.MessagingServiceSid;
|
|
295
|
+
else if (DEFAULT_MESSAGING_SERVICE_SID) body.MessagingServiceSid = DEFAULT_MESSAGING_SERVICE_SID;
|
|
296
|
+
if (a.MediaUrl) body.MediaUrl = a.MediaUrl;
|
|
297
|
+
if (a.StatusCallback) body.StatusCallback = a.StatusCallback;
|
|
298
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", "/Messages.json", body), null, 2) }] };
|
|
299
|
+
}
|
|
300
|
+
case "get_message":
|
|
301
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/Messages/${a.Sid}.json`), null, 2) }] };
|
|
302
|
+
case "list_messages": {
|
|
303
|
+
const q = buildQuery({
|
|
304
|
+
To: a.To,
|
|
305
|
+
From: a.From,
|
|
306
|
+
DateSent: a.DateSent,
|
|
307
|
+
"DateSent>": a.DateSentAfter,
|
|
308
|
+
"DateSent<": a.DateSentBefore,
|
|
309
|
+
PageSize: a.PageSize,
|
|
310
|
+
});
|
|
311
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/Messages.json${q}`), null, 2) }] };
|
|
312
|
+
}
|
|
313
|
+
case "delete_message":
|
|
314
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("DELETE", `/Messages/${a.Sid}.json`), null, 2) }] };
|
|
315
|
+
case "make_call": {
|
|
316
|
+
const body: Record<string, unknown> = {
|
|
317
|
+
To: a.To,
|
|
318
|
+
From: a.From,
|
|
319
|
+
Url: a.Url,
|
|
320
|
+
};
|
|
321
|
+
if (a.Method) body.Method = a.Method;
|
|
322
|
+
if (a.StatusCallback) body.StatusCallback = a.StatusCallback;
|
|
323
|
+
if (a.StatusCallbackEvent) body.StatusCallbackEvent = a.StatusCallbackEvent;
|
|
324
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", "/Calls.json", body), null, 2) }] };
|
|
325
|
+
}
|
|
326
|
+
case "get_call":
|
|
327
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/Calls/${a.Sid}.json`), null, 2) }] };
|
|
328
|
+
case "update_call": {
|
|
329
|
+
const body: Record<string, unknown> = {};
|
|
330
|
+
if (a.Status) body.Status = a.Status;
|
|
331
|
+
if (a.Url) body.Url = a.Url;
|
|
332
|
+
if (a.Method) body.Method = a.Method;
|
|
333
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", `/Calls/${a.Sid}.json`, body), null, 2) }] };
|
|
334
|
+
}
|
|
335
|
+
case "start_verification": {
|
|
336
|
+
const body: Record<string, unknown> = {
|
|
337
|
+
To: a.To,
|
|
338
|
+
Channel: a.Channel,
|
|
339
|
+
};
|
|
340
|
+
if (a.Locale) body.Locale = a.Locale;
|
|
341
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", `https://verify.twilio.com/v2/Services/${a.ServiceSid}/Verifications`, body), null, 2) }] };
|
|
342
|
+
}
|
|
343
|
+
case "check_verification": {
|
|
344
|
+
const body: Record<string, unknown> = {
|
|
345
|
+
To: a.To,
|
|
346
|
+
Code: a.Code,
|
|
347
|
+
};
|
|
348
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", `https://verify.twilio.com/v2/Services/${a.ServiceSid}/VerificationCheck`, body), null, 2) }] };
|
|
349
|
+
}
|
|
350
|
+
case "lookup_phone": {
|
|
351
|
+
const q = buildQuery({ Fields: a.Fields, CountryCode: a.CountryCode });
|
|
352
|
+
const number = encodeURIComponent(String(a.PhoneNumber ?? ""));
|
|
353
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `https://lookups.twilio.com/v2/PhoneNumbers/${number}${q}`), null, 2) }] };
|
|
354
|
+
}
|
|
355
|
+
case "list_incoming_numbers": {
|
|
356
|
+
const q = buildQuery({
|
|
357
|
+
PhoneNumber: a.PhoneNumber,
|
|
358
|
+
FriendlyName: a.FriendlyName,
|
|
359
|
+
PageSize: a.PageSize,
|
|
360
|
+
});
|
|
361
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("GET", `/IncomingPhoneNumbers.json${q}`), null, 2) }] };
|
|
362
|
+
}
|
|
363
|
+
case "buy_phone_number": {
|
|
364
|
+
const body: Record<string, unknown> = {};
|
|
365
|
+
if (a.PhoneNumber) body.PhoneNumber = a.PhoneNumber;
|
|
366
|
+
if (a.AreaCode) body.AreaCode = a.AreaCode;
|
|
367
|
+
if (a.FriendlyName) body.FriendlyName = a.FriendlyName;
|
|
368
|
+
if (a.VoiceUrl) body.VoiceUrl = a.VoiceUrl;
|
|
369
|
+
if (a.SmsUrl) body.SmsUrl = a.SmsUrl;
|
|
370
|
+
if (a.StatusCallback) body.StatusCallback = a.StatusCallback;
|
|
371
|
+
return { content: [{ type: "text", text: JSON.stringify(await twilioRequest("POST", "/IncomingPhoneNumbers.json", body), null, 2) }] };
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
async function main() {
|
|
382
|
+
if (process.argv.includes("--http") || process.env.MCP_HTTP === "true") {
|
|
383
|
+
const { default: express } = await import("express");
|
|
384
|
+
const { randomUUID } = await import("node:crypto");
|
|
385
|
+
const app = express();
|
|
386
|
+
app.use(express.json());
|
|
387
|
+
const transports = new Map<string, StreamableHTTPServerTransport>();
|
|
388
|
+
app.get("/health", (_req: unknown, res: { json: (body: unknown) => unknown }) => res.json({ status: "ok", sessions: transports.size }));
|
|
389
|
+
app.post("/mcp", async (req: { headers: Record<string, string | string[] | undefined>; body: unknown }, res: { status: (code: number) => { json: (body: unknown) => unknown } }) => {
|
|
390
|
+
const sid = req.headers["mcp-session-id"] as string | undefined;
|
|
391
|
+
if (sid && transports.has(sid)) { await transports.get(sid)!.handleRequest(req as never, res as never, req.body); return; }
|
|
392
|
+
if (!sid && isInitializeRequest(req.body)) {
|
|
393
|
+
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (id) => { transports.set(id, t); } });
|
|
394
|
+
t.onclose = () => { if (t.sessionId) transports.delete(t.sessionId); };
|
|
395
|
+
const s = new Server({ name: "mcp-twilio", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
396
|
+
(server as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.forEach((v, k) => (s as unknown as { _requestHandlers: Map<unknown, unknown> })._requestHandlers.set(k, v));
|
|
397
|
+
(server as unknown as { _notificationHandlers?: Map<unknown, unknown> })._notificationHandlers?.forEach((v, k) => (s as unknown as { _notificationHandlers: Map<unknown, unknown> })._notificationHandlers.set(k, v));
|
|
398
|
+
await s.connect(t);
|
|
399
|
+
await t.handleRequest(req as never, res as never, req.body); return;
|
|
400
|
+
}
|
|
401
|
+
res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Bad Request" }, id: null });
|
|
402
|
+
});
|
|
403
|
+
app.get("/mcp", async (req: { headers: Record<string, string | string[] | undefined> }, res: { status: (code: number) => { send: (body: string) => unknown } }) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req as never, res as never); else res.status(400).send("Invalid session"); });
|
|
404
|
+
app.delete("/mcp", async (req: { headers: Record<string, string | string[] | undefined> }, res: { status: (code: number) => { send: (body: string) => unknown } }) => { const sid = req.headers["mcp-session-id"] as string; if (sid && transports.has(sid)) await transports.get(sid)!.handleRequest(req as never, res as never); else res.status(400).send("Invalid session"); });
|
|
405
|
+
const port = Number(process.env.MCP_PORT) || 3000;
|
|
406
|
+
app.listen(port, () => { console.error(`MCP HTTP server on http://localhost:${port}/mcp`); });
|
|
407
|
+
} else {
|
|
408
|
+
const transport = new StdioServerTransport();
|
|
409
|
+
await server.connect(transport);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
main().catch(console.error);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|