From 209386f0fdd8f3ff4088108b1bec1f3c4c84e06d Mon Sep 17 00:00:00 2001 From: Vapi Tasker Date: Fri, 23 Jan 2026 18:58:57 +0000 Subject: [PATCH] feat: add OAuth-on-first-use to public MCP server Make VAPI_TOKEN optional and add OAuth flow for frictionless setup. Changes: - Make VAPI_TOKEN optional in server startup - Add OAuth flow handler with automatic account creation - Add token storage to ~/.vapi/mcp-config.json - Wrap all tool handlers to trigger OAuth on first use - Update version to 0.2.0 Users can now paste MCP config without API key, and OAuth will trigger automatically on first tool call. Related: VAP-11408 Co-Authored-By: Claude --- src/auth/oauth.ts | 168 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 9 +- src/tools/assistant.ts | 6 +- src/tools/call.ts | 5 +- src/tools/index.ts | 25 ++++-- src/tools/phone-number.ts | 4 +- src/tools/tool.ts | 6 +- 7 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 src/auth/oauth.ts diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts new file mode 100644 index 0000000..e1e5608 --- /dev/null +++ b/src/auth/oauth.ts @@ -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 { + 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(); +} diff --git a/src/index.ts b/src/index.ts index 5e7ee22..2188ddf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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: [], }); diff --git a/src/tools/assistant.ts b/src/tools/assistant.ts index 9341ef1..87d7107 100644 --- a/src/tools/assistant.ts +++ b/src/tools/assistant.ts @@ -14,7 +14,7 @@ import { createToolHandler } from './utils.js'; export const registerAssistantTools = ( server: McpServer, - vapiClient: VapiClient + getClient: () => VapiClient ) => { server.tool( 'list_assistants', @@ -22,6 +22,7 @@ export const registerAssistantTools = ( {}, 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); @@ -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); @@ -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); @@ -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 diff --git a/src/tools/call.ts b/src/tools/call.ts index 408e347..2712d72 100644 --- a/src/tools/call.ts +++ b/src/tools/call.ts @@ -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); }) @@ -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); @@ -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); }) diff --git a/src/tools/index.ts b/src/tools/index.ts index f19b2a5..8381691 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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); }; diff --git a/src/tools/phone-number.ts b/src/tools/phone-number.ts index 3d460e3..393090c 100644 --- a/src/tools/phone-number.ts +++ b/src/tools/phone-number.ts @@ -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); }) @@ -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); diff --git a/src/tools/tool.ts b/src/tools/tool.ts index d487e1e..ce10a4d 100644 --- a/src/tools/tool.ts +++ b/src/tools/tool.ts @@ -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); }) @@ -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); }) @@ -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); @@ -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);