Skip to content

Commit f4bc315

Browse files
Vapi Taskerclaude
andcommitted
fix: add MCP server outbound phone number validation and guidance
Prevent cryptic API errors when MCP users attempt outbound calls with Vapi-provisioned (inbound-only) phone numbers by adding pre-validation and clear tool descriptions. Changes: - Add transformPhoneNumberOutput to expose provider field, letting users identify which numbers support outbound calling - Update create_call tool description to warn that outbound requires Twilio or Vonage imported numbers - Update CallInputSchema phoneNumberId description to document the Vapi inbound-only restriction and reference list_phone_numbers tool - Add validatePhoneNumberForOutbound pre-validation in transformCallInput that throws OutboundCallValidationError with actionable guidance Resolves: VAPICS-696 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fb17949 commit f4bc315

6 files changed

Lines changed: 759 additions & 0 deletions

File tree

mcp-server/src/schemas/index.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Schemas for MCP server tool inputs.
3+
*
4+
* These define the shape and descriptions of parameters accepted
5+
* by the MCP tools for the Vapi API.
6+
*/
7+
8+
export interface SchemaProperty {
9+
type: string;
10+
description: string;
11+
enum?: string[];
12+
}
13+
14+
export interface ToolSchema {
15+
type: "object";
16+
properties: Record<string, SchemaProperty>;
17+
required?: string[];
18+
}
19+
20+
/**
21+
* Schema for the create_call tool input.
22+
*
23+
* The `phoneNumberId` description explicitly notes that outbound calls
24+
* require a Twilio or Vonage imported number, and that Vapi-provisioned
25+
* numbers are inbound-only.
26+
*/
27+
export const CallInputSchema: ToolSchema = {
28+
type: "object",
29+
properties: {
30+
phoneNumberId: {
31+
type: "string",
32+
description:
33+
"The ID of the phone number to use for the outbound call. " +
34+
"Must be a Twilio or Vonage imported number for outbound calls. " +
35+
"Vapi-provisioned numbers are inbound-only and cannot be used for outbound dialing. " +
36+
'Use the "list_phone_numbers" tool to find numbers with provider "twilio" or "vonage".',
37+
},
38+
assistantId: {
39+
type: "string",
40+
description:
41+
"The ID of the assistant to use for the call. " + "Provide either assistantId, squadId, or workflowId.",
42+
},
43+
workflowId: {
44+
type: "string",
45+
description:
46+
"The ID of the workflow to use for the call. " + "Provide either assistantId, squadId, or workflowId.",
47+
},
48+
squadId: {
49+
type: "string",
50+
description:
51+
"The ID of the squad to use for the call. " + "Provide either assistantId, squadId, or workflowId.",
52+
},
53+
customerId: {
54+
type: "string",
55+
description: "The ID of an existing customer to call.",
56+
},
57+
customerNumber: {
58+
type: "string",
59+
description: "The phone number of the customer to call (E.164 format, e.g. +14155551234).",
60+
},
61+
},
62+
required: ["phoneNumberId"],
63+
};
64+
65+
/**
66+
* Schema for the list_phone_numbers tool input.
67+
*/
68+
export const ListPhoneNumbersSchema: ToolSchema = {
69+
type: "object",
70+
properties: {
71+
limit: {
72+
type: "string",
73+
description: "Maximum number of phone numbers to return.",
74+
},
75+
},
76+
};
77+
78+
/**
79+
* Schema for the get_phone_number tool input.
80+
*/
81+
export const GetPhoneNumberSchema: ToolSchema = {
82+
type: "object",
83+
properties: {
84+
id: {
85+
type: "string",
86+
description: "The unique identifier of the phone number to retrieve.",
87+
},
88+
},
89+
required: ["id"],
90+
};

