@doverhq/mcp
Version:
Dover MCP server for accessing Dover job data
446 lines • 19.8 kB
JavaScript
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