MCP server · phone calls
MCP Server for Phone Calls
CallMCP is an MCP server that gives an agent a phone tool. It exposes calling as a set of MCP tools — make_call, check_call_status, get_transcript, get_call_recording, and list_recent_calls — so an agent that already speaks the MCP tool-call contract can dial a number without a bespoke telephony integration.
How it fits together
An agent doesn't dial a phone directly. It sends a tools/call request to CallMCP, CallMCP dispatches the call through Twilio (the carrier that actually rings the number and carries the audio), the call happens, and the outcome — status, recording, transcript — flows back into CallMCP's call record. The agent reads that record with a second tool call once the call ends.
Agent
│ tools/call → make_call { agent_id, to, context, lead_id }
▼
CallMCP (MCP server, JSON-RPC 2.0 over HTTPS)
│ authenticates the request, resolves the agent's business,
│ dispatches an outbound call
▼
Twilio / telephony carrier
│ dials the number, carries the audio, ends the call
▼
Call record updated (status, duration, recording_url, transcript)
│
▼
Agent
│ tools/call → check_call_status → get_transcript → get_call_recording
▼
Agent reads the outcome and decides what happens nextTool schema
Every tool in CallMCP publishes a JSON Schema for its arguments, discoverable with tools/list before you ever call it. Here's make_call:
{
"name": "make_call",
"description": "Initiate a real outbound phone call via an AI agent",
"inputSchema": {
"type": "object",
"properties": {
"agent_id": { "type": "string", "description": "Agent ID to use for the call" },
"to": { "type": "string", "description": "Phone number to call (E.164, e.g. +19085551234)" },
"name": { "type": "string", "description": "Caller/lead name (optional)" },
"context": { "type": "string", "description": "Call context or reason (optional)" },
"first_message": { "type": "string", "description": "Override the agent greeting (optional)" },
"lead_id": { "type": "string", "description": "Existing lead ID to associate (optional)" },
"idempotency_key": { "type": "string", "description": "Retry-safe dedupe key — repeating the same key returns the original call instead of dialing again (optional but recommended)" }
},
"required": ["agent_id", "to"]
}
}agent_id and to are the only required fields. Everything else — a name for the greeting, a one-line reason the agent should open with, a lead to attach the call to, a dedupe key for safe retries — is optional context that shapes the call without changing the contract.
Sample call
A tools/call request is a normal JSON-RPC envelope: a method, a tool name, and arguments that match the schema above.
POST /mcp
Authorization: Bearer kc_live_...
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "make_call",
"arguments": {
"agent_id": "agt_9f2a1c",
"to": "+15551234567",
"context": "Confirm Thursday 2pm appointment",
"lead_id": "lead_88213"
}
}
}The response carries a call ID and a text summary a model can read directly, plus a structured object for code that wants fields instead of prose:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{
"type": "text",
"text": "Call initiated successfully.\nCall ID: call_3f9a1e2b\nConversation ID: conv_7c81ff90\nStatus: queued\nAgent: agt_9f2a1c\nTo: +15551234567"
}
],
"structuredContent": {
"success": true,
"call": {
"id": "call_3f9a1e2b",
"conversation_id": "conv_7c81ff90",
"status": "queued",
"agent_id": "agt_9f2a1c",
"business_id": "biz_44a1",
"lead_id": "lead_88213",
"to": "+15551234567"
}
}
}
}Save call.id. It's what you pass to check_call_status, get_transcript, and get_call_recording once the call ends.
Approval and safety
make_call requires a token scoped with calls:write — a read-only key can't place calls. It's also annotated destructiveHint: true and readOnlyHint: false in its tool definition, which is how MCP clients that implement human-in-the-loop confirmation (Claude included) know to flag it before executing, rather than treating it like a harmless read.
Config-changing actions go further than an annotation. Updating an agent's prompt, voice, or transfer number through update_agent_config takes an idempotency_key and, for high-impact changes, an authority envelope (human_confirmed, dashboard_session, or system_policy) — or the call can pass queue_for_approval: true and land in a dashboard queue instead of executing blind. Call it without sufficient authority and you get exactly this back:
{
"status": "needs_approval",
"code": "approval_required",
"business_id": "biz_44a1",
"request_id": "req_7c1a...",
"risk_level": "high",
"message": "This change is valid but needs human authority. Ask for confirmation, then retry with the same idempotency_key and an authority envelope.",
"approval": {
"mode": "human_confirmed",
"prompt": "Confirm: change the agent's voice to ...",
"confirmation_text": "Confirm: change the agent's voice to ...",
"expires_at": "2026-07-10T14:02:11Z",
"review_url": "/dashboard/approvals?request_id=req_7c1a..."
}
}Retry the same idempotency_key with a satisfied authority envelope and it executes; pass queue_for_approval: true up front instead and it persists that same request as pending_approval for a human to review in the dashboard rather than returning needs_approval back to the agent. Every change is also hashed and snapshotted before it applies, so a misconfiguration is fully recoverable. buy_number goes through this same broker, too — a purchase is carrier-billed the moment it executes, so calling it without a satisfied authority envelope returns pending_approval instead of buying the number. make_call is the one call/purchase tool that still dials immediately on a valid key with no broker in front of it; the destructive-hint annotation is its only gate today.
Fetching the transcript
Poll check_call_status with the call ID until the status is terminal, then pull the transcript:
{
"name": "get_transcript",
"arguments": { "call_id": "call_3f9a1e2b" }
}Before the call ends, or if nothing was captured, the response says so instead of guessing:
{
"success": true,
"call": { "id": "call_3f9a1e2b", "status": "in-progress", "summary": null },
"transcript_available": false,
"transcript": null,
"transcript_truncated": false,
"transcript_length": 0
}Once the call has a transcript, you get the text back directly (truncated past 50,000 characters, with a note telling you the untruncated length):
{
"success": true,
"call": {
"id": "call_3f9a1e2b",
"agent_name": "Front Desk",
"status": "completed",
"duration_seconds": 94,
"summary": "Confirmed Thursday 2pm appointment.",
"recording_url": "https://.../call_3f9a1e2b.mp3"
},
"transcript_available": true,
"transcript": "AI: Hi, this is Front Desk calling to confirm...\nHuman: Yes, that works...",
"transcript_truncated": false,
"transcript_length": 812
}get_call_recording returns the same call shape with just the audio URL if all you need is the recording, and list_recent_calls returns a paginated feed of calls (filterable by agent_id or status) when you want the log instead of a single call.
Numbers — search, buy, attach
A number is a separate step from an agent. search_available_numbers is a live Twilio inventory lookup, checked in real time:
{
"name": "search_available_numbers",
"arguments": { "area_code": "415", "country": "US", "limit": 5 }
}
// → { "available_numbers": ["+14155550101", "+14155550118", ...] }buy_number takes one of those exact strings and purchases it from the carrier — this spends real money the moment it executes, which is why it's authority-gated the same way update_agent_config is (see "Approval and safety" above). With a satisfied authority envelope, it buys the number immediately:
{
"name": "buy_number",
"arguments": { "phone_number": "+14155550101" }
}
// → {
// "business_id": "biz_44a1",
// "number": { "phone_number": "+14155550101", "status": "active" },
// "compliance": { "high_risk_category": false, "disclosure_note": "..." }
// }Without one, you get back pending_approval instead of a purchased number — the same shape as the needs_approval response shown above, for a human to approve or deny from the dashboard.
Buying doesn't auto-attach an agent — call attach_number with the number and an agent_id to route calls to it, or omit agent_id to just park it in your business's pool. detach_number releases a number back to the unassigned registry (annotated destructiveHint: true — it drops routing), and list_numbers reads back what you currently hold.
Webhooks — push instead of poll
set_webhook registers a URL and the event types you want pushed to it. It returns a signing secret once, on creation — store it, it isn't retrievable again:
{
"name": "set_webhook",
"arguments": {
"webhook_url": "https://your-agent.example.com/hooks/callmcp",
"events": ["call.completed", "lead.created", "sms.received"]
}
}
// → { "webhook": {...}, "webhook_secret": "9f2a1c4e...(64-char hex)", "events": [...] }16 event types exist today: call.received, call.started, call.completed, call.failed, sms.received, sms.sent, sms.delivered, sms.failed, voicemail.received, phone_number.assigned, phone_number.released, workspace.paused, workspace.canceled, workspace.teardown_completed, lead.created, and lead.scored. Every delivery is a signed POST — an HMAC-SHA256 of the timestamp and body, keyed by your webhook secret, in a header you verify before trusting the payload:
POST https://your-agent.example.com/hooks/callmcp
Content-Type: application/json
X-KaiCalls-Signature: t=1751932800,v1=<hmac-sha256("1751932800." + body, secret)>
X-KaiCalls-Event: call.completed
{
"event": "call.completed",
"event_id": "evt_9c21...",
"event_type": "call.completed",
"version": 1,
"business_id": "biz_44a1",
"occurred_at": "2026-07-09T14:03:45Z",
"idempotency_key": "call.completed:call_3f9a1e2b",
"object": { "type": "call", "id": "call_3f9a1e2b" },
"correlation": { "lead_id": "lead_88213", "agent_id": "agt_9f2a1c", "object_type": "call", "object_id": "call_3f9a1e2b", "idempotency_key": "call.completed:call_3f9a1e2b" },
"data": { "duration": 94, "status": "completed", "agent_name": "Front Desk", "summary": "Confirmed Thursday 2pm appointment." }
}Recompute the HMAC over `${timestamp}.${body}` with your secret and compare against the v1= value before acting on a delivery — that's what proves the POST actually came from CallMCP and not an attacker who found your URL. idempotency_key is stable per event, so a retried delivery (yours or a network retry) is safe to de-dupe on that field instead of re-processing.