UNPKG

@doverhq/mcp

Version:

Dover MCP server for accessing Dover job data

446 lines 19.8 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 DoverMCPServer { server; apiKey; baseUrl; constructor() { this.apiKey = process.env.DOVER_API_KEY || ''; this.baseUrl = process.env.DOVER_BASE_URL || 'https://app.dover.com'; if (!this.apiKey) { console.error('DOVER_API_KEY environment variable is required'); process.exit(1); } this.server = new Server({ name: 'dover-mcp-server', version: '0.1.0', }, { capabilities: { tools: {}, }, }); this.setupToolHandlers(); this.setupErrorHandling(); } setupErrorHandling() { this.server.onerror = (error) => { console.error('[MCP Error]', error); }; process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'list_jobs', description: 'List all active jobs from Dover', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'add_candidate', description: 'Add a candidate to a Dover job without sending email outreach', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'UUID of the job to add the candidate to' }, first_name: { type: 'string', description: 'Candidate\'s first name' }, last_name: { type: 'string', description: 'Candidate\'s last name' }, email: { type: 'string', description: 'Candidate\'s email address (optional)' }, linkedin_url: { type: 'string', description: 'LinkedIn profile URL (optional)' }, resume_url: { type: 'string', description: 'URL to candidate\'s resume (optional, must be PDF/DOC/DOCX/TXT)' }, pipeline_stage: { type: 'string', description: 'Pipeline stage name to add candidate to (e.g., "Initial Screening", "Technical Interview")' }, notes: { type: 'string', description: 'Additional comments about the candidate (max 5000 characters)' } }, required: ['job_id', 'first_name', 'last_name', 'pipeline_stage'] }, }, { name: 'source_candidate', description: 'Source a candidate with personalized email outreach. Returns email preview for user confirmation, or sends email if confirmed.', inputSchema: { type: 'object', properties: { job_id: { type: 'string', description: 'UUID of the job to source for' }, first_name: { type: 'string', description: 'Candidate\'s first name' }, last_name: { type: 'string', description: 'Candidate\'s last name' }, linkedin_url: { type: 'string', description: 'LinkedIn profile URL (required for sourcing)' }, email: { type: 'string', description: 'Candidate\'s email (leave empty to auto-discover)' }, send_email: { type: 'boolean', description: 'Set to true to actually send the email after preview. Default: false (preview only)', default: false }, custom_subject: { type: 'string', description: 'Override the generated email subject' }, custom_message: { type: 'string', description: 'Override the generated email body' } }, required: ['job_id', 'first_name', 'last_name', 'linkedin_url'] }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { if (name === 'list_jobs') { return await this.listJobs(); } else if (name === 'add_candidate') { return await this.addCandidate(args); } else if (name === 'source_candidate') { return await this.sourceCandidate(args); } else { throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], }; } }); } async listJobs() { try { const response = await fetch(`${this.baseUrl}/api/v1/external/jobs`, { headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`Dover API error: ${response.status} ${response.statusText}`); } const data = await response.json(); return { content: [ { type: 'text', text: JSON.stringify({ total_jobs: data.count, jobs: data.results.map(job => ({ id: job.id, title: job.title, stages: job.hiring_pipeline_stages.map(stage => stage.name), stage_count: job.hiring_pipeline_stages.length })) }, null, 2), }, ], }; } catch (error) { throw new Error(`Failed to fetch jobs: ${error instanceof Error ? error.message : String(error)}`); } } async addCandidate(args) { try { // Validate required fields if (!args.job_id || !args.first_name || !args.last_name || !args.pipeline_stage) { throw new Error('Missing required fields: job_id, first_name, last_name, and pipeline_stage are required'); } // Validate resume URL extension if provided if (args.resume_url) { const validExtensions = ['.pdf', '.doc', '.docx', '.txt']; const hasValidExtension = validExtensions.some(ext => args.resume_url.toLowerCase().endsWith(ext)); if (!hasValidExtension) { throw new Error('Resume URL must end with .pdf, .doc, .docx, or .txt'); } } // Validate notes length if (args.notes && args.notes.length > 5000) { throw new Error('Notes must be 5000 characters or less'); } // Prepare request body const requestBody = { job_id: args.job_id, candidate_first_name: args.first_name, candidate_last_name: args.last_name, hiring_pipeline_stage_name: args.pipeline_stage, }; // Add optional fields if provided if (args.email) { requestBody.candidate_email = args.email; } if (args.linkedin_url) { requestBody.candidate_linkedin_url = args.linkedin_url; } if (args.resume_url) { requestBody.resume_url = args.resume_url; } if (args.notes) { requestBody.additional_comments = args.notes; } const response = await fetch(`${this.baseUrl}/api/v1/external/add-candidate`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Dover API error: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); if (!data.success) { throw new Error(`Failed to add candidate: ${data.message || 'Unknown error'}`); } return { content: [ { type: 'text', text: JSON.stringify({ success: true, message: `Successfully added ${args.first_name} ${args.last_name} to job ${args.job_id} in stage "${args.pipeline_stage}"`, candidate: { name: `${args.first_name} ${args.last_name}`, email: args.email || 'Not provided', linkedin: args.linkedin_url || 'Not provided', stage: args.pipeline_stage, notes: args.notes || 'None' } }, null, 2), }, ], }; } catch (error) { throw new Error(`Failed to add candidate: ${error instanceof Error ? error.message : String(error)}`); } } async discoverCandidateEmail(firstName, lastName, linkedinUrl) { try { const response = await fetch(`${this.baseUrl}/api/v1/extension/find-emails`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ first_name: firstName, last_name: lastName, linkedin_profile_url: linkedinUrl }) }); if (!response.ok) { throw new Error(`Email discovery failed: ${response.status} ${response.statusText}`); } const data = await response.json(); return data.emails || []; } catch (error) { throw new Error(`Failed to discover email: ${error instanceof Error ? error.message : String(error)}`); } } async generateEmailContent(jobId, firstName, linkedinUrl, customSubject, customMessage) { try { const response = await fetch(`${this.baseUrl}/api/v1/extension/candidates/get-campaign-template`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ job_id: jobId, candidate_first_name: firstName, linkedin_profile_url: linkedinUrl }) }); if (!response.ok) { throw new Error(`Template generation failed: ${response.status} ${response.statusText}`); } const data = await response.json(); return { subject: customSubject || data.subject, body: customMessage || data.body, personalized_content: data.personalized_content }; } catch (error) { throw new Error(`Failed to generate email content: ${error instanceof Error ? error.message : String(error)}`); } } async sendSourcedCandidate(args, email, emailContent) { try { const requestBody = { job_id: args.job_id, full_name: `${args.first_name} ${args.last_name}`, linkedin_profile_url: args.linkedin_url, email: email, send_outreach: true }; if (args.custom_subject) { requestBody.custom_subject = args.custom_subject; } if (args.custom_message) { requestBody.custom_message = args.custom_message; } const response = await fetch(`${this.baseUrl}/api/v1/extension/add-candidate`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Dover extension API error: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); return { content: [{ type: 'text', text: JSON.stringify({ success: true, action: 'sent', message: `Successfully sourced ${args.first_name} ${args.last_name} and sent outreach email`, candidate: { name: `${args.first_name} ${args.last_name}`, email: email, linkedin: args.linkedin_url }, email: { subject: emailContent.subject, sent_to: email, personalized_content: emailContent.personalized_content } }, null, 2) }] }; } catch (error) { throw new Error(`Failed to send sourced candidate: ${error instanceof Error ? error.message : String(error)}`); } } async sourceCandidate(args) { try { // Validate required fields if (!args.job_id || !args.first_name || !args.last_name || !args.linkedin_url) { throw new Error('Missing required fields: job_id, first_name, last_name, and linkedin_url are required'); } // Validate LinkedIn URL format if (!args.linkedin_url.includes('linkedin.com')) { throw new Error('Invalid LinkedIn URL format'); } // Step 1: Email Discovery (if needed) let candidateEmails = []; if (!args.email) { candidateEmails = await this.discoverCandidateEmail(args.first_name, args.last_name, args.linkedin_url); } else { candidateEmails = [{ email: args.email, type: args.email.includes('@gmail.com') || args.email.includes('@yahoo.com') ? 'personal' : 'work', confidence: 'provided' }]; } if (candidateEmails.length === 0) { throw new Error('Could not find email address for candidate. Please provide an email manually.'); } // Step 2: Generate Email Content const emailContent = await this.generateEmailContent(args.job_id, args.first_name, args.linkedin_url, args.custom_subject, args.custom_message); // Step 3: Preview or Send if (!args.send_email) { // Return preview for user confirmation return { content: [{ type: 'text', text: JSON.stringify({ action: 'preview', candidate: { name: `${args.first_name} ${args.last_name}`, linkedin: args.linkedin_url }, email_options: candidateEmails, generated_email: { subject: emailContent.subject, body: emailContent.body, personalized_content: emailContent.personalized_content || 'AI-generated personalized content will be included' }, instructions: 'To send this email, call source_candidate again with send_email: true. You can also provide custom_subject or custom_message to override the generated content.' }, null, 2) }] }; } else { // Actually send the email const selectedEmail = candidateEmails[0].email; // Use first email found return await this.sendSourcedCandidate(args, selectedEmail, emailContent); } } catch (error) { throw new Error(`Failed to source candidate: ${error instanceof Error ? error.message : String(error)}`); } } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Dover MCP server running on stdio'); } } const server = new DoverMCPServer(); server.run().catch(console.error); //# sourceMappingURL=index.js.map