MCP tool server
Dialer V2 exposes part of its calling, messaging and CRM capability as a Model Context Protocol server, so external AI orchestrators can discover and invoke it as tools over one HTTP endpoint.
| Property | Value |
|---|---|
Endpoint |
|
Protocol version |
|
Transport |
Stateless HTTP POST — no SSE, no per-connection state |
Server info |
|
Authentication |
|
Tenant |
|
Architecture
A thin plugin layer over the existing services. It contains no business logic of its own — each tool delegates to the same service the REST API uses.
HTTP (JSON-RPC 2.0) app/api/mcp/route.ts auth + tenant + JSON parse
↓ server.handleRequest()
McpServer lib/mcp/server.ts JSON-RPC dispatch, ZodError handling
↓ registry.getTool()
McpToolRegistry lib/mcp/registry.ts name → implementation
↓ tool.handle(input, ctx)
IMcpTool implementations lib/mcp/tools/*.ts Zod-validated, delegate down
↓
Services / Repositories lib/services, lib/repositories
| Concern | File |
|---|---|
HTTP endpoint |
|
JSON-RPC server |
|
Tool registry |
|
Type contracts ( |
|
Tool bootstrap |
|
Tool implementations |
|
Input schemas |
|
Shared secret |
|
Design decisions:
-
Registry pattern — the tool list is derived from registrations, and the registry throws on duplicate names at module load, catching typos at startup rather than at call time. It is built once per process.
-
Centralised validation —
McpServercatchesZodError, so toolhandle()methods stay free of validation plumbing. -
Scoped context — every tool receives
{ subscriptionId }and scopes its database access to it. A tool cannot bypass tenant isolation. -
Best-effort activity logging —
send_smsandsend_emailwriteActivityrows after a successful send; a logging failure is recorded but does not fail the tool call.
Authentication and tenancy
Authorization: Bearer <MCP_API_SECRET>
x-subscription-id: <subscriptionId>
| Condition | JSON-RPC code | HTTP |
|---|---|---|
|
|
500 |
Missing or wrong bearer token |
|
401 |
Missing |
|
400 |
Unlike the dashboard routes, MCP does not use Supabase sessions or the active_subscription_id
cookie — it authenticates with a shared secret and takes the tenant explicitly from the header.
JSON-RPC methods
| Method | Behaviour |
|---|---|
|
Handshake; returns |
|
Client acknowledgement; returns an empty result |
|
Definitions (name, description, JSON Schema) for every registered tool |
|
Dispatches to |
Unknown methods return -32601 Method not found.
Error codes
| Code | Meaning |
|---|---|
|
Parse error — invalid JSON body |
|
Invalid request, unauthorized, or missing tenant header |
|
Method not found, or tool not found |
|
Invalid params — e.g. missing |
|
Internal error, or server misconfiguration |
|
Input validation errors are not JSON-RPC errors. When a tool’s Zod schema rejects the arguments, the
server returns a successful JSON-RPC response whose |
Capability discovery
GET /api/mcp needs no authentication and returns a human-readable document:
{
"name": "Dialer MCP Server",
"protocol": "Model Context Protocol",
"version": "2024-11-05",
"transport": "HTTP POST",
"authentication": "Bearer token (MCP_API_SECRET)",
"tenant_header": "x-subscription-id",
"tools_count": 5,
"tools": [{ "name": "list_ai_sales_agents", "description": "…" }]
}
Tools
list_ai_sales_agents
List active caller-type AI agents in the workspace.
- Input
-
none
- Returns
-
{ agents: [{ id, name, retell_agent_id, prompt }], total } - Delegates to
-
AiAgentRepository.findCallerAgentsBySubscription()
list_call_history
Recent call history for a phone number, across every campaign in the workspace.
- Input
-
phone_number(E.164, required),limit(1–50, default 50) - Returns
-
{ phone_number, calls: […], total }— status, disposition, summary, transcript, AI agent, duration, recording URL, sentiment, callback info, timestamps - Delegates to
-
CallRepository.findCallsByPhoneNumber()
initiate_call
Initiate an outbound call for a campaign target.
- Input
-
target_id(UUID, required). The campaign is read from the target record. - Flow
-
resolve the target → verify its campaign belongs to the caller’s workspace → pre-validate (DNC, prior disposition, E.164) →
PhoneNumberCallingService.initiateCall() - Returns
-
{ call_id, provider_call_id, campaign_id, target_id, phone_number, phone_number_id, is_fallback_number }, or a tool error when pre-validation skips the call
send_sms
Start an outbound SMS conversation through the Retell SMS agent.
- Required input
-
agent_name,company_name,customer_name,customer_phone(E.164),product_interest,lead_stage(new,contacted,engaged,qualified,stalled),goal,initial_message - Optional input
-
last_interaction_summary,customer_notes,conversation_history([{ role, content, timestamp }]),follow_up_count,urgency(high,normal,low) - Behaviour
-
the Retell agent is fixed to
RETELL_SMS_AGENT_ID; thefromnumber is auto-selected from the workspace’s SMS-enabled numbers; every field is forwarded as a Retell dynamic variable; anActivityrow is written best-effort - Returns
-
{ chat_id, chat_status, from_number, to }
send_email
Send an email from alex@payability.com through Resend.
- Input
-
to(email, required),subject(required),body(plain text or HTML, required),hubspot_contact_id(optional) - Behaviour
-
sends through Resend; when
hubspot_contact_idis supplied, logs an email engagement on that contact (best-effort); writes anActivityrow and anEmailsrow (best-effort) - Returns
-
{ success, email_id, to, subject, hubspot: { logged, engagement_id?, error? } }
Adding a tool
-
Create
lib/mcp/tools/my-new-tool.tsimplementingIMcpTool:-
a
definitionwithname,descriptionandinputSchema(a JSON-Schema subset); -
handle(input, context)that validates with a Zod schema fromlib/schema/mcp.ts, scopes every query bycontext.subscriptionId, and delegates to a service or repository; -
return through the
okResult(data)/errorResult(message)helpers inlib/mcp/types.ts.
-
-
Add the input schema to
lib/schema/mcp.ts. -
Register it:
export const toolRegistry = new McpToolRegistry()
// …existing tools
.register(new MyNewTool())
Examples
Discover tools:
curl -sX POST https://<host>/api/mcp \
-H "Authorization: Bearer $MCP_API_SECRET" \
-H "x-subscription-id: $SUBSCRIPTION_ID" \
-H "Content-Type: application/json" \
-d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }'
Call a tool:
curl -sX POST https://<host>/api/mcp \
-H "Authorization: Bearer $MCP_API_SECRET" \
-H "x-subscription-id: $SUBSCRIPTION_ID" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "list_call_history", "arguments": { "phone_number": "+12025550100", "limit": 10 } }
}'
Successful result — the payload is a JSON string inside MCP text content:
{
"jsonrpc": "2.0",
"id": 2,
"result": { "content": [{ "type": "text", "text": "{ \"phone_number\": \"+12025550100\", \"calls\": [], \"total\": 0 }" }] }
}
Tool-level error — still a JSON-RPC success:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "Invalid input — phone_number: phone_number must be in E.164 format (e.g. +12025550100)" }],
"isError": true
}
}