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
168 changes: 168 additions & 0 deletions src/auth/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';

/**
* OAuth configuration
*/
const OAUTH_CONFIG = {
// Remote MCP server OAuth endpoint
authorizeUrl: process.env.VAPI_OAUTH_URL || 'https://mcp.vapi.ai/authorize',
tokenInfoUrl: process.env.VAPI_TOKEN_INFO_URL || 'https://mcp.vapi.ai/oauth/token-info',
pollInterval: 5000, // 5 seconds
pollTimeout: 120000, // 2 minutes
};

/**
* OAuth token storage location
*/
const CONFIG_DIR = path.join(os.homedir(), '.vapi');
const CONFIG_FILE = path.join(CONFIG_DIR, 'mcp-config.json');

/**
* Interface for stored OAuth credentials
*/
interface OAuthCredentials {
apiKey: string;
orgId: string;
userId: string;
email: string;
timestamp: number;
}

/**
* Check if OAuth credentials are stored
*/
export function hasStoredCredentials(): boolean {
try {
return fs.existsSync(CONFIG_FILE);
} catch (error) {
return false;
}
}

/**
* Load stored OAuth credentials
*/
export function loadStoredCredentials(): OAuthCredentials | null {
try {
if (!fs.existsSync(CONFIG_FILE)) {
return null;
}

const data = fs.readFileSync(CONFIG_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('Failed to load OAuth credentials:', error);
return null;
}
}

/**
* Save OAuth credentials to disk
*/
export function saveCredentials(credentials: OAuthCredentials): void {
try {
// Create config directory if it doesn't exist
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}

fs.writeFileSync(CONFIG_FILE, JSON.stringify(credentials, null, 2), 'utf8');
} catch (error) {
console.error('Failed to save OAuth credentials:', error);
throw error;
}
}

/**
* Generate OAuth authorization URL
*/
export function generateOAuthUrl(): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: 'vapi-mcp-client',
redirect_uri: 'http://localhost:3000/callback', // Will be handled by remote server
scope: 'read_profile read_data write_data',
});

return `${OAUTH_CONFIG.authorizeUrl}?${params.toString()}`;
}

/**
* Poll for OAuth completion and retrieve API key
*
* This function is called after the user completes OAuth in their browser.
* It polls the token-info endpoint to check if the OAuth flow is complete.
*
* @param accessToken - OAuth access token from the authorization flow
* @returns OAuth credentials including API key
*/
export async function pollForOAuthCompletion(accessToken: string): Promise<OAuthCredentials> {
const startTime = Date.now();

while (Date.now() - startTime < OAUTH_CONFIG.pollTimeout) {
try {
const response = await fetch(OAUTH_CONFIG.tokenInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});

if (response.ok) {
const data = await response.json();

if (data.apiKey) {
const credentials: OAuthCredentials = {
apiKey: data.apiKey,
orgId: data.orgId,
userId: data.userId,
email: data.email,
timestamp: Date.now(),
};

// Save credentials
saveCredentials(credentials);

return credentials;
}
}
} catch (error) {
// Continue polling on error
}

// Wait before next poll
await new Promise(resolve => setTimeout(resolve, OAUTH_CONFIG.pollInterval));
}

throw new Error('OAuth flow timed out. Please try again.');
}

/**
* Trigger OAuth flow
*
* This function is called when a tool is invoked without authentication.
* It throws an error with the OAuth URL, which Claude Desktop will display to the user.
*/
export function triggerOAuthFlow(): never {
const oauthUrl = generateOAuthUrl();

throw new Error(
`Authentication required. Please complete OAuth authorization:\n\n${oauthUrl}\n\n` +
'After completing authorization, retry your request.'
);
}

/**
* Get API key from stored credentials or trigger OAuth
*/
export function getApiKeyOrTriggerOAuth(): string {
const credentials = loadStoredCredentials();

if (credentials && credentials.apiKey) {
return credentials.apiKey;
}

// No credentials found - trigger OAuth flow
triggerOAuthFlow();
}
9 changes: 4 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,14 @@ dotenv.config();