mcp-server/src/tools/call.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* MCP tool definitions for Vapi call operations.
3+
*
4+
* These tool definitions follow the MCP (Model Context Protocol) tool
5+
* specification and are used to expose Vapi call functionality to
6+
* AI agents and other MCP clients.
7+
*/
8+
9+
import type { ToolSchema } from "../schemas/index.js";
10+
import { CallInputSchema, GetPhoneNumberSchema, ListPhoneNumbersSchema } from "../schemas/index.js";
11+
12+
export interface ToolDefinition {
13+
name: string;
14+
description: string;
15+
inputSchema: ToolSchema;
16+
}
17+
18+
/**
19+
* Tool definition for creating outbound calls.
20+
*
21+
* IMPORTANT: Outbound calls require a Twilio or Vonage imported phone number.
22+
* Vapi-provisioned numbers are inbound-only and cannot dial outbound.
23+
* Use the "list_phone_numbers" tool to find numbers with provider
24+
* "twilio" or "vonage" that support outbound calling.
25+
*/
26+
export const createCallTool: ToolDefinition = {
27+
name: "create_call",
28+
description:
29+
"Creates an outbound phone call. " +
30+
"IMPORTANT: Outbound calls require a Twilio or Vonage imported phone number. " +
31+
"Vapi-provisioned numbers (provider: 'vapi') are inbound-only and cannot be used for outbound calls. " +
32+
'Use the "list_phone_numbers" tool first to find a phone number with provider "twilio" or "vonage". ' +
33+
"You must provide a phoneNumberId, at least one of assistantId/squadId/workflowId, " +
34+
"and a customer to call (via customerId or customerNumber).",
35+
inputSchema: CallInputSchema,
36+
};
37+
38+
/**
39+
* Tool definition for listing phone numbers.
40+
*
41+
* Returns phone numbers with their provider field exposed so users
42+
* can identify which numbers support outbound calling.
43+
*/
44+
export const listPhoneNumbersTool: ToolDefinition = {
45+
name: "list_phone_numbers",
46+
description:
47+
"Lists all phone numbers in your Vapi account. " +
48+
"Each number includes a 'provider' field indicating the phone number type: " +
49+
"'twilio', 'vonage', 'telnyx', or 'byo-phone-number' for imported numbers (support outbound calls), " +
50+
"or 'vapi' for Vapi-provisioned numbers (inbound-only, cannot make outbound calls). " +
51+
"Use this tool to find outbound-capable numbers before creating a call.",
52+
inputSchema: ListPhoneNumbersSchema,
53+
};
54+
55+
/**
56+
* Tool definition for getting a specific phone number.
57+
*
58+
* Returns the phone number details including the provider field.
59+
*/
60+
export const getPhoneNumberTool: ToolDefinition = {
61+
name: "get_phone_number",
62+
description:
63+
"Gets details of a specific phone number by ID. " +
64+
"Returns the phone number configuration including the 'provider' field: " +
65+
"'twilio', 'vonage', 'telnyx', or 'byo-phone-number' for imported numbers (support outbound calls), " +
66+
"or 'vapi' for Vapi-provisioned numbers (inbound-only).",
67+
inputSchema: GetPhoneNumberSchema,
68+
};
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Transformers for MCP server inputs and outputs.
3+
*
4+
* These functions transform data between the MCP tool interface
5+
* and the Vapi API format.
6+
*/
7+
8+
/** Phone number providers that support outbound calling. */
9+
const OUTBOUND_CAPABLE_PROVIDERS = ["twilio", "vonage", "telnyx", "byo-phone-number"] as const;
10+
11+
/** Phone number providers that are inbound-only. */
12+
const INBOUND_ONLY_PROVIDERS = ["vapi"] as const;
13+
14+
export type OutboundCapableProvider = (typeof OUTBOUND_CAPABLE_PROVIDERS)[number];
15+
export type InboundOnlyProvider = (typeof INBOUND_ONLY_PROVIDERS)[number];
16+
export type PhoneNumberProvider = OutboundCapableProvider | InboundOnlyProvider;
17+
18+
export interface PhoneNumberOutput {
19+
id: string;
20+
orgId: string;
21+
provider: PhoneNumberProvider;
22+
number?: string;
23+
name?: string;
24+
createdAt: string;
25+
updatedAt: string;
26+
assistantId?: string;
27+
workflowId?: string;
28+
squadId?: string;
29+
}
30+
31+
export interface PhoneNumberApiResponse {
32+
id: string;
33+
orgId: string;
34+
provider: string;
35+
number?: string;
36+
name?: string;
37+
createdAt: string;
38+
updatedAt: string;
39+
assistantId?: string;
40+
workflowId?: string;
41+
squadId?: string;
42+
[key: string]: unknown;
43+
}
44+
45+
/**
46+
* Transforms a phone number API response into the MCP output format.
47+
*
48+
* Exposes the `provider` field so MCP users can identify which numbers
49+
* support outbound calling (twilio, vonage, telnyx, byo-phone-number)
50+
* versus inbound-only (vapi).
51+
*/
52+
export function transformPhoneNumberOutput(apiResponse: PhoneNumberApiResponse): PhoneNumberOutput {
53+
return {
54+
id: apiResponse.id,
55+
orgId: apiResponse.orgId,
56+
provider: apiResponse.provider as PhoneNumberProvider,
57+
number: apiResponse.number,
58+
name: apiResponse.name,
59+
createdAt: apiResponse.createdAt,
60+
updatedAt: apiResponse.updatedAt,
61+
assistantId: apiResponse.assistantId,
62+
workflowId: apiResponse.workflowId,
63+
squadId: apiResponse.squadId,
64+
};
65+
}
66+
67+
export interface CallInput {
68+
phoneNumberId?: string;
69+
assistantId?: string;
70+
workflowId?: string;
71+
squadId?: string;
72+
customerId?: string;
73+
customer?: {
74+
number: string;
75+
[key: string]: unknown;
76+
};
77+
[key: string]: unknown;
78+
}
79+
80+
export interface CallApiPayload {
81+
phoneNumberId?: string;
82+
assistantId?: string;
83+
workflowId?: string;
84+
squadId?: string;
85+
customerId?: string;
86+
customer?: {
87+
number: string;
88+
[key: string]: unknown;
89+
};
90+
[key: string]: unknown;
91+
}
92+
93+
export class OutboundCallValidationError extends Error {
94+
constructor(message: string) {
95+
super(message);
96+
this.name = "OutboundCallValidationError";
97+
}
98+
}
99+
100+
/**
101+
* Validates whether a phone number can be used for outbound calling.
102+
*
103+
* Vapi-provisioned numbers are inbound-only and cannot dial outbound.
104+
* This pre-validates the phone number type before making the API call
105+
* to provide a clear, actionable error message instead of the cryptic
106+
* API error "Vapi Numbers Can't Dial Outbound Yet".
107+
*
108+
* @param phoneNumberId - The ID of the phone number to validate.
109+
* @param fetchPhoneNumber - A function that fetches the phone number details by ID.
110+
* @throws {OutboundCallValidationError} If the phone number is a Vapi-provisioned number.
111+
*/
112+
export async function validatePhoneNumberForOutbound(
113+
phoneNumberId: string,
114+
fetchPhoneNumber: (id: string) => Promise<PhoneNumberApiResponse>,
115+
): Promise<void> {
116+
const phoneNumber = await fetchPhoneNumber(phoneNumberId);
117+
118+
if (phoneNumber.provider === "vapi") {
119+
throw new OutboundCallValidationError(
120+
`Phone number "${phoneNumberId}" is a Vapi-provisioned number (provider: "vapi") and cannot be used for outbound calls. ` +
121+
"Vapi-provisioned numbers are inbound-only. " +
122+
"To make outbound calls, use a Twilio or Vonage imported number instead. " +
123+
'You can check your available numbers with the "list_phone_numbers" tool and look for numbers with provider "twilio" or "vonage".',
124+
);
125+
}
126+
}
127+
128+
/**
129+
* Transforms call input from MCP format to the API payload format.
130+
*
131+
* If a `phoneNumberId` is provided, this function validates that the
132+
* phone number is capable of outbound calling before returning the
133+
* API payload. Vapi-provisioned numbers will be rejected with a
134+
* clear error message.
135+
*
136+
* @param input - The MCP call input.
137+
* @param fetchPhoneNumber - A function that fetches phone number details by ID.
138+
* Required when `phoneNumberId` is provided in the input.
139+
* @returns The API payload for creating an outbound call.
140+
* @throws {OutboundCallValidationError} If a Vapi-provisioned number is used.
141+
*/
142+
export async function transformCallInput(
143+
input: CallInput,
144+
fetchPhoneNumber?: (id: string) => Promise<PhoneNumberApiResponse>,
145+
): Promise<CallApiPayload> {
146+
if (input.phoneNumberId && fetchPhoneNumber) {
147+
await validatePhoneNumberForOutbound(input.phoneNumberId, fetchPhoneNumber);
148+
}
149+
150+
const payload: CallApiPayload = {};
151+
152+
if (input.phoneNumberId !== undefined) {
153+
payload.phoneNumberId = input.phoneNumberId;
154+
}
155+
if (input.assistantId !== undefined) {
156+
payload.assistantId = input.assistantId;
157+
}
158+
if (input.workflowId !== undefined) {
159+
payload.workflowId = input.workflowId;
160+
}
161+
if (input.squadId !== undefined) {
162+
payload.squadId = input.squadId;
163+
}
164+
if (input.customerId !== undefined) {
165+
payload.customerId = input.customerId;
166+
}
167+
if (input.customer !== undefined) {
168+
payload.customer = input.customer;
169+
}
170+
171+
return payload;
172+
}

