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.

See a full call walkthroughConnect your agent

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 next

Tool 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.

See a full call walkthroughNew to MCP? Start hereConnect your agent
Connect