twilio-call-data-mcp
# 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
```bash
npm install twilio-call-data-mcp
```
Or clone and build:
```bash
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.
```bash
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):
```json
{
"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:
```json
{
"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
```bash
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
TDQS
Scored across 6 tools
Each tool targets a distinct resource and action: searching calls, getting call detail, retrieving recording metadata, listing agents, getting agent activity, and aggregating summaries. There is no overlap between tools; even the two agent-related tools are clearly separated by current status versus historical activity.
Most tools follow a consistent verb_noun pattern (search_calls, get_call, get_recording, list_agents, get_agent_activity). The one outlier is call_summary, which drops the verb and uses a noun phrase instead of something like summarize_calls. This is a minor deviation that does not impede readability.
Six tools is within the ideal 3-15 range and each tool serves a clear purpose in the call data exploration and analytics domain. The count feels well-scoped without being bloated or too thin.
The tool set covers the full read-only lifecycle for Twilio call data: search, retrieve details, retrieve recordings, list agents, retrieve agent activity, and generate summaries. There are no obvious gaps for the stated purpose of analyzing call data; the domain is fully represented.