Skip to content

Commit 209386f

Browse files
Vapi Taskerclaude
andcommitted
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 <noreply@anthropic.com>
1 parent c644117 commit 209386f

File tree

7 files changed

+209
-14
lines changed

7 files changed

+209
-14
lines changed

src/auth/oauth.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
5+
/**
6+
* OAuth configuration
7+
*/
8+
const OAUTH_CONFIG = {
9+
// Remote MCP server OAuth endpoint
10+
authorizeUrl: process.env.VAPI_OAUTH_URL || 'https://mcp.vapi.ai/authorize',
11+
tokenInfoUrl: process.env.VAPI_TOKEN_INFO_URL || 'https://mcp.vapi.ai/oauth/token-info',
12+
pollInterval: 5000, // 5 seconds
13+
pollTimeout: 120000, // 2 minutes
14+
};
15+
16+
/**
17+
* OAuth token storage location
18+
*/
19+
const CONFIG_DIR = path.join(os.homedir(), '.vapi');
20+
const CONFIG_FILE = path.join(CONFIG_DIR, 'mcp-config.json');
21+
22+
/**
23+
* Interface for stored OAuth credentials
24+
*/
25+
interface OAuthCredentials {
26+
apiKey: string;
27+
orgId: string;
28+
userId: string;
29+
email: string;
30+
timestamp: number;
31+
}
32+
33+
/**
34+
* Check if OAuth credentials are stored
35+
*/
36+
export function hasStoredCredentials(): boolean {
37+
try {
38+
return fs.existsSync(CONFIG_FILE);
39+
} catch (error) {
40+
return false;
41+
}
42+
}
43+
44+
/**
45+
* Load stored OAuth credentials
46+
*/
47+
export function loadStoredCredentials(): OAuthCredentials | null {
48+
try {
49+
if (!fs.existsSync(CONFIG_FILE)) {
50+
return null;
51+
}
52+
53+
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
54+
return JSON.parse(data);
55+
} catch (error) {
56+
console.error('Failed to load OAuth credentials:', error);
57+
return null;
58+
}
59+
}
60+
61+
/**
62+
* Save OAuth credentials to disk
63+
*/
64+
export function saveCredentials(credentials: OAuthCredentials): void {
65+
try {
66+
// Create config directory if it doesn't exist
67+
if (!fs.existsSync(CONFIG_DIR)) {
68+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
69+
}
70+
71+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(credentials, null, 2), 'utf8');
72+
} catch (error) {
73+
console.error('Failed to save OAuth credentials:', error);
74+
throw error;
75+
}
76+
}
77+
78+
/**
79+
* Generate OAuth authorization URL
80+
*/
81+
export function generateOAuthUrl(): string {
82+
const params = new URLSearchParams({
83+
response_type: 'code',
84+
client_id: 'vapi-mcp-client',
85+
redirect_uri: 'http://localhost:3000/callback', // Will be handled by remote server
86+
scope: 'read_profile read_data write_data',
87+
});
88+
89+
return `${OAUTH_CONFIG.authorizeUrl}?${params.toString()}`;
90+
}
91+
92+
/**
93+
* Poll for OAuth completion and retrieve API key
94+
*
95+
* This function is called after the user completes OAuth in their browser.
96+
* It polls the token-info endpoint to check if the OAuth flow is complete.
97+
*
98+
* @param accessToken - OAuth access token from the authorization flow
99+
* @returns OAuth credentials including API key
100+
*/
101+
export async function pollForOAuthCompletion(accessToken: string): Promise<OAuthCredentials> {
102+
const startTime = Date.now();
103+
104+
while (Date.now() - startTime < OAUTH_CONFIG.pollTimeout) {
105+
try {
106+
const response = await fetch(OAUTH_CONFIG.tokenInfoUrl, {
107+
headers: {
108+
Authorization: `Bearer ${accessToken}`,
109+
},
110+
});
111+
112+
if (response.ok) {
113+
const data = await response.json();
114+
115+
if (data.apiKey) {
116+
const credentials: OAuthCredentials = {
117+
apiKey: data.apiKey,
118+
orgId: data.orgId,
119+
userId: data.userId,
120+
email: data.email,
121+
timestamp: Date.now(),
122+
};
123+
124+
// Save credentials
125+
saveCredentials(credentials);
126+
127+
return credentials;
128+
}
129+
}
130+
} catch (error) {
131+
// Continue polling on error
132+
}
133+
134+
// Wait before next poll
135+
await new Promise(resolve => setTimeout(resolve, OAUTH_CONFIG.pollInterval));
136+
}
137+
138+
throw new Error('OAuth flow timed out. Please try again.');
139+
}
140+
141+
/**
142+
* Trigger OAuth flow
143+
*
144+
* This function is called when a tool is invoked without authentication.
145+
* It throws an error with the OAuth URL, which Claude Desktop will display to the user.
146+
*/
147+
export function triggerOAuthFlow(): never {
148+
const oauthUrl = generateOAuthUrl();
149+
150+
throw new Error(
151+
`Authentication required. Please complete OAuth authorization:\n\n${oauthUrl}\n\n` +
152+
'After completing authorization, retry your request.'
153+
);
154+
}
155+
156+
/**
157+
* Get API key from stored credentials or trigger OAuth
158+
*/
159+
export function getApiKeyOrTriggerOAuth(): string {
160+
const credentials = loadStoredCredentials();
161+
162+
if (credentials && credentials.apiKey) {
163+
return credentials.apiKey;
164+
}
165+
166+
// No credentials found - trigger OAuth flow
167+
triggerOAuthFlow();
168+
}

