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

POST /api/mcp (JSON-RPC 2.0), GET /api/mcp (capability discovery)

Protocol version

2024-11-05

Transport

Stateless HTTP POST — no SSE, no per-connection state

Server info

dialer-mcp v`1.0.0`

Authentication

Authorization: Bearer <MCP_API_SECRET>

Tenant

x-subscription-id: <subscriptionId>, required on every call

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

app/api/mcp/route.ts

JSON-RPC server

lib/mcp/server.ts

Tool registry

lib/mcp/registry.ts

Type contracts (IMcpTool, McpContext, result helpers)

lib/mcp/types.ts

Tool bootstrap

lib/mcp/tools/index.ts

Tool implementations

lib/mcp/tools/*.ts

Input schemas

lib/schema/mcp.ts

Shared secret

MCP_API_SECRET in lib/constants.ts

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 — McpServer catches ZodError, so tool handle() 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_sms and send_email write Activity rows 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

MCP_API_SECRET unset

-32603 Server misconfiguration

500

Missing or wrong bearer token

-32600 Unauthorized

401

Missing x-subscription-id

-32600

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

initialize

Handshake; returns protocolVersion, serverInfo, capabilities

notifications/initialized

Client acknowledgement; returns an empty result

tools/list

Definitions (name, description, JSON Schema) for every registered tool

tools/call

Dispatches to params.name with params.arguments

Unknown methods return -32601 Method not found.

Error codes

Code Meaning

-32700

Parse error — invalid JSON body

-32600

Invalid request, unauthorized, or missing tenant header

-32601

Method not found, or tool not found

-32602

Invalid params — e.g. missing params.name on tools/call

-32603

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 result carries isError: true and a human-readable message. This lets the calling model read the message and correct itself instead of the transport failing.

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; the from number is auto-selected from the workspace’s SMS-enabled numbers; every field is forwarded as a Retell dynamic variable; an Activity row 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_id is supplied, logs an email engagement on that contact (best-effort); writes an Activity row and an Emails row (best-effort)

Returns

{ success, email_id, to, subject, hubspot: { logged, engagement_id?, error? } }

Adding a tool

  1. Create lib/mcp/tools/my-new-tool.ts implementing IMcpTool:

    • a definition with name, description and inputSchema (a JSON-Schema subset);

    • handle(input, context) that validates with a Zod schema from lib/schema/mcp.ts, scopes every query by context.subscriptionId, and delegates to a service or repository;

    • return through the okResult(data) / errorResult(message) helpers in lib/mcp/types.ts.

  2. Add the input schema to lib/schema/mcp.ts.

  3. 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
  }
}