tests/mcp-server/schemas.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
import { CallInputSchema, GetPhoneNumberSchema, ListPhoneNumbersSchema } from "../../mcp-server/src/schemas/index.js";
3+
4+
describe("CallInputSchema", () => {
5+
it("should have phoneNumberId as a required field", () => {
6+
expect(CallInputSchema.required).toContain("phoneNumberId");
7+
});
8+
9+
it("should document that phoneNumberId requires Twilio or Vonage for outbound", () => {
10+
const description = CallInputSchema.properties.phoneNumberId.description;
11+
expect(description).toContain("Twilio");
12+
expect(description).toContain("Vonage");
13+
});
14+
15+
it("should document that Vapi numbers are inbound-only", () => {
16+
const description = CallInputSchema.properties.phoneNumberId.description;
17+
expect(description).toContain("Vapi-provisioned");
18+
expect(description).toContain("inbound-only");
19+
});
20+
21+
it("should reference list_phone_numbers tool for finding outbound-capable numbers", () => {
22+
const description = CallInputSchema.properties.phoneNumberId.description;
23+
expect(description).toContain("list_phone_numbers");
24+
});
25+
26+
it("should include assistantId, workflowId, and squadId properties", () => {
27+
expect(CallInputSchema.properties).toHaveProperty("assistantId");
28+
expect(CallInputSchema.properties).toHaveProperty("workflowId");
29+
expect(CallInputSchema.properties).toHaveProperty("squadId");
30+
});
31+
32+
it("should include customer-related properties", () => {
33+
expect(CallInputSchema.properties).toHaveProperty("customerId");
34+
expect(CallInputSchema.properties).toHaveProperty("customerNumber");
35+
});
36+
});
37+
38+
describe("ListPhoneNumbersSchema", () => {
39+
it("should be a valid object schema", () => {
40+
expect(ListPhoneNumbersSchema.type).toBe("object");
41+
});
42+
43+
it("should have an optional limit property", () => {
44+
expect(ListPhoneNumbersSchema.properties).toHaveProperty("limit");
45+
expect(ListPhoneNumbersSchema.required).toBeUndefined();
46+
});
47+
});
48+
49+
describe("GetPhoneNumberSchema", () => {
50+
it("should require the id field", () => {
51+
expect(GetPhoneNumberSchema.required).toContain("id");
52+
});
53+
54+
it("should have an id property with string type", () => {
55+
expect(GetPhoneNumberSchema.properties.id.type).toBe("string");
56+
});
57+
});

0 commit comments

Comments
 (0)