UNPKG

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
#!/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',