Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions mcp-server/src/schemas/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Schemas for MCP server tool inputs.
*
* These define the shape and descriptions of parameters accepted
* by the MCP tools for the Vapi API.
*/

export interface SchemaProperty {
type: string;
description: string;
enum?: string[];
}

export interface ToolSchema {
type: "object";
properties: Record<string, SchemaProperty>;
required?: string[];
}

/**
* Schema for the create_call tool input.
*
* The `phoneNumberId` description explicitly notes that outbound calls
* require a Twilio or Vonage imported number, and that Vapi-provisioned
* numbers are inbound-only.
*/
export const CallInputSchema: ToolSchema = {
type: "object",
properties: {
phoneNumberId: {
type: "string",
description:
"The ID of the phone number to use for the outbound call. " +
"Must be a Twilio or Vonage imported number for outbound calls. " +
"Vapi-provisioned numbers are inbound-only and cannot be used for outbound dialing. " +
'Use the "list_phone_numbers" tool to find numbers with provider "twilio" or "vonage".',
},
assistantId: {
type: "string",
description:
"The ID of the assistant to use for the call. " + "Provide either assistantId, squadId, or workflowId.",
},
workflowId: {
type: "string",
description:
"The ID of the workflow to use for the call. " + "Provide either assistantId, squadId, or workflowId.",
},
squadId: {
type: "string",
description:
"The ID of the squad to use for the call. " + "Provide either assistantId, squadId, or workflowId.",
},
customerId: {
type: "string",
description: "The ID of an existing customer to call.",
},
customerNumber: {
type: "string",
description: "The phone number of the customer to call (E.164 format, e.g. +14155551234).",
},
},
required: ["phoneNumberId"],
};

/**
* Schema for the list_phone_numbers tool input.
*/
export const ListPhoneNumbersSchema: ToolSchema = {
type: "object",
properties: {
limit: {
type: "string",
description: "Maximum number of phone numbers to return.",
},
},
};

/**
* Schema for the get_phone_number tool input.
*/
export const GetPhoneNumberSchema: ToolSchema = {
type: "object",
properties: {
id: {
type: "string",
description: "The unique identifier of the phone number to retrieve.",
},
},
required: ["id"],
};
68 changes: 68 additions & 0 deletions mcp-server/src/tools/call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* MCP tool definitions for Vapi call operations.
*
* These tool definitions follow the MCP (Model Context Protocol) tool
* specification and are used to expose Vapi call functionality to
* AI agents and other MCP clients.
*/

import type { ToolSchema } from "../schemas/index.js";
import { CallInputSchema, GetPhoneNumberSchema, ListPhoneNumbersSchema } from "../schemas/index.js";

export interface ToolDefinition {
name: string;
description: string;
inputSchema: ToolSchema;
}

/**
* Tool definition for creating outbound calls.
*
* IMPORTANT: Outbound calls require a Twilio or Vonage imported phone number.
* Vapi-provisioned numbers are inbound-only and cannot dial outbound.
* Use the "list_phone_numbers" tool to find numbers with provider
* "twilio" or "vonage" that support outbound calling.
*/
export const createCallTool: ToolDefinition = {
name: "create_call",
description:
"Creates an outbound phone call. " +
"IMPORTANT: Outbound calls require a Twilio or Vonage imported phone number. " +
"Vapi-provisioned numbers (provider: 'vapi') are inbound-only and cannot be used for outbound calls. " +
'Use the "list_phone_numbers" tool first to find a phone number with provider "twilio" or "vonage". ' +
"You must provide a phoneNumberId, at least one of assistantId/squadId/workflowId, " +
"and a customer to call (via customerId or customerNumber).",
inputSchema: CallInputSchema,
};

/**
* Tool definition for listing phone numbers.
*
* Returns phone numbers with their provider field exposed so users
* can identify which numbers support outbound calling.
*/
export const listPhoneNumbersTool: ToolDefinition = {
name: "list_phone_numbers",
description:
"Lists all phone numbers in your Vapi account. " +
"Each number includes a 'provider' field indicating the phone number type: " +
"'twilio', 'vonage', 'telnyx', or 'byo-phone-number' for imported numbers (support outbound calls), " +
"or 'vapi' for Vapi-provisioned numbers (inbound-only, cannot make outbound calls). " +
"Use this tool to find outbound-capable numbers before creating a call.",
inputSchema: ListPhoneNumbersSchema,
};

