twilio-call-data-mcp
An MCP server that gives any MCP client read-only access to Twilio call data. It exposes six tools for searching calls, pulling recordings, querying agent activity, and generating summaries — with explicit handling for the ways Twilio's data misleads you if you take it at face value.
This server is read-only. It cannot place calls, send messages, modify resources, or access billing. That constraint is a feature.
Install
npm install twilio-call-data-mcp
Or clone and build:
git clone https://github.com/burkecampbell/twilio-call-data-mcp.git
cd twilio-call-data-mcp
npm install
npm run build
Credentials
Set three environment variables. The server refuses to start without the first two.
export TWILIO_ACCOUNT_SID=your_account_sid
export TWILIO_AUTH_TOKEN=your_auth_token
export TWILIO_WORKSPACE_SID=your_taskrouter_workspace_sid # required for agent tools
Copy .env.example to .env for local development. Never commit .env.
MCP Client Configuration
Add to your MCP client config (e.g., Claude Desktop):
{
"mcpServers": {
"twilio-call-data": {
"command": "npx",
"args": ["twilio-call-data-mcp"],
"env": {
"TWILIO_ACCOUNT_SID": "your_account_sid",
"TWILIO_AUTH_TOKEN": "your_auth_token",
"TWILIO_WORKSPACE_SID": "your_workspace_sid"
}
}
}
}
Tools
search_calls
Filter call records by date range, phone number, direction, status, minimum duration, or agent. Returns rows plus a pagination cursor.
| Parameter | Type | Required | Description |
|---|---|---|---|
start_date |
string | No | Start of range (YYYY-MM-DD or ISO 8601), interpreted in timezone |
end_date |
string | No | End of range |
phone_number |
string | No | E.164 format, matches from or to |
direction |
enum | No | inbound, outbound-dial, outbound-api, outbound |
status |
enum | No | completed, busy, no-answer, canceled, failed, etc. |
min_duration |
integer | No | Minimum CDR duration in seconds |
agent |
string | No | Agent name or worker SID |
page_size |
integer | No | 1-100, default 50 |
cursor |
string | No | From a previous response |
timezone |
string | No | IANA timezone, default UTC |
get_call
Full detail for one call SID, including child legs.
| Parameter | Type | Required | Description |
|---|---|---|---|
call_sid |
string | Yes | Starts with "CA" |
timezone |
string | No | IANA timezone, default UTC |
get_recording
Recording metadata for a call: signed URL with expiry, duration, channel count, and transcript if one exists. Never returns audio bytes.
| Parameter | Type | Required | Description |
|---|---|---|---|
call_sid |
string | Yes | Starts with "CA" |
timezone |
string | No | IANA timezone, default UTC |
list_agents
TaskRouter worker roster with current activity status.
| Parameter | Type | Required | Description |
|---|---|---|---|
timezone |
string | No | IANA timezone, default UTC |
get_agent_activity
Activity and reservation history for one worker over a time window, with summary statistics.
| Parameter | Type | Required | Description |
|---|---|---|---|
worker_sid |
string | Yes | Starts with "WK" |
start_date |
string | Yes | Window start |
end_date |
string | Yes | Window end |
timezone |
string | No | IANA timezone, default UTC |
call_summary
Aggregates by day, agent, or queue: call counts, talk time, and abandonment rate.
| Parameter | Type | Required | Description |
|---|---|---|---|
start_date |
string | Yes | Range start |
end_date |
string | Yes | Range end |
group_by |
enum | No | day, agent, or queue (default day) |
timezone |
string | No | IANA timezone, default UTC |
Talk Time: Why This Server Returns Two Numbers
Every tool that reports call duration returns two fields, not one:
{
"duration_seconds": { "value": 42, "source": "cdr", "measures": "ring_to_hangup" },
"talk_seconds": { "value": 35, "source": "taskrouter", "measures": "agent_audio_open" }
}
What is happening: Twilio's CDR (Call Detail Record) duration counts from the moment the call connects — including ring time — to hangup. TaskRouter and Flex Insights count from when the agent's audio path actually opens. For the same call, these numbers disagree. In the example above, seven seconds of ringing are counted as "duration" in the CDR but are not part of the actual conversation.
Why this matters: An LLM handed a bare duration: 42 will average it, bill against it, and compare it to a talk_time: 35 from another source without knowing these are different measurements of the same call. It will produce confident, wrong totals. A human reviewing those totals will not know to question them because the numbers look precise.
What we do: Every duration field in every response carries its source (where the number came from) and measures (what the number actually counts). A consumer that receives both fields can choose which one fits their question. A consumer that ignores the labels is making that choice explicitly rather than accidentally.
What this costs: Slightly larger payloads. Every response carries two labeled objects instead of one bare integer. The trade-off is worth it — seven seconds of mislabeled data across a month of calls adds up to hours of phantom talk time.
What This Server Does Not Do
- No call control. Cannot place, transfer, hold, or end calls.
- No messaging. Cannot send or read SMS/MMS.
- No resource modification. Cannot update call records, recordings, worker states, or any Twilio resource.
- No billing access. Cannot read account balance, usage records, or pricing.
- No audio streaming. Recording endpoints return signed URLs and metadata, never audio bytes.
If you need write operations, use the Twilio SDK directly. This server exists specifically to be safe to point an LLM at.
Decision Log
1. Never return one number for talk time
Naive approach: Return duration: 42 like Twilio's API does.
How it fails: A downstream consumer averages CDR durations against TaskRouter talk times from another report. The 7-second ring-time gap per call compounds across hundreds of calls into hours of phantom talk time. Worse, this looks correct — both numbers are real, they just measure different things.
What we do instead: Every duration field is a structured object with value, source, and measures. CDR duration and TaskRouter talk time are never presented as the same kind of number.
What it costs: Larger JSON payloads. Consumers must destructure an object instead of reading a bare integer. This friction is the point — it forces acknowledgment of what the number means.
2. Timezones are explicit or the tool refuses
Naive approach: Accept date strings and assume UTC, or worse, assume the server's local time.
How it fails: A twelve-hour offset between a source timestamp and the server's timezone assumption put an entire day of calls on the wrong date. The symptom looked like missing data — "we had no calls on Tuesday" — when the calls were there, just bucketed into Monday and Wednesday by a timezone mismatch.
What we do instead: Every tool accepts an optional timezone parameter (IANA identifiers only — "America/New_York", not "EST"). Defaults to UTC. Date range filters are interpreted in the caller's timezone. Every timestamp in every response carries its timezone. No implicit conversions.
What it costs: Callers must know their timezone, which they already do. Responses are slightly larger with the timezone field. The alternative — silent misattribution of calls to wrong dates — costs debugging hours.
3. Pagination that admits truncation
Naive approach: Return the first 50 rows. If there are more, include a next_page link.
How it fails: A consumer receives 50 rows and computes totals. There were actually 200 rows. Nothing in the response distinguished "there were 50 calls" from "we stopped at 50." The consumer's summary is confidently wrong by 75%.
What we do instead: Every paginated response includes has_more (boolean), truncated (boolean), total_available (integer or null), and cursor (opaque string). When a result set hits the page size limit, truncated: true tells the consumer explicitly that totals computed from this page are incomplete.
What it costs: One extra boolean and one nullable integer per response. Consumers that want complete data must page through. But now they know they need to.
4. Rate limits handled, not hoped about
Naive approach: Make the API call. If it fails, return the error.
How it fails: Twilio returns 429 under load. A bare error response gives the consumer no information about whether this is a transient throttle or a persistent problem, and no way to distinguish "the data doesn't exist" from "we couldn't get to it."
What we do instead: Exponential backoff with jitter on 429 and 5xx responses, up to 5 retries. Every response includes a retry_info object with the retry count and a throttled boolean. A consumer seeing retries: 3, throttled: true knows the data is fresh but the API is under pressure.
What it costs: Worst case, a single tool call takes 30+ seconds instead of failing immediately. Retry count in the response adds a small object to every payload. The alternative — silent failures that look like empty data — is worse.
Development
npm install
npm test # runs Vitest with fixture data, no credentials needed
npm run build # compiles TypeScript
npm run dev # runs with tsx for development
Tests use recorded and scrubbed Twilio API responses. No credentials are needed to run CI.
License
MIT