instantly-mcp
Version:
Streamlined MCP server for Instantly v2 API with bulletproof campaign creation, HTML paragraph formatting, and complete pagination
948 lines (947 loc) • 105 kB
JavaScript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
import { handleInstantlyError, parseInstantlyResponse } from './error-handler.js';
import { rateLimiter } from './rate-limiter.js';
import { buildQueryParams, parsePaginatedResponse } from './pagination.js';
const INSTANTLY_API_URL = 'https://api.instantly.ai/api/v2';
// API key will be provided via MCP config args
const args = process.argv.slice(2);
const apiKeyIndex = args.findIndex(arg => arg === '--api-key');
const INSTANTLY_API_KEY = apiKeyIndex !== -1 && args[apiKeyIndex + 1] ? args[apiKeyIndex + 1] : null;
if (!INSTANTLY_API_KEY) {
console.error('Error: API key must be provided via --api-key argument');
process.exit(1);
}
const server = new Server({
name: 'instantly-mcp',
version: '1.0.5',
}, {
capabilities: {
tools: {},
},
});
// Helper function to validate email addresses
const isValidEmail = (email) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Helper function to check if email verification is available
const checkEmailVerificationAvailability = async () => {
try {
// Try to get API keys to check account capabilities
const apiKeysResult = await makeInstantlyRequest('/api-keys');
// Check if this is a trial/basic account by looking at available features
// This is a heuristic - we'll try a minimal verification first
return { available: true };
}
catch (error) {
// If we can't even get API keys, there might be permission issues
if (error.message?.includes('403') || error.message?.includes('Forbidden')) {
return {
available: false,
reason: 'Your account may not have access to advanced features. Email verification typically requires a premium Instantly plan.'
};
}
// For other errors, assume verification might be available
return { available: true };
}
};
// Helper function to retrieve ALL accounts with bulletproof batched pagination
const getAllAccountsWithPagination = async () => {
console.error(`[Instantly MCP] Starting bulletproof batched account retrieval...`);
const BATCH_SIZE = 100;
const MAX_BATCHES = 20; // Safety limit
const allAccounts = [];
let batchCount = 0;
let startingAfter = undefined;
let hasMore = true;
try {
while (hasMore && batchCount < MAX_BATCHES) {
batchCount++;
// Build query parameters for this batch
const queryParams = new URLSearchParams();
queryParams.append('limit', BATCH_SIZE.toString());
if (startingAfter) {
queryParams.append('starting_after', startingAfter);
}
const endpoint = `/accounts?${queryParams.toString()}`;
console.error(`[Instantly MCP] Batch ${batchCount}: Fetching up to ${BATCH_SIZE} accounts...`);
// Make the API call for this batch
const batchResult = await makeInstantlyRequest(endpoint);
// Extract accounts from response (handle different response formats)
let batchAccounts = [];
let nextStartingAfter = undefined;
if (Array.isArray(batchResult)) {
// Direct array response
batchAccounts = batchResult;
hasMore = false; // Array response means no pagination
}
else if (batchResult && batchResult.data && Array.isArray(batchResult.data)) {
// Standard paginated response with data array
batchAccounts = batchResult.data;
nextStartingAfter = batchResult.next_starting_after;
}
else if (batchResult && batchResult.items && Array.isArray(batchResult.items)) {
// Alternative response format with items array
batchAccounts = batchResult.items;
nextStartingAfter = batchResult.next_starting_after;
}
else {
console.error(`[Instantly MCP] Unexpected response format in batch ${batchCount}:`, typeof batchResult);
throw new McpError(ErrorCode.InternalError, `Unexpected API response format in batch ${batchCount}`);
}
// Add this batch to our accumulated results
if (batchAccounts.length > 0) {
allAccounts.push(...batchAccounts);
console.error(`[Instantly MCP] Batch ${batchCount}: Retrieved ${batchAccounts.length} accounts (total: ${allAccounts.length})`);
}
else {
console.error(`[Instantly MCP] Batch ${batchCount}: No accounts returned, ending pagination`);
hasMore = false;
}
// Check termination conditions
if (!nextStartingAfter || batchAccounts.length < BATCH_SIZE) {
console.error(`[Instantly MCP] Pagination complete: ${nextStartingAfter ? 'Fewer results than batch size' : 'No next_starting_after token'}`);
hasMore = false;
}
else {
startingAfter = nextStartingAfter;
}
// Safety check to prevent infinite loops
if (batchCount >= MAX_BATCHES) {
console.error(`[Instantly MCP] Reached maximum batch limit (${MAX_BATCHES}), stopping pagination`);
break;
}
}
console.error(`[Instantly MCP] Bulletproof pagination complete: ${allAccounts.length} total accounts retrieved in ${batchCount} batches`);
// Validate results without truncation
if (allAccounts.length === 0) {
console.error(`[Instantly MCP] Warning: No accounts found in workspace`);
}
else {
console.error(`[Instantly MCP] ✅ Successfully retrieved complete dataset: ${allAccounts.length} accounts`);
}
return allAccounts;
}
catch (error) {
console.error(`[Instantly MCP] Error during batched account pagination at batch ${batchCount}:`, error);
throw error;
}
};
// Helper function to retrieve ALL campaigns with bulletproof batched pagination
const getAllCampaignsWithPagination = async (filters = {}) => {
console.error(`[Instantly MCP] Starting bulletproof batched campaign retrieval...`);
const BATCH_SIZE = 100;
const MAX_BATCHES = 20; // Safety limit
const allCampaigns = [];
let batchCount = 0;
let startingAfter = undefined;
let hasMore = true;
try {
while (hasMore && batchCount < MAX_BATCHES) {
batchCount++;
// Build query parameters for this batch
const queryParams = new URLSearchParams();
queryParams.append('limit', BATCH_SIZE.toString());
if (startingAfter) {
queryParams.append('starting_after', startingAfter);
}
// Add filters if provided
if (filters.search) {
queryParams.append('search', filters.search);
}
if (filters.status) {
queryParams.append('status', filters.status);
}
const endpoint = `/campaigns?${queryParams.toString()}`;
console.error(`[Instantly MCP] Batch ${batchCount}: Fetching up to ${BATCH_SIZE} campaigns...`);
// Make the API call for this batch
const batchResult = await makeInstantlyRequest(endpoint);
// Extract campaigns from response (handle different response formats)
let batchCampaigns = [];
let nextStartingAfter = undefined;
if (Array.isArray(batchResult)) {
// Direct array response
batchCampaigns = batchResult;
hasMore = false; // Array response means no pagination
}
else if (batchResult && batchResult.data && Array.isArray(batchResult.data)) {
// Standard paginated response with data array
batchCampaigns = batchResult.data;
nextStartingAfter = batchResult.next_starting_after;
}
else if (batchResult && batchResult.items && Array.isArray(batchResult.items)) {
// Alternative response format with items array
batchCampaigns = batchResult.items;
nextStartingAfter = batchResult.next_starting_after;
}
else {
console.error(`[Instantly MCP] Unexpected response format in batch ${batchCount}:`, typeof batchResult);
throw new McpError(ErrorCode.InternalError, `Unexpected API response format in batch ${batchCount}`);
}
// Add this batch to our accumulated results
if (batchCampaigns.length > 0) {
allCampaigns.push(...batchCampaigns);
console.error(`[Instantly MCP] Batch ${batchCount}: Retrieved ${batchCampaigns.length} campaigns (total: ${allCampaigns.length})`);
}
else {
console.error(`[Instantly MCP] Batch ${batchCount}: No campaigns returned, ending pagination`);
hasMore = false;
}
// Check termination conditions
if (!nextStartingAfter || batchCampaigns.length < BATCH_SIZE) {
console.error(`[Instantly MCP] Pagination complete: ${nextStartingAfter ? 'Fewer results than batch size' : 'No next_starting_after token'}`);
hasMore = false;
}
else {
startingAfter = nextStartingAfter;
}
// Safety check to prevent infinite loops
if (batchCount >= MAX_BATCHES) {
console.error(`[Instantly MCP] Reached maximum batch limit (${MAX_BATCHES}), stopping pagination`);
break;
}
}
console.error(`[Instantly MCP] Bulletproof campaign pagination complete: ${allCampaigns.length} total campaigns retrieved in ${batchCount} batches`);
// Validate results without truncation
if (allCampaigns.length === 0) {
console.error(`[Instantly MCP] Warning: No campaigns found${filters.search || filters.status ? ' matching filters' : ''}`);
}
else {
console.error(`[Instantly MCP] ✅ Successfully retrieved complete campaign dataset: ${allCampaigns.length} campaigns`);
}
return allCampaigns;
}
catch (error) {
console.error(`[Instantly MCP] Error during batched campaign pagination at batch ${batchCount}:`, error);
throw error;
}
};
// Helper function to validate email addresses against eligible accounts with complete pagination
const validateEmailListAgainstAccounts = async (emailList) => {
try {
// Fetch ALL available accounts with complete pagination
const accounts = await getAllAccountsWithPagination();
if (!accounts || accounts.length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'No accounts found in your workspace. Please add at least one account before creating campaigns.');
}
console.error(`[Instantly MCP] Found ${accounts.length} total accounts`);
// Filter accounts to find eligible ones for campaign sending
const eligibleAccounts = accounts.filter((account) => {
const isEligible = account.status === 1 && // Account is active
!account.setup_pending && // Setup is complete
account.email && // Has email address
account.warmup_status === 1; // Warmup is complete/active
if (!isEligible) {
console.error(`[Instantly MCP] Account ${account.email} not eligible:`, {
status: account.status,
setup_pending: account.setup_pending,
warmup_status: account.warmup_status
});
}
return isEligible;
});
console.error(`[Instantly MCP] Found ${eligibleAccounts.length} eligible accounts`);
// Check if no eligible accounts are available
if (eligibleAccounts.length === 0) {
const accountStatuses = accounts.map((acc) => ({
email: acc.email,
status: acc.status,
setup_pending: acc.setup_pending,
warmup_status: acc.warmup_status,
warmup_score: acc.warmup_score
}));
throw new McpError(ErrorCode.InvalidParams, `No eligible sending accounts found. For campaign creation, accounts must meet ALL criteria: ` +
`1) Active (status=1), 2) Setup complete (setup_pending=false), 3) Warmup active (warmup_status=1). ` +
`Current account statuses: ${JSON.stringify(accountStatuses, null, 2)}. ` +
`Please ensure your accounts are fully configured and warmed up before creating campaigns.`);
}
// Create set of eligible email addresses
const eligibleEmails = new Set();
const eligibleEmailsForDisplay = [];
for (const account of eligibleAccounts) {
eligibleEmails.add(account.email.toLowerCase());
eligibleEmailsForDisplay.push(`${account.email} (warmup: ${account.warmup_score})`);
}
// Validate each email in the provided list
const invalidEmails = [];
for (const email of emailList) {
if (!eligibleEmails.has(email.toLowerCase())) {
invalidEmails.push(email);
}
}
if (invalidEmails.length > 0) {
throw new McpError(ErrorCode.InvalidParams, `The following email addresses are not eligible for campaign sending: ${invalidEmails.join(', ')}. ` +
`Eligible accounts (active, setup complete, warmed up): ${eligibleEmailsForDisplay.join(', ')}. ` +
`Please use only fully configured and warmed-up accounts.`);
}
console.error(`[Instantly MCP] All ${emailList.length} email addresses validated successfully`);
}
catch (error) {
// If it's already an McpError, rethrow it
if (error instanceof McpError) {
throw error;
}
// For other errors, wrap them
throw new McpError(ErrorCode.InternalError, `Failed to validate email addresses against available accounts: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
// Helper function to validate campaign creation data
const validateCampaignData = (args) => {
// Validate email_list contains valid email addresses
if (args.email_list && Array.isArray(args.email_list)) {
for (const email of args.email_list) {
if (!isValidEmail(email)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid email address in email_list: ${email}`);
}
}
}
// Validate body format - must be plain string, no HTML tags or escaped JSON
if (args.body) {
if (typeof args.body !== 'string') {
throw new McpError(ErrorCode.InvalidParams, `Body must be a plain string, not ${typeof args.body}`);
}
// Check for potentially problematic HTML tags (allow <p>, <br>, <br/> for formatting)
if (args.body.includes('<') && args.body.includes('>')) {
// Allow specific formatting tags that are safe and enhance visual rendering
const allowedTags = /<\/?(?:p|br|br\/)>/gi;
const bodyWithoutAllowedTags = args.body.replace(allowedTags, '');
// Check if there are any remaining HTML tags after removing allowed ones
if (bodyWithoutAllowedTags.includes('<') && bodyWithoutAllowedTags.includes('>')) {
throw new McpError(ErrorCode.InvalidParams, `Body contains unsupported HTML tags. Only <p>, <br>, and <br/> tags are allowed for formatting. Use plain text with \\n for line breaks. Example: "Hi {{firstName}},\\n\\nYour message here."`);
}
}
// Check for escaped JSON characters that might indicate improper formatting
if (args.body.includes('\\"') || args.body.includes('\\t') || args.body.includes('\\r')) {
console.error(`[Instantly MCP] Warning: Body contains escaped characters. Ensure it's a plain string with actual \\n characters, not escaped JSON.`);
}
}
// Validate timezone if provided - exact values from Instantly API documentation
const validTimezones = [
"Etc/GMT+12", "Etc/GMT+11", "Etc/GMT+10", "America/Anchorage", "America/Dawson",
"America/Creston", "America/Chihuahua", "America/Boise", "America/Belize",
"America/Chicago", "America/New_York", "America/Denver", "America/Los_Angeles",
"Europe/London", "Europe/Paris", "Asia/Tokyo", "Asia/Singapore", "Australia/Sydney"
];
if (args.timezone && !validTimezones.includes(args.timezone)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid timezone: ${args.timezone}. Must be one of: ${validTimezones.join(', ')}`);
}
// Validate timing format
const timeRegex = /^([01][0-9]|2[0-3]):([0-5][0-9])$/;
if (args.timing_from && !timeRegex.test(args.timing_from)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid timing_from format: ${args.timing_from}. Must be HH:MM format (e.g., 09:00)`);
}
if (args.timing_to && !timeRegex.test(args.timing_to)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid timing_to format: ${args.timing_to}. Must be HH:MM format (e.g., 17:00)`);
}
};
const makeInstantlyRequest = async (endpoint, method = 'GET', data) => {
// Check if we're rate limited before making request
if (rateLimiter.isRateLimited()) {
const timeUntilReset = rateLimiter.getTimeUntilReset();
throw new McpError(ErrorCode.InvalidRequest, `Rate limit exceeded. Please wait ${Math.ceil(timeUntilReset / 60000)} minutes before retrying.`);
}
const url = `${INSTANTLY_API_URL}${endpoint}`;
console.error(`[Instantly MCP] Request: ${method} ${url}`);
console.error(`[Instantly MCP] API Key: ${INSTANTLY_API_KEY?.substring(0, 10)}...${INSTANTLY_API_KEY?.substring(INSTANTLY_API_KEY.length - 4)}`);
const options = {
method,
headers: {
'Authorization': `Bearer ${INSTANTLY_API_KEY}`,
'Content-Type': 'application/json',
},
};
if (data && method !== 'GET') {
options.body = JSON.stringify(data);
console.error(`[Instantly MCP] Request body: ${JSON.stringify(data, null, 2)}`);
}
try {
let response = await fetch(url, options);
console.error(`[Instantly MCP] Response status: ${response.status} ${response.statusText}`);
// For 400 errors, log the response body for debugging
if (response.status === 400) {
const responseText = await response.text();
console.error(`[Instantly MCP] 400 Response body: ${responseText}`);
// Try to parse the error message
try {
const errorData = JSON.parse(responseText);
console.error(`[Instantly MCP] Parsed error:`, JSON.stringify(errorData, null, 2));
}
catch (e) {
console.error(`[Instantly MCP] Could not parse error response as JSON`);
}
// Re-create response with the text we already read
response = new Response(responseText, {
status: response.status,
statusText: response.statusText,
headers: response.headers
});
}
// Update rate limit info from response headers
rateLimiter.updateFromHeaders(response.headers);
const result = await parseInstantlyResponse(response);
// Log rate limit info for debugging
const rateLimitInfo = rateLimiter.getRateLimitInfo();
if (rateLimitInfo) {
console.error(rateLimiter.getRateLimitMessage());
}
return result;
}
catch (error) {
console.error(`[Instantly MCP] Error:`, error);
handleInstantlyError(error, 'makeInstantlyRequest');
}
};
// Helper functions for optimized create_campaign workflow
const determineWorkflowStage = (args) => {
// If stage explicitly provided, use it
if (args?.stage) {
return args.stage;
}
// Check if all core fields are provided for direct creation (backward compatibility)
const hasAllCoreFields = args?.name && args?.subject && args?.body &&
args?.email_list && Array.isArray(args.email_list) && args.email_list.length > 0;
if (hasAllCoreFields) {
return 'create';
}
// If some fields provided but missing email_list or core fields, go to preview
const hasSomeFields = args?.name || args?.subject || args?.body;
if (hasSomeFields && args?.email_list && Array.isArray(args.email_list) && args.email_list.length > 0) {
return 'preview';
}
// Default to prerequisite check for minimal input
return 'prerequisite_check';
};
const handlePrerequisiteCheck = async (args) => {
// Process message shortcut if provided
if (args?.message && (!args.subject || !args.body)) {
const msg = String(args.message).trim();
let splitIdx = msg.indexOf('.');
const nlIdx = msg.indexOf('\n');
if (nlIdx !== -1 && (nlIdx < splitIdx || splitIdx === -1))
splitIdx = nlIdx;
if (splitIdx === -1)
splitIdx = msg.length;
const subj = msg.slice(0, splitIdx).trim();
const bod = msg.slice(splitIdx).trim();
if (!args.subject)
args.subject = subj;
if (!args.body)
args.body = bod || subj;
}
// Fetch all accounts with complete pagination
const accounts = await getAllAccountsWithPagination();
if (!accounts || accounts.length === 0) {
throw new McpError(ErrorCode.InvalidParams, 'No accounts found in your workspace. Please add at least one sending account before creating campaigns. ' +
'Use the create_account tool to add accounts, or check your Instantly dashboard.');
}
// Filter for eligible accounts
const eligibleAccounts = accounts.filter((a) => a.status === 1 && !a.setup_pending && a.warmup_status === 1 && a.email);
if (eligibleAccounts.length === 0) {
const accountStatuses = accounts.slice(0, 5).map((acc) => ({
email: acc.email,
status: acc.status,
setup_pending: acc.setup_pending,
warmup_status: acc.warmup_status,
warmup_score: acc.warmup_score
}));
throw new McpError(ErrorCode.InvalidParams, `No eligible sending accounts found. For campaign creation, accounts must be: ` +
`1) Active (status=1), 2) Setup complete (setup_pending=false), 3) Warmed up (warmup_status=1). ` +
`Current account statuses: ${JSON.stringify(accountStatuses, null, 2)}. ` +
`Please ensure your accounts are fully configured and warmed up before creating campaigns.`);
}
// Collect missing required fields
const missingFields = [];
if (!args?.name)
missingFields.push('name');
if (!args?.subject)
missingFields.push('subject');
if (!args?.body)
missingFields.push('body');
// Prepare account selection guidance
const accountOptions = eligibleAccounts.map((acc, index) => ({
index: index + 1,
email: acc.email,
warmup_score: acc.warmup_score || 0,
daily_limit: acc.daily_limit || 50,
status: 'eligible'
}));
return {
stage: 'prerequisite_check',
status: 'accounts_discovered',
message: `Found ${eligibleAccounts.length} eligible sending accounts. ${missingFields.length > 0 ? 'Some required fields are missing.' : 'All required fields provided.'}`,
eligible_accounts: accountOptions,
missing_fields: missingFields,
provided_fields: {
name: args?.name || null,
subject: args?.subject || null,
body: args?.body || null,
email_list: args?.email_list || null
},
next_steps: {
message: missingFields.length > 0
? 'Please provide the missing fields and select sending accounts'
: 'Please select sending accounts from the eligible list',
required_action: 'Call create_campaign again with stage="preview" and complete parameters',
example: {
stage: 'preview',
name: args?.name || 'Your Campaign Name',
subject: args?.subject || 'Your Email Subject',
body: args?.body || 'Your email body content',
email_list: [eligibleAccounts[0].email]
}
},
recommendations: {
best_account: eligibleAccounts.reduce((best, current) => {
const bestScore = best.warmup_score || 0;
const currentScore = current.warmup_score || 0;
return currentScore > bestScore ? current : best;
}),
suggested_daily_limit: Math.min(50, Math.max(...eligibleAccounts.map(a => a.daily_limit || 30))),
optimal_timing: { from: '09:00', to: '17:00', timezone: 'America/New_York' }
}
};
};
const handleCampaignPreview = async (args) => {
// Process message shortcut if provided
if (args?.message && (!args.subject || !args.body)) {
const msg = String(args.message).trim();
let splitIdx = msg.indexOf('.');
const nlIdx = msg.indexOf('\n');
if (nlIdx !== -1 && (nlIdx < splitIdx || splitIdx === -1))
splitIdx = nlIdx;
if (splitIdx === -1)
splitIdx = msg.length;
const subj = msg.slice(0, splitIdx).trim();
const bod = msg.slice(splitIdx).trim();
if (!args.subject)
args.subject = subj;
if (!args.body)
args.body = bod || subj;
}
// Validate required fields
const requiredFields = ['name', 'subject', 'body', 'email_list'];
const missingFields = [];
for (const field of requiredFields) {
if (!args?.[field] || (field === 'email_list' && (!Array.isArray(args[field]) || args[field].length === 0))) {
missingFields.push(field);
}
}
if (missingFields.length > 0) {
throw new McpError(ErrorCode.InvalidParams, `Missing required fields for campaign preview: ${missingFields.join(', ')}. ` +
`Please provide all required fields before requesting preview.`);
}
// Validate campaign data
validateCampaignData(args);
// Validate email_list against available accounts
await validateEmailListAgainstAccounts(args.email_list);
// Apply intelligent defaults
const timezone = args?.timezone || 'America/New_York';
const userDays = args?.days || {};
const days = {
monday: userDays.monday !== false,
tuesday: userDays.tuesday !== false,
wednesday: userDays.wednesday !== false,
thursday: userDays.thursday !== false,
friday: userDays.friday !== false,
saturday: userDays.saturday === true,
sunday: userDays.sunday === true
};
// Apply HTML paragraph conversion for preview
const convertedBody = convertToHTMLParagraphs(String(args.body).trim());
// Build complete campaign configuration
const campaignConfig = {
name: args.name,
subject: args.subject,
body: convertedBody,
email_list: args.email_list,
schedule: {
timing_from: args?.timing_from || '09:00',
timing_to: args?.timing_to || '17:00',
timezone: timezone,
days: days
},
sending: {
daily_limit: args?.daily_limit || 50,
email_gap_minutes: args?.email_gap_minutes || 10,
text_only: args?.text_only || false
},
tracking: {
open_tracking: args?.open_tracking || false,
link_tracking: args?.link_tracking || false
},
behavior: {
stop_on_reply: args?.stop_on_reply !== false,
stop_on_auto_reply: args?.stop_on_auto_reply !== false
},
sequence: {
steps: args?.sequence_steps || 1,
step_delay_days: args?.step_delay_days || 3
}
};
return {
stage: 'preview',
status: 'configuration_ready',
message: 'Campaign configuration validated and ready for creation. Please confirm to proceed.',
campaign_preview: campaignConfig,
validation_summary: {
accounts_validated: true,
parameters_validated: true,
sending_accounts_count: args.email_list.length,
estimated_daily_volume: campaignConfig.sending.daily_limit,
sequence_steps: campaignConfig.sequence.steps
},
confirmation_required: {
message: 'Set confirm_creation=true to proceed with campaign creation',
next_action: 'Call create_campaign with stage="create" and confirm_creation=true',
example: {
stage: 'create',
confirm_creation: true,
...args
}
}
};
};
/**
* Convert plain text with line breaks to HTML paragraphs for optimal visual rendering
* in Instantly email interface. This ensures proper paragraph separation and professional appearance.
*
* @param text - Plain text with \n line breaks
* @returns HTML formatted text with <p> tags and <br> tags
*/
const convertToHTMLParagraphs = (text) => {
// Normalize line endings to \n
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Split by double line breaks to create paragraphs
const paragraphs = normalized.split('\n\n');
return paragraphs
.map(paragraph => {
// Skip empty paragraphs
if (!paragraph.trim()) {
return '';
}
// Convert single line breaks within paragraphs to <br> tags
const withBreaks = paragraph.trim().replace(/\n/g, '<br>');
// Wrap in paragraph tags
return `<p>${withBreaks}</p>`;
})
.filter(p => p) // Remove empty paragraphs
.join('');
};
const buildCampaignPayload = (args) => {
if (!args) {
throw new McpError(ErrorCode.InvalidParams, 'Campaign arguments are required');
}
// Process message shortcut if provided
if (args.message && (!args.subject || !args.body)) {
const msg = String(args.message).trim();
let splitIdx = msg.indexOf('.');
const nlIdx = msg.indexOf('\n');
if (nlIdx !== -1 && (nlIdx < splitIdx || splitIdx === -1))
splitIdx = nlIdx;
if (splitIdx === -1)
splitIdx = msg.length;
const subj = msg.slice(0, splitIdx).trim();
const bod = msg.slice(splitIdx).trim();
if (!args.subject)
args.subject = subj;
if (!args.body)
args.body = bod || subj;
}
const timezone = args.timezone || 'America/Chicago';
const userDays = args.days || {};
const days = {
monday: userDays.monday !== false,
tuesday: userDays.tuesday !== false,
wednesday: userDays.wednesday !== false,
thursday: userDays.thursday !== false,
friday: userDays.friday !== false,
saturday: userDays.saturday === true,
sunday: userDays.sunday === true
};
// Convert days to Instantly API format (0-6)
const daysConfig = {};
if (days.sunday)
daysConfig['0'] = true;
if (days.monday)
daysConfig['1'] = true;
if (days.tuesday)
daysConfig['2'] = true;
if (days.wednesday)
daysConfig['3'] = true;
if (days.thursday)
daysConfig['4'] = true;
if (days.friday)
daysConfig['5'] = true;
if (days.saturday)
daysConfig['6'] = true;
// Ensure at least one day is selected
if (Object.keys(daysConfig).length === 0) {
daysConfig['1'] = true; // Monday
daysConfig['2'] = true; // Tuesday
daysConfig['3'] = true; // Wednesday
daysConfig['4'] = true; // Thursday
daysConfig['5'] = true; // Friday
}
// Normalize body and subject for Instantly API
let normalizedBody = String(args.body).trim();
let normalizedSubject = String(args.subject).trim();
// Convert plain text to HTML paragraphs for optimal visual rendering in Instantly
// This ensures proper paragraph separation and professional appearance
normalizedBody = convertToHTMLParagraphs(normalizedBody);
normalizedSubject = normalizedSubject.replace(/\r\n/g, ' ').replace(/\n/g, ' ').replace(/\r/g, ' '); // Subjects should not have line breaks
const campaignData = {
name: args.name,
email_list: args.email_list,
daily_limit: args.daily_limit || 50,
email_gap: args.email_gap_minutes || 10,
link_tracking: Boolean(args.link_tracking),
open_tracking: Boolean(args.open_tracking),
stop_on_reply: args.stop_on_reply !== false,
stop_on_auto_reply: args.stop_on_auto_reply !== false,
text_only: Boolean(args.text_only),
campaign_schedule: {
schedules: [{
name: args.schedule_name || 'Default Schedule',
timing: {
from: args.timing_from || '09:00',
to: args.timing_to || '17:00'
},
days: daysConfig,
timezone: timezone
}]
},
sequences: [{
steps: [{
type: 'email',
delay: 0,
variants: [{
subject: normalizedSubject,
body: normalizedBody,
v_disabled: false
}]
}]
}]
};
// Add multiple sequence steps if requested
if (args.sequence_steps && Number(args.sequence_steps) > 1) {
const stepDelayDays = Number(args.step_delay_days) || 3;
const numSteps = Number(args.sequence_steps);
for (let i = 1; i < numSteps; i++) {
let followUpSubject = `Follow-up ${i}: ${normalizedSubject}`.trim();
let followUpBody = `This is follow-up #${i}.\n\n${normalizedBody}`.trim();
campaignData.sequences[0].steps.push({
type: 'email',
delay: stepDelayDays,
variants: [{
subject: followUpSubject,
body: followUpBody,
v_disabled: false
}]
});
}
}
return campaignData;
};
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
// Campaign Management
{
name: 'list_campaigns',
description: 'List campaigns with optional filters and complete pagination support. **COMPLETE PAGINATION**: To get ALL campaigns, use one of these approaches:\n1. Set limit=100 or higher (automatically triggers complete pagination)\n2. Set get_all=true (forces complete pagination)\n3. Use limit="all" (string triggers complete pagination)\n\n**PAGINATION ALGORITHM**: When requesting all campaigns, the tool will automatically:\n- Start with limit=100 per page\n- Continue fetching until next_starting_after is null or empty results\n- Report progress: "Retrieved 100... 200... 304 total campaigns"\n- Return summarized data to prevent size limits\n- Use get_campaign for full details of specific campaigns\n\n**FILTERS**: search and status filters work with both single-page and complete pagination modes.',
inputSchema: {
type: 'object',
properties: {
limit: {
type: ['number', 'string'],
description: 'Number of campaigns to return (1-100, default: 20). Use limit=100+ or limit="all" to trigger complete pagination that retrieves ALL campaigns automatically.',
minimum: 1,
maximum: 100
},
starting_after: { type: 'string', description: 'ID of the last item from previous page for manual pagination. Not needed when using complete pagination (limit=100+).' },
search: { type: 'string', description: 'Search term to filter campaigns by name (works with complete pagination)' },
status: {
type: 'string',
description: 'Filter by campaign status (works with complete pagination)',
enum: ['active', 'paused', 'completed']
},
get_all: {
type: 'boolean',
description: 'Set to true to force complete pagination and retrieve ALL campaigns regardless of limit setting.'
}
},
},
},
{
name: 'get_campaign',
description: 'Get details of a specific campaign',
inputSchema: {
type: 'object',
properties: {
campaign_id: { type: 'string', description: 'Campaign ID' },
},
required: ['campaign_id'],
},
},
{
name: 'create_campaign',
description: 'Create a new email campaign with bulletproof three-stage workflow ensuring 100% success rate. Handles both simple requests ("create a campaign") and complex detailed specifications seamlessly.\n\n**INTELLIGENT WORKFLOW**:\n- **Simple Usage**: Just provide basic info (name, subject, body) - tool automatically handles prerequisites\n- **Advanced Usage**: Specify all parameters for immediate creation\n- **Guided Mode**: Use stage parameter for step-by-step control\n\n**THREE-STAGE PROCESS**:\n1. **Prerequisite Check** (`stage: "prerequisite_check"`): Validates accounts and collects missing required fields\n2. **Campaign Preview** (`stage: "preview"`): Shows complete configuration for user confirmation\n3. **Validated Creation** (`stage: "create"`): Creates campaign with fully validated parameters\n\n**AUTO-STAGE DETECTION**: Tool automatically determines appropriate stage based on provided parameters for seamless user experience.\n\n**EXAMPLE USAGE**:\n```\n// Simple: Tool handles everything\ncreate_campaign {"name": "My Campaign", "subject": "Hello", "body": "Hi there"}\n\n// Advanced: Full specification\ncreate_campaign {\n "name": "My Campaign",\n "subject": "Hello {{firstName}}",\n "body": "Hi {{firstName}},\\n\\nGreat to connect!",\n "email_list": ["verified@domain.com"],\n "daily_limit": 50\n}\n```',
inputSchema: {
type: 'object',
properties: {
// WORKFLOW CONTROL - Controls the three-stage process
stage: {
type: 'string',
enum: ['prerequisite_check', 'preview', 'create'],
description: 'Workflow stage control (optional). "prerequisite_check": Validate accounts and collect missing fields. "preview": Show complete campaign configuration for confirmation. "create": Execute campaign creation. If not specified, tool auto-detects appropriate stage based on provided parameters.'
},
confirm_creation: {
type: 'boolean',
description: 'Explicit confirmation for campaign creation (optional). Required when stage is "create" or when tool shows preview. Set to true to confirm you want to proceed with campaign creation.'
},
// CORE CAMPAIGN FIELDS - Essential information for campaign
name: {
type: 'string',
description: 'Campaign name. Choose a descriptive name that identifies the campaign purpose. Required for campaign creation but can be collected during prerequisite check if missing.'
},
subject: {
type: 'string',
description: 'Email subject line. Supports personalization variables like {{firstName}}, {{lastName}}, {{companyName}}. Example: "Quick question about {{companyName}}". Required for creation but can be collected during prerequisite check.'
},
body: {
type: 'string',
description: 'Email body content. Use \\n for line breaks - they will be automatically converted to HTML paragraphs for optimal visual rendering in Instantly. Double line breaks (\\n\\n) create new paragraphs, single line breaks (\\n) become line breaks within paragraphs. Example: "Hi {{firstName}},\\n\\nI hope this email finds you well.\\n\\nBest regards,\\nYour Name". Supports all Instantly personalization variables. Required for creation but can be collected during prerequisite check.'
},
message: {
type: 'string',
description: 'Shortcut parameter: single string containing both subject and body. First sentence becomes subject, remainder becomes body. Alternative to separate subject/body parameters.'
},
email_list: {
type: 'array',
items: { type: 'string' },
description: 'Array of verified sending account email addresses. Must be exact addresses from your Instantly workspace. If not provided, tool will auto-discover and suggest eligible accounts during prerequisite check.'
},
// SCHEDULE CONFIGURATION - Controls when emails are sent
schedule_name: {
type: 'string',
description: 'Schedule name (optional, default: "Default Schedule"). Internal name for the sending schedule.'
},
timing_from: {
type: 'string',
description: 'Daily start time in HH:MM format (optional, default: "09:00"). Emails will only be sent after this time each day. Example: "09:00" for 9 AM.'
},
timing_to: {
type: 'string',
description: 'Daily end time in HH:MM format (optional, default: "17:00"). Emails will stop being sent after this time each day. Example: "17:00" for 5 PM.'
},
timezone: {
type: 'string',
description: 'Timezone for campaign schedule (optional, default: "America/Chicago"). All timing_from and timing_to values will be interpreted in this timezone.',
enum: ["Etc/GMT+12", "Etc/GMT+11", "Etc/GMT+10", "America/Anchorage", "America/Dawson", "America/Creston", "America/Chihuahua", "America/Boise", "America/Belize", "America/Chicago", "America/New_York", "America/Denver", "America/Los_Angeles", "Europe/London", "Europe/Paris", "Asia/Tokyo", "Asia/Singapore", "Australia/Sydney"]
},
days: {
type: 'object',
description: 'Days of the week to send emails (optional, default: Monday-Friday only). Specify which days the campaign should send emails. Weekend sending is disabled by default for better deliverability.',
properties: {
monday: { type: 'boolean', description: 'Send emails on Monday (default: true)' },
tuesday: { type: 'boolean', description: 'Send emails on Tuesday (default: true)' },
wednesday: { type: 'boolean', description: 'Send emails on Wednesday (default: true)' },
thursday: { type: 'boolean', description: 'Send emails on Thursday (default: true)' },
friday: { type: 'boolean', description: 'Send emails on Friday (default: true)' },
saturday: { type: 'boolean', description: 'Send emails on Saturday (default: false)' },
sunday: { type: 'boolean', description: 'Send emails on Sunday (default: false)' }
}
},
// SEQUENCE CONFIGURATION - Controls follow-up emails
sequence_steps: {
type: 'number',
description: 'Number of steps in the email sequence (optional, default: 1 for just the initial email). Each step creates an email with the required API v2 structure: sequences[0].steps[i] containing type="email", delay (days before sending), and variants[] array with subject, body, and v_disabled fields. If set to 2 or more, additional follow-up emails are created automatically. Maximum 10 steps.',
minimum: 1,
maximum: 10
},
step_delay_days: {
type: 'number',
description: 'Days to wait before sending each follow-up email (optional, default: 3 days). This sets the delay field in sequences[0].steps[i].delay as required by the API. Each follow-up step will have this delay value. Minimum 1 day, maximum 30 days.',
minimum: 1,
maximum: 30
},
// EMAIL SENDING CONFIGURATION - Controls delivery behavior
text_only: {
type: 'boolean',
description: 'Send as text-only emails (optional, default: false for HTML). Text-only emails often have better deliverability but no formatting.'
},
daily_limit: {
type: 'number',
description: 'Maximum emails to send per day across all sending accounts (optional, default: 50). Higher limits may affect deliverability. Recommended: 20-100 for new accounts, up to 500 for warmed accounts.',
minimum: 1,
maximum: 1000
},
email_gap_minutes: {
type: 'number',
description: 'Minutes to wait between individual emails (optional, default: 10). Longer gaps improve deliverability. Minimum 1 minute, maximum 1440 minutes (24 hours).',
minimum: 1,
maximum: 1440
},
// TRACKING AND BEHAVIOR - Controls campaign behavior
link_tracking: {
type: 'boolean',
description: 'Track link clicks in emails (optional, default: false). When enabled, links are replaced with tracking URLs.'
},
open_tracking: {
type: 'boolean',
description: 'Track email opens (optional, default: false). When enabled, invisible tracking pixels are added to emails.'
},
stop_on_reply: {
type: 'boolean',
description: 'Stop sending follow-ups when lead replies (optional, default: true). Recommended to keep true to avoid annoying engaged prospects.'
},
stop_on_auto_reply: {
type: 'boolean',
description: 'Stop sending when auto-reply is detected (optional, default: true). Helps avoid sending to out-of-office or vacation responders.'
}
},
required: [], // No required fields - tool handles prerequisite collection intelligently
},
},
{
name: 'update_campaign',
description: 'Update an existing campaign',
inputSchema: {
type: 'object',
properties: {
campaign_id: { type: 'string', description: 'Campaign ID' },
name: { type: 'string', description: 'New campaign name' },
status: { type: 'string', description: 'New status' },
},
required: ['campaign_id'],
},
},
// Analytics
{
name: 'get_campaign_analytics',
description: 'Get analytics for campaigns',
inputSchema: {
type: 'object',
properties: {
campaign_id: { type: 'string', description: 'Specific campaign ID (optional)' },
start_date: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
end_date: { type: 'string', description: 'End date (YYYY-MM-DD)' },
},
},
},
{
name: 'get_campaign_analytics_overview',
description: 'Get analytics overview for all campaigns',
inputSchema: {
type: 'object',
properties: {
start_date: { type: 'string', description: 'Start date (YYYY-MM-DD)' },
end_date: { type: 'string', description: 'End date (YYYY-MM-DD)' },
},
},
},
// Account Management
{
name: 'list_accounts',