/**
* Tool definition for getting a specific phone number.
*
* Returns the phone number details including the provider field.
*/
export const getPhoneNumberTool: ToolDefinition = {
name: "get_phone_number",
description:
"Gets details of a specific phone number by ID. " +
"Returns the phone number configuration including the 'provider' field: " +
"'twilio', 'vonage', 'telnyx', or 'byo-phone-number' for imported numbers (support outbound calls), " +
"or 'vapi' for Vapi-provisioned numbers (inbound-only).",
inputSchema: GetPhoneNumberSchema,
};
172 changes: 172 additions & 0 deletions mcp-server/src/transformers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Transformers for MCP server inputs and outputs.
*
* These functions transform data between the MCP tool interface
* and the Vapi API format.
*/

/** Phone number providers that support outbound calling. */
const OUTBOUND_CAPABLE_PROVIDERS = ["twilio", "vonage", "telnyx", "byo-phone-number"] as const;

/** Phone number providers that are inbound-only. */
const INBOUND_ONLY_PROVIDERS = ["vapi"] as const;

export type OutboundCapableProvider = (typeof OUTBOUND_CAPABLE_PROVIDERS)[number];
export type InboundOnlyProvider = (typeof INBOUND_ONLY_PROVIDERS)[number];
export type PhoneNumberProvider = OutboundCapableProvider | InboundOnlyProvider;

export interface PhoneNumberOutput {
id: string;
orgId: string;
provider: PhoneNumberProvider;
number?: string;
name?: string;
createdAt: string;
updatedAt: string;
assistantId?: string;
workflowId?: string;
squadId?: string;
}

export interface PhoneNumberApiResponse {
id: string;
orgId: string;
provider: string;
number?: string;
name?: string;
createdAt: string;
updatedAt: string;
assistantId?: string;
workflowId?: string;
squadId?: string;
[key: string]: unknown;
}

/**
* Transforms a phone number API response into the MCP output format.
*
* Exposes the `provider` field so MCP users can identify which numbers
* support outbound calling (twilio, vonage, telnyx, byo-phone-number)
* versus inbound-only (vapi).
*/
export function transformPhoneNumberOutput(apiResponse: PhoneNumberApiResponse): PhoneNumberOutput {
return {
id: apiResponse.id,
orgId: apiResponse.orgId,
provider: apiResponse.provider as PhoneNumberProvider,
number: apiResponse.number,
name: apiResponse.name,
createdAt: apiResponse.createdAt,
updatedAt: apiResponse.updatedAt,
assistantId: apiResponse.assistantId,
workflowId: apiResponse.workflowId,
squadId: apiResponse.squadId,
};
}