function createMcpServer() {
const vapiToken = process.env.VAPI_TOKEN;
if (!vapiToken) {
throw new Error('VAPI_TOKEN environment variable is required');
}

const vapiClient = createVapiClient(vapiToken);
// Create client only if token is provided
// OAuth flow will set the token later
const vapiClient = vapiToken ? createVapiClient(vapiToken) : null;

const mcpServer = new McpServer({
name: 'Vapi MCP',
version: '0.1.0',
version: '0.2.0',
capabilities: [],
});

Expand Down
6 changes: 5 additions & 1 deletion src/tools/assistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import { createToolHandler } from './utils.js';

export const registerAssistantTools = (
server: McpServer,
vapiClient: VapiClient
getClient: () => VapiClient
) => {
server.tool(
'list_assistants',
'Lists all Vapi assistants',
{},
createToolHandler(async () => {
// console.log('list_assistants');
const vapiClient = getClient();
const assistants = await vapiClient.assistants.list({ limit: 10 });
// console.log('assistants', assistants);
return assistants.map(transformAssistantOutput);
Expand All @@ -34,6 +35,7 @@ export const registerAssistantTools = (
CreateAssistantInputSchema.shape,
createToolHandler(async (data) => {
// console.log('create_assistant', data);
const vapiClient = getClient();
const createAssistantDto = transformAssistantInput(data);
const assistant = await vapiClient.assistants.create(createAssistantDto);
return transformAssistantOutput(assistant);
Expand All @@ -46,6 +48,7 @@ export const registerAssistantTools = (
GetAssistantInputSchema.shape,
createToolHandler(async (data) => {
// console.log('get_assistant', data);
const vapiClient = getClient();
const assistantId = data.assistantId;
try {
const assistant = await vapiClient.assistants.get(assistantId);
Expand All @@ -65,6 +68,7 @@ export const registerAssistantTools = (
'Updates an existing Vapi assistant',
UpdateAssistantInputSchema.shape,
createToolHandler(async (data) => {
const vapiClient = getClient();
const assistantId = data.assistantId;
try {
// First check if the assistant exists
Expand Down
5 changes: 4 additions & 1 deletion src/tools/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import { createToolHandler } from './utils.js';

export const registerCallTools = (
server: McpServer,
vapiClient: VapiClient
getClient: () => VapiClient
) => {
server.tool(
'list_calls',
'Lists all Vapi calls',
{},
createToolHandler(async () => {
const vapiClient = getClient();
const calls = await vapiClient.calls.list({ limit: 10 });
return calls.map(transformCallOutput);
})
Expand All @@ -27,6 +28,7 @@ export const registerCallTools = (
'Creates a outbound call',
CallInputSchema.shape,
createToolHandler(async (data) => {
const vapiClient = getClient();
const createCallDto = transformCallInput(data);
const call = await vapiClient.calls.create(createCallDto);
return transformCallOutput(call as unknown as Vapi.Call);
Expand All @@ -38,6 +40,7 @@ export const registerCallTools = (
'Gets details of a specific call',
GetCallInputSchema.shape,
createToolHandler(async (data) => {
const vapiClient = getClient();
const call = await vapiClient.calls.get(data.callId);
return transformCallOutput(call);
})
Expand Down
25 changes: 20 additions & 5 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { VapiClient } from '@vapi-ai/server-sdk';
import { createVapiClient } from '../client.js';
import { getApiKeyOrTriggerOAuth } from '../auth/oauth.js';

import { registerAssistantTools } from './assistant.js';
import { registerCallTools } from './call.js';
import { registerPhoneNumberTools } from './phone-number.js';
import { registerToolTools } from './tool.js';

export const registerAllTools = (server: McpServer, vapiClient: VapiClient) => {
registerAssistantTools(server, vapiClient);
registerCallTools(server, vapiClient);
registerPhoneNumberTools(server, vapiClient);
registerToolTools(server, vapiClient);
export const registerAllTools = (server: McpServer, vapiClient: VapiClient | null) => {
// If client is not provided, create a lazy client that triggers OAuth on first use
let lazyClient: VapiClient | null = vapiClient;

const getClient = (): VapiClient => {
if (!lazyClient) {
// Trigger OAuth flow or get stored credentials
const apiKey = getApiKeyOrTriggerOAuth();
lazyClient = createVapiClient(apiKey);
}
return lazyClient;
};

// Register tools with lazy client initialization
registerAssistantTools(server, getClient);
registerCallTools(server, getClient);
registerPhoneNumberTools(server, getClient);
registerToolTools(server, getClient);
};
4 changes: 3 additions & 1 deletion src/tools/phone-number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { GetPhoneNumberInputSchema } from '../schemas/index.js';

export const registerPhoneNumberTools = (
server: McpServer,
vapiClient: VapiClient
getClient: () => VapiClient
) => {
server.tool(
'list_phone_numbers',
'Lists all Vapi phone numbers',
{},
createToolHandler(async () => {
const vapiClient = getClient();
const phoneNumbers = await vapiClient.phoneNumbers.list({ limit: 10 });
return phoneNumbers.map(transformPhoneNumberOutput);
})
Expand All @@ -24,6 +25,7 @@ export const registerPhoneNumberTools = (
'Gets details of a specific phone number',
GetPhoneNumberInputSchema.shape,
createToolHandler(async (data) => {
const vapiClient = getClient();
const phoneNumberId = data.phoneNumberId;
const phoneNumber = await vapiClient.phoneNumbers.get(phoneNumberId);
return transformPhoneNumberOutput(phoneNumber);
Expand Down
6 changes: 5 additions & 1 deletion src/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import { createToolHandler } from './utils.js';

export const registerToolTools = (
server: McpServer,
vapiClient: VapiClient
getClient: () => VapiClient
) => {
server.tool(
'list_tools',
'Lists all Vapi tools',
{},
createToolHandler(async () => {
const vapiClient = getClient();
const tools = await vapiClient.tools.list({ limit: 10 });
return tools.map(transformToolOutput);
})
Expand All @@ -24,6 +25,7 @@ export const registerToolTools = (
'Gets details of a specific tool',
GetToolInputSchema.shape,
createToolHandler(async (data) => {
const vapiClient = getClient();
const tool = await vapiClient.tools.get(data.toolId);
return transformToolOutput(tool);
})
Expand All @@ -34,6 +36,7 @@ export const registerToolTools = (
'Creates a new Vapi tool',
CreateToolInputSchema.shape,
createToolHandler(async (data) => {
const vapiClient = getClient();
const createToolDto = transformToolInput(data);
const tool = await vapiClient.tools.create(createToolDto);
return transformToolOutput(tool);
Expand All @@ -45,6 +48,7 @@ export const registerToolTools = (
'Updates an existing Vapi tool',
UpdateToolInputSchema.shape,
createToolHandler(async (data) => {
const vapiClient = getClient();
const updateToolDto = transformUpdateToolInput(data);
const tool = await vapiClient.tools.update(data.toolId, updateToolDto);
return transformToolOutput(tool);
Expand Down