UNPKG

willo-mcp-server

Version:

MCP server for Willo API integration - list interviews, participants, and get detailed participant information

539 lines 27.5 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; class WillowMCPServer { server; apiKey; baseUrl; constructor() { this.server = new Server({ name: "willo-mcp-server", version: "1.0.0", }, { capabilities: { tools: {}, }, }); // Get API key from environment variable this.apiKey = process.env.WILLO_API_KEY || process.env.WILLOW_API_KEY || ""; this.baseUrl = process.env.WILLO_BASE_URL || process.env.WILLOW_BASE_URL || "https://api.willotalent.com"; this.setupToolHandlers(); } setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "list_interviews", description: "List interviews from Willow API with optional filters", inputSchema: { type: "object", properties: { department: { type: "string", description: "Filter by department", }, owner: { type: "string", description: "Filter by owner", }, search: { type: "string", description: "Search term", }, ordering: { type: "string", description: "Sort order (e.g., 'scheduled_at', '-scheduled_at')", }, page_size: { type: "number", description: "Number of results per page (1-30)", minimum: 1, maximum: 30, }, api_key: { type: "string", description: "Willow API key (if not set in environment)", }, }, }, }, { name: "list_participants", description: "List participants with optional filters", inputSchema: { type: "object", properties: { interview: { type: "string", description: "Key of the interview to filter by", }, department: { type: "string", description: "Key of the department to filter by", }, owner: { type: "string", description: "Key of the owner to filter by", }, search: { type: "string", description: "Search by name, email, interview title", }, page_size: { type: "number", description: "Number of results per page (1-30)", minimum: 1, maximum: 30, }, api_key: { type: "string", description: "Willow API key (if not set in environment)", }, }, }, }, { name: "get_participant", description: "Get details for a specific participant", inputSchema: { type: "object", properties: { participant_id: { type: "string", description: "Participant ID to get details for", }, api_key: { type: "string", description: "Willow API key (if not set in environment)", }, }, required: ["participant_id"], }, }, { name: "find_participants_by_status", description: "Find all participants with a specific status across all interviews. Uses exact Willo status codes.", inputSchema: { type: "object", properties: { status: { type: "string", description: "Exact Willo status to filter by. Valid values: 'Default' (new/incomplete), 'Received' (pending evaluation), 'Accepted' (approved), 'Rejected' (declined)", enum: ["Default", "Received", "Accepted", "Rejected"], }, department: { type: "string", description: "Optional: Key of the department to filter by", }, owner: { type: "string", description: "Optional: Key of the owner to filter by", }, interview: { type: "string", description: "Optional: Key of specific interview to search within", }, api_key: { type: "string", description: "Willo API key (if not set in environment)", }, delay_ms: { type: "number", description: "Optional: Delay between API calls in milliseconds (default: 200ms, min: 100ms)", minimum: 100, }, }, required: ["status"], }, }, ], }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "list_interviews") { return await this.listInterviews(request.params.arguments); } else if (request.params.name === "list_participants") { return await this.listParticipants(request.params.arguments); } else if (request.params.name === "get_participant") { return await this.getParticipant(request.params.arguments); } else if (request.params.name === "find_participants_by_status") { return await this.findParticipantsByStatus(request.params.arguments); } throw new Error(`Unknown tool: ${request.params.name}`); }); } async listInterviews(args = {}) { const apiKey = args.api_key || this.apiKey; if (!apiKey) { throw new Error("API key is required. Set WILLO_API_KEY environment variable or provide api_key parameter."); } // Build query parameters const params = new URLSearchParams(); if (args.department) params.append("department", args.department); if (args.owner) params.append("owner", args.owner); if (args.search) params.append("search", args.search); if (args.ordering) params.append("ordering", args.ordering); if (args.page_size) { const pageSize = Math.max(1, Math.min(30, args.page_size)); params.append("page_size", pageSize.toString()); } const url = `${this.baseUrl}/api/integrations/v2/interviews/?${params.toString()}`; try { const response = await this.makeApiCallWithRetry(url, apiKey); if (!response.ok) { const errorText = await response.text(); throw new Error(`Willo API error (${response.status}): ${errorText}`); } const data = await response.json(); return { content: [ { type: "text", text: JSON.stringify({ count: data.count, next: data.next, previous: data.previous, results: data.results.map(interview => ({ key: interview.key, title: interview.title, owner: { key: interview.owner.key, email: interview.owner.email, full_name: interview.owner.full_name, }, organisation: { key: interview.organisation.key, name: interview.organisation.name, }, department: { key: interview.department.key, name: interview.department.name, }, invite_link: interview.invite_link, })), }, null, 2), }, ], }; } catch (error) { throw new Error(`Failed to fetch interviews: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async listParticipants(args = {}) { const apiKey = args.api_key || this.apiKey; if (!apiKey) { throw new Error("API key is required. Set WILLO_API_KEY environment variable or provide api_key parameter."); } // Build query parameters const params = new URLSearchParams(); if (args.interview) params.append("interview", args.interview); if (args.department) params.append("department", args.department); if (args.owner) params.append("owner", args.owner); if (args.search) params.append("search", args.search); if (args.page_size) { const pageSize = Math.max(1, Math.min(30, args.page_size)); params.append("page_size", pageSize.toString()); } const url = `${this.baseUrl}/api/integrations/v2/participants/?${params.toString()}`; try { const response = await this.makeApiCallWithRetry(url, apiKey); if (!response.ok) { const errorText = await response.text(); throw new Error(`Willo API error (${response.status}): ${errorText}`); } const data = await response.json(); return { content: [ { type: "text", text: JSON.stringify({ count: data.count, next: data.next, previous: data.previous, results: data.results.map(participant => ({ key: participant.key, name: participant.name, email: participant.email, phone: participant.phone, interview: { key: participant.interview.key, title: participant.interview.title, }, department: { key: participant.department.key, name: participant.department.name, }, created_at: participant.created_at, location: participant.location, showcase_link: participant.showcase_link, overview_link: participant.overview_link, avatar_remote_link: participant.avatar_remote_link, custom_id: participant.custom_id, availability: participant.availability, })), }, null, 2), }, ], }; } catch (error) { throw new Error(`Failed to fetch participants: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getParticipant(args = {}) { const apiKey = args.api_key || this.apiKey; if (!apiKey) { throw new Error("API key is required. Set WILLO_API_KEY environment variable or provide api_key parameter."); } if (!args.participant_id) { throw new Error("participant_id parameter is required"); } const url = `${this.baseUrl}/api/integrations/v2/participants/${args.participant_id}/`; try { const response = await this.makeApiCallWithRetry(url, apiKey); if (!response.ok) { const errorText = await response.text(); throw new Error(`Willo API error (${response.status}): ${errorText}`); } const participant = await response.json(); return { content: [ { type: "text", text: JSON.stringify({ key: participant.key, email: participant.email, name: participant.name, phone: participant.phone, status: participant.status, status_changed_by: participant.status_changed_by, status_changed_at: participant.status_changed_at, send_notifications: participant.send_notifications, custom_id: participant.custom_id, summary: participant.summary, interview: { key: participant.interview.key, title: participant.interview.title, }, department: { key: participant.department.key, name: participant.department.name, }, created_at: participant.created_at, updated_at: participant.updated_at, location: participant.location, showcase_link: participant.showcase_link, candidate_overview_link: participant.candidate_overview_link, avatar_remote_link: participant.avatar_remote_link, availability: participant.availability, answers: participant.answers?.map(answer => ({ key: answer.key, created_at: answer.created_at, updated_at: answer.updated_at, is_finished: answer.is_finished, media_extension: answer.media_extension, remote_link: answer.remote_link, is_media_deleted: answer.is_media_deleted, text: answer.text, spent_time: answer.spent_time, additional_info: answer.additional_info, type_specific_data: answer.type_specific_data, transcript: answer.transcript, status: answer.status, reason: answer.reason, question: { key: answer.question.key, text: answer.question.text, order: answer.question.order, max_duration: answer.question.max_duration, max_retakes: answer.question.max_retakes, answer_type: answer.question.answer_type, thinking_time: answer.question.thinking_time, max_characters: answer.question.max_characters, max_words: answer.question.max_words, type_specific_data: answer.question.type_specific_data, }, })), }, null, 2), }, ], }; } catch (error) { throw new Error(`Failed to fetch participant: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async makeApiCallWithRetry(url, apiKey, maxRetries = 3) { let lastError = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch(url, { method: "GET", headers: { "Authorization": apiKey, "Content-Type": "application/json", }, }); // If rate limited (429), wait and retry if (response.status === 429) { const retryAfter = response.headers.get('retry-after'); const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : Math.min(1000 * Math.pow(2, attempt), 10000); // Exponential backoff, max 10s if (attempt < maxRetries) { console.error(`Rate limited (429). Waiting ${waitTime}ms before retry ${attempt}/${maxRetries}`); await new Promise(resolve => setTimeout(resolve, waitTime)); continue; } else { throw new Error(`Rate limited by Willo API. Please wait ${Math.ceil(waitTime / 1000)} seconds and try again.`); } } return response; } catch (error) { lastError = error instanceof Error ? error : new Error('Unknown error'); if (attempt < maxRetries) { const waitTime = 1000 * Math.pow(2, attempt); // Exponential backoff console.error(`API call failed (attempt ${attempt}/${maxRetries}). Waiting ${waitTime}ms...`); await new Promise(resolve => setTimeout(resolve, waitTime)); } } } throw lastError || new Error('Max retries exceeded'); } async findParticipantsByStatus(args = {}) { const apiKey = args.api_key || this.apiKey; if (!apiKey) { throw new Error("API key is required. Set WILLO_API_KEY environment variable or provide api_key parameter."); } if (!args.status) { throw new Error("status parameter is required"); } const targetStatus = args.status; // Use exact status match const matchedParticipants = []; let currentPage = 1; let hasMorePages = true; try { // First, get all participants with optional filters while (hasMorePages) { const params = new URLSearchParams(); if (args.interview) params.append("interview", args.interview); if (args.department) params.append("department", args.department); if (args.owner) params.append("owner", args.owner); params.append("page_size", "30"); // Max page size const url = `${this.baseUrl}/api/integrations/v2/participants/?${params.toString()}`; const response = await this.makeApiCallWithRetry(url, apiKey); if (!response.ok) { const errorText = await response.text(); throw new Error(`Willo API error (${response.status}): ${errorText}`); } const data = await response.json(); // For each participant, get detailed info to check status // Process in smaller batches to avoid overwhelming the API const batchSize = 5; for (let i = 0; i < data.results.length; i += batchSize) { const batch = data.results.slice(i, i + batchSize); for (const participant of batch) { try { const detailUrl = `${this.baseUrl}/api/integrations/v2/participants/${participant.key}/`; const detailResponse = await this.makeApiCallWithRetry(detailUrl, apiKey); if (detailResponse.ok) { const detailedParticipant = await detailResponse.json(); // Check if status matches exactly if (detailedParticipant.status === targetStatus) { matchedParticipants.push({ key: detailedParticipant.key, name: detailedParticipant.name, email: detailedParticipant.email, phone: detailedParticipant.phone, status: detailedParticipant.status, status_changed_by: detailedParticipant.status_changed_by, status_changed_at: detailedParticipant.status_changed_at, interview: { key: detailedParticipant.interview.key, title: detailedParticipant.interview.title, }, department: { key: detailedParticipant.department.key, name: detailedParticipant.department.name, }, created_at: detailedParticipant.created_at, updated_at: detailedParticipant.updated_at, location: detailedParticipant.location, showcase_link: detailedParticipant.showcase_link, candidate_overview_link: detailedParticipant.candidate_overview_link, custom_id: detailedParticipant.custom_id, summary: detailedParticipant.summary, }); } } // Add configurable delay between individual calls const delayMs = Math.max(100, args.delay_ms || 200); await new Promise(resolve => setTimeout(resolve, delayMs)); } catch (detailError) { // If it's a rate limit error, provide helpful message if (detailError instanceof Error && detailError.message.includes('Rate limited')) { console.error(`Rate limiting encountered while processing participant ${participant.key}. Consider trying again in a few moments.`); // Continue with other participants instead of failing completely continue; } console.error(`Failed to get details for participant ${participant.key}:`, detailError); } } // Add longer delay between batches if (i + batchSize < data.results.length) { await new Promise(resolve => setTimeout(resolve, 500)); } } // Check if there are more pages hasMorePages = !!data.next; currentPage++; // Safety check to prevent infinite loops if (currentPage > 100) { console.error("Reached maximum page limit (100), stopping pagination"); break; } } return { content: [ { type: "text", text: JSON.stringify({ status_filter: args.status, total_matched: matchedParticipants.length, participants: matchedParticipants, }, null, 2), }, ], }; } catch (error) { throw new Error(`Failed to find participants by status: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Willo MCP server running on stdio"); } } const server = new WillowMCPServer(); server.run().catch(console.error); //# sourceMappingURL=index.js.map