willo-mcp-server
Version:
MCP server for Willo API integration - list interviews, participants, and get detailed participant information
539 lines • 27.5 kB
JavaScript
#!/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