export interface CallInput {
phoneNumberId?: string;
assistantId?: string;
workflowId?: string;
squadId?: string;
customerId?: string;
customer?: {
number: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

export interface CallApiPayload {
phoneNumberId?: string;
assistantId?: string;
workflowId?: string;
squadId?: string;
customerId?: string;
customer?: {
number: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

export class OutboundCallValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "OutboundCallValidationError";
}
}

/**
* Validates whether a phone number can be used for outbound calling.
*
* Vapi-provisioned numbers are inbound-only and cannot dial outbound.
* This pre-validates the phone number type before making the API call
* to provide a clear, actionable error message instead of the cryptic
* API error "Vapi Numbers Can't Dial Outbound Yet".
*
* @param phoneNumberId - The ID of the phone number to validate.
* @param fetchPhoneNumber - A function that fetches the phone number details by ID.
* @throws {OutboundCallValidationError} If the phone number is a Vapi-provisioned number.
*/
export async function validatePhoneNumberForOutbound(
phoneNumberId: string,
fetchPhoneNumber: (id: string) => Promise<PhoneNumberApiResponse>,
): Promise<void> {
const phoneNumber = await fetchPhoneNumber(phoneNumberId);

if (phoneNumber.provider === "vapi") {
throw new OutboundCallValidationError(
`Phone number "${phoneNumberId}" is a Vapi-provisioned number (provider: "vapi") and cannot be used for outbound calls. ` +
"Vapi-provisioned numbers are inbound-only. " +
"To make outbound calls, use a Twilio or Vonage imported number instead. " +
'You can check your available numbers with the "list_phone_numbers" tool and look for numbers with provider "twilio" or "vonage".',
);
}
}

/**
* Transforms call input from MCP format to the API payload format.
*
* If a `phoneNumberId` is provided, this function validates that the
* phone number is capable of outbound calling before returning the
* API payload. Vapi-provisioned numbers will be rejected with a
* clear error message.
*
* @param input - The MCP call input.
* @param fetchPhoneNumber - A function that fetches phone number details by ID.
* Required when `phoneNumberId` is provided in the input.
* @returns The API payload for creating an outbound call.
* @throws {OutboundCallValidationError} If a Vapi-provisioned number is used.
*/
export async function transformCallInput(
input: CallInput,
fetchPhoneNumber?: (id: string) => Promise<PhoneNumberApiResponse>,
): Promise<CallApiPayload> {
if (input.phoneNumberId && fetchPhoneNumber) {
await validatePhoneNumberForOutbound(input.phoneNumberId, fetchPhoneNumber);
}

const payload: CallApiPayload = {};

if (input.phoneNumberId !== undefined) {
payload.phoneNumberId = input.phoneNumberId;
}
if (input.assistantId !== undefined) {
payload.assistantId = input.assistantId;
}
if (input.workflowId !== undefined) {
payload.workflowId = input.workflowId;
}
if (input.squadId !== undefined) {
payload.squadId = input.squadId;
}
if (input.customerId !== undefined) {
payload.customerId = input.customerId;
}
if (input.customer !== undefined) {
payload.customer = input.customer;
}

return payload;
}
57 changes: 57 additions & 0 deletions tests/mcp-server/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { CallInputSchema, GetPhoneNumberSchema, ListPhoneNumbersSchema } from "../../mcp-server/src/schemas/index.js";

describe("CallInputSchema", () => {
it("should have phoneNumberId as a required field", () => {
expect(CallInputSchema.required).toContain("phoneNumberId");
});

it("should document that phoneNumberId requires Twilio or Vonage for outbound", () => {
const description = CallInputSchema.properties.phoneNumberId.description;
expect(description).toContain("Twilio");
expect(description).toContain("Vonage");
});

it("should document that Vapi numbers are inbound-only", () => {
const description = CallInputSchema.properties.phoneNumberId.description;
expect(description).toContain("Vapi-provisioned");
expect(description).toContain("inbound-only");
});

it("should reference list_phone_numbers tool for finding outbound-capable numbers", () => {
const description = CallInputSchema.properties.phoneNumberId.description;
expect(description).toContain("list_phone_numbers");
});

it("should include assistantId, workflowId, and squadId properties", () => {
expect(CallInputSchema.properties).toHaveProperty("assistantId");
expect(CallInputSchema.properties).toHaveProperty("workflowId");
expect(CallInputSchema.properties).toHaveProperty("squadId");
});

it("should include customer-related properties", () => {
expect(CallInputSchema.properties).toHaveProperty("customerId");
expect(CallInputSchema.properties).toHaveProperty("customerNumber");
});
});

describe("ListPhoneNumbersSchema", () => {
it("should be a valid object schema", () => {
expect(ListPhoneNumbersSchema.type).toBe("object");
});

it("should have an optional limit property", () => {
expect(ListPhoneNumbersSchema.properties).toHaveProperty("limit");
expect(ListPhoneNumbersSchema.required).toBeUndefined();
});
});

describe("GetPhoneNumberSchema", () => {
it("should require the id field", () => {
expect(GetPhoneNumberSchema.required).toContain("id");
});

it("should have an id property with string type", () => {
expect(GetPhoneNumberSchema.properties.id.type).toBe("string");
});
});
Loading
Loading