src/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@ dotenv.config();
1010

1111
function createMcpServer() {
1212
const vapiToken = process.env.VAPI_TOKEN;
13-
if (!vapiToken) {
14-
throw new Error('VAPI_TOKEN environment variable is required');
15-
}
1613

17-
const vapiClient = createVapiClient(vapiToken);
14+
// Create client only if token is provided
15+
// OAuth flow will set the token later
16+
const vapiClient = vapiToken ? createVapiClient(vapiToken) : null;
1817

1918
const mcpServer = new McpServer({
2019
name: 'Vapi MCP',
21-
version: '0.1.0',
20+
version: '0.2.0',
2221
capabilities: [],
2322
});
2423

src/tools/assistant.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ import { createToolHandler } from './utils.js';
1414

1515
export const registerAssistantTools = (
1616
server: McpServer,
17-
vapiClient: VapiClient
17+
getClient: () => VapiClient
1818
) => {
1919
server.tool(
2020
'list_assistants',
2121
'Lists all Vapi assistants',
2222
{},
2323
createToolHandler(async () => {
2424
// console.log('list_assistants');
25+
const vapiClient = getClient();
2526
const assistants = await vapiClient.assistants.list({ limit: 10 });
2627
// console.log('assistants', assistants);
2728
return assistants.map(transformAssistantOutput);
@@ -34,6 +35,7 @@ export const registerAssistantTools = (
3435
CreateAssistantInputSchema.shape,
3536
createToolHandler(async (data) => {
3637
// console.log('create_assistant', data);
38+
const vapiClient = getClient();
3739
const createAssistantDto = transformAssistantInput(data);
3840
const assistant = await vapiClient.assistants.create(createAssistantDto);
3941
return transformAssistantOutput(assistant);
@@ -46,6 +48,7 @@ export const registerAssistantTools = (
4648
GetAssistantInputSchema.shape,
4749
createToolHandler(async (data) => {
4850
// console.log('get_assistant', data);
51+
const vapiClient = getClient();
4952
const assistantId = data.assistantId;
5053
try {
5154
const assistant = await vapiClient.assistants.get(assistantId);
@@ -65,6 +68,7 @@ export const registerAssistantTools = (
6568
'Updates an existing Vapi assistant',
6669
UpdateAssistantInputSchema.shape,
6770
createToolHandler(async (data) => {
71+
const vapiClient = getClient();
6872
const assistantId = data.assistantId;
6973
try {
7074
// First check if the assistant exists

src/tools/call.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import { createToolHandler } from './utils.js';
1010

1111
export const registerCallTools = (
1212
server: McpServer,
13-
vapiClient: VapiClient
13+
getClient: () => VapiClient
1414
) => {
1515
server.tool(
1616
'list_calls',
1717
'Lists all Vapi calls',
1818
{},
1919
createToolHandler(async () => {
20+
const vapiClient = getClient();
2021
const calls = await vapiClient.calls.list({ limit: 10 });
2122
return calls.map(transformCallOutput);
2223
})
@@ -27,6 +28,7 @@ export const registerCallTools = (
2728
'Creates a outbound call',
2829
CallInputSchema.shape,
2930
createToolHandler(async (data) => {
31+
const vapiClient = getClient();
3032
const createCallDto = transformCallInput(data);
3133
const call = await vapiClient.calls.create(createCallDto);
3234
return transformCallOutput(call as unknown as Vapi.Call);
@@ -38,6 +40,7 @@ export const registerCallTools = (
3840
'Gets details of a specific call',
3941
GetCallInputSchema.shape,
4042
createToolHandler(async (data) => {
43+
const vapiClient = getClient();
4144
const call = await vapiClient.calls.get(data.callId);
4245
return transformCallOutput(call);
4346
})

src/tools/index.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import { VapiClient } from '@vapi-ai/server-sdk';
3+
import { createVapiClient } from '../client.js';
4+
import { getApiKeyOrTriggerOAuth } from '../auth/oauth.js';
35

46
import { registerAssistantTools } from './assistant.js';
57
import { registerCallTools } from './call.js';
68
import { registerPhoneNumberTools } from './phone-number.js';
79
import { registerToolTools } from './tool.js';
810

9-
export const registerAllTools = (server: McpServer, vapiClient: VapiClient) => {
10-
registerAssistantTools(server, vapiClient);
11-
registerCallTools(server, vapiClient);
12-
registerPhoneNumberTools(server, vapiClient);
13-
registerToolTools(server, vapiClient);
11+
export const registerAllTools = (server: McpServer, vapiClient: VapiClient | null) => {
12+
// If client is not provided, create a lazy client that triggers OAuth on first use
13+
let lazyClient: VapiClient | null = vapiClient;
14+
15+
const getClient = (): VapiClient => {
16+
if (!lazyClient) {
17+
// Trigger OAuth flow or get stored credentials
18+
const apiKey = getApiKeyOrTriggerOAuth();
19+
lazyClient = createVapiClient(apiKey);
20+
}
21+
return lazyClient;
22+
};
23+
24+
// Register tools with lazy client initialization
25+
registerAssistantTools(server, getClient);
26+
registerCallTools(server, getClient);
27+
registerPhoneNumberTools(server, getClient);
28+
registerToolTools(server, getClient);
1429
};

src/tools/phone-number.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import { GetPhoneNumberInputSchema } from '../schemas/index.js';
77

88
export const registerPhoneNumberTools = (
99
server: McpServer,
10-
vapiClient: VapiClient
10+
getClient: () => VapiClient
1111
) => {
1212
server.tool(
1313
'list_phone_numbers',
1414
'Lists all Vapi phone numbers',
1515
{},
1616
createToolHandler(async () => {
17+
const vapiClient = getClient();
1718
const phoneNumbers = await vapiClient.phoneNumbers.list({ limit: 10 });
1819
return phoneNumbers.map(transformPhoneNumberOutput);
1920
})
@@ -24,6 +25,7 @@ export const registerPhoneNumberTools = (
2425
'Gets details of a specific phone number',
2526
GetPhoneNumberInputSchema.shape,
2627
createToolHandler(async (data) => {
28+
const vapiClient = getClient();
2729
const phoneNumberId = data.phoneNumberId;
2830
const phoneNumber = await vapiClient.phoneNumbers.get(phoneNumberId);
2931
return transformPhoneNumberOutput(phoneNumber);

src/tools/tool.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import { createToolHandler } from './utils.js';
77

88
export const registerToolTools = (
99
server: McpServer,
10-
vapiClient: VapiClient
10+
getClient: () => VapiClient
1111
) => {
1212
server.tool(
1313
'list_tools',
1414
'Lists all Vapi tools',
1515
{},
1616
createToolHandler(async () => {
17+
const vapiClient = getClient();
1718
const tools = await vapiClient.tools.list({ limit: 10 });
1819
return tools.map(transformToolOutput);
1920
})
@@ -24,6 +25,7 @@ export const registerToolTools = (
2425
'Gets details of a specific tool',
2526
GetToolInputSchema.shape,
2627
createToolHandler(async (data) => {
28+
const vapiClient = getClient();
2729
const tool = await vapiClient.tools.get(data.toolId);
2830
return transformToolOutput(tool);
2931
})
@@ -34,6 +36,7 @@ export const registerToolTools = (
3436
'Creates a new Vapi tool',
3537
CreateToolInputSchema.shape,
3638
createToolHandler(async (data) => {
39+
const vapiClient = getClient();
3740
const createToolDto = transformToolInput(data);
3841
const tool = await vapiClient.tools.create(createToolDto);
3942
return transformToolOutput(tool);
@@ -45,6 +48,7 @@ export const registerToolTools = (
4548
'Updates an existing Vapi tool',
4649
UpdateToolInputSchema.shape,
4750
createToolHandler(async (data) => {
51+
const vapiClient = getClient();
4852
const updateToolDto = transformUpdateToolInput(data);
4953
const tool = await vapiClient.tools.update(data.toolId, updateToolDto);
5054
return transformToolOutput(tool);

0 commit comments

Comments
 (0)