@sendinel/mcp-server 1.0.181 → 1.0.182

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,10 +6,10 @@ interface PeriodMetrics {
6
6
  clicked: number;
7
7
  bounced: number;
8
8
  complained: number;
9
- open_rate: number;
10
- click_rate: number;
11
- bounce_rate: number;
12
- complaint_rate: number;
9
+ open_rate: number | null;
10
+ click_rate: number | null;
11
+ bounce_rate: number | null;
12
+ complaint_rate: number | null;
13
13
  }
14
14
  export declare function computeMetrics(rows: any[]): PeriodMetrics;
15
15
  interface TimeBucket {
@@ -14,12 +14,20 @@ const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Frid
14
14
  export function computeMetrics(rows) {
15
15
  const counts = countEmailRows(rows);
16
16
  return { ...counts,
17
- open_rate: percentRate(counts.opened, counts.delivered, 2) ?? 0,
18
- click_rate: percentRate(counts.clicked, counts.delivered, 2) ?? 0,
19
- bounce_rate: percentRate(counts.bounced, counts.sent, 2) ?? 0,
20
- complaint_rate: percentRate(counts.complained, counts.sent, 2) ?? 0,
17
+ open_rate: percentRate(counts.opened, counts.delivered, 2),
18
+ click_rate: percentRate(counts.clicked, counts.delivered, 2),
19
+ bounce_rate: percentRate(counts.bounced, counts.sent, 2),
20
+ complaint_rate: percentRate(counts.complained, counts.sent, 2),
21
21
  };
22
22
  }
23
+ // Look up sites in the active project before reading analytics or calling AI.
24
+ function advisorSite(siteId, projectId) {
25
+ return db().from('sites').select('id, name, brand_voice')
26
+ .eq('id', siteId).eq('project_id', projectId).maybeSingle();
27
+ }
28
+ function rateDelta(current, prior) {
29
+ return current === null || prior === null ? null : +(current - prior).toFixed(2);
30
+ }
23
31
  export function buildTimeBuckets(rows, groupBy, metric) {
24
32
  const buckets = groupMetricRows(rows, row => String(groupBy === "hour" ? new Date(row.created_at).getUTCHours() : new Date(row.created_at).getUTCDay()));
25
33
  const results = [];
@@ -91,13 +99,20 @@ export function registerAdvisorTools(server) {
91
99
  idempotentHint: true,
92
100
  openWorldHint: true,
93
101
  },
94
- description: 'Analyze campaign performance and get actionable recommendations. Returns period-over-period metrics, domain health issues, engagement distribution, and prioritized recommendations with severity levels.',
102
+ description: 'Analyze campaign performance and get actionable recommendations. Returns period-over-period metrics, domain health issues, engagement distribution, and prioritized recommendations with severity levels. The overall verdict is an engagement/campaign verdict, not a deliverability verdict.',
95
103
  inputSchema: {
96
104
  site_id: z.string().uuid().optional().describe('Filter to a specific site'),
97
105
  days: z.number().int().min(1).max(365).optional().default(30).describe('Analysis period in days (default: 30)'),
98
106
  },
99
107
  }, async ({ site_id, days: daysParam }) => {
100
108
  const projectId = getProjectId();
109
+ if (site_id) {
110
+ const { data: site, error } = await advisorSite(site_id, projectId);
111
+ if (error)
112
+ return toolError(dbToolError(error));
113
+ if (!site)
114
+ return toolError({ code: 'not_found', error: 'Site not found' });
115
+ }
101
116
  const days = daysParam ?? 30;
102
117
  const now = new Date();
103
118
  const periodStart = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
@@ -159,7 +174,7 @@ export function registerAdvisorTools(server) {
159
174
  });
160
175
  }
161
176
  // High bounce rate
162
- if (current.sent > 0 && current.bounce_rate > 5) {
177
+ if (current.bounce_rate !== null && current.bounce_rate > 5) {
163
178
  recommendations.push({
164
179
  type: 'high_bounce_rate',
165
180
  severity: 'critical',
@@ -168,7 +183,7 @@ export function registerAdvisorTools(server) {
168
183
  });
169
184
  }
170
185
  // High complaint rate
171
- if (current.sent > 0 && current.complaint_rate > 0.3) {
186
+ if (current.complaint_rate !== null && current.complaint_rate > 0.3) {
172
187
  recommendations.push({
173
188
  type: 'high_complaint_rate',
174
189
  severity: 'critical',
@@ -177,7 +192,7 @@ export function registerAdvisorTools(server) {
177
192
  });
178
193
  }
179
194
  // Open rate declining
180
- if (prior.sent >= 50 && current.sent >= 50) {
195
+ if (prior.sent >= 50 && current.sent >= 50 && prior.open_rate !== null && current.open_rate !== null) {
181
196
  const delta = current.open_rate - prior.open_rate;
182
197
  if (delta < -10) {
183
198
  recommendations.push({
@@ -253,9 +268,9 @@ export function registerAdvisorTools(server) {
253
268
  const hasWarning = recommendations.some(r => r.severity === 'warning');
254
269
  const overall = hasCritical ? 'CRITICAL' : hasWarning ? 'NEEDS_ATTENTION' : 'HEALTHY';
255
270
  const trends = prior.sent > 0 ? {
256
- open_rate_delta: +(current.open_rate - prior.open_rate).toFixed(2),
257
- click_rate_delta: +(current.click_rate - prior.click_rate).toFixed(2),
258
- bounce_rate_delta: +(current.bounce_rate - prior.bounce_rate).toFixed(2),
271
+ open_rate_delta: rateDelta(current.open_rate, prior.open_rate),
272
+ click_rate_delta: rateDelta(current.click_rate, prior.click_rate),
273
+ bounce_rate_delta: rateDelta(current.bounce_rate, prior.bounce_rate),
259
274
  volume_change: +((current.sent - prior.sent) / prior.sent * 100).toFixed(1),
260
275
  } : null;
261
276
  return j({
@@ -286,19 +301,32 @@ export function registerAdvisorTools(server) {
286
301
  },
287
302
  }, async ({ campaign_id, step_order, count, site_id, create_variants }) => {
288
303
  const projectId = getProjectId();
304
+ const { data: campaign, error: campaignError } = await db()
305
+ .from('campaigns').select('id').eq('id', campaign_id).eq('project_id', projectId).maybeSingle();
306
+ if (campaignError)
307
+ return toolError(dbToolError(campaignError));
308
+ if (!campaign)
309
+ return toolError({ code: 'not_found', error: 'Campaign not found' });
310
+ const { data: site, error: siteError } = await advisorSite(site_id, projectId);
311
+ if (siteError)
312
+ return toolError(dbToolError(siteError));
313
+ if (!site)
314
+ return toolError({ code: 'not_found', error: 'Site not found' });
289
315
  const apiKey = process.env.ANTHROPIC_API_KEY;
290
316
  if (!apiKey)
291
- return txt('Error: ANTHROPIC_API_KEY not configured.');
317
+ return toolError({ code: 'internal_error', error: 'ANTHROPIC_API_KEY not configured.' });
292
318
  // 1. Get current subject (variant A)
293
- const { data: currentStep } = await db()
319
+ const { data: currentStep, error: stepError } = await db()
294
320
  .from('campaign_steps')
295
321
  .select('id, subject, html_body:body_html, text_body:body_text, delay_days, delay_hours')
296
322
  .eq('campaign_id', campaign_id)
297
323
  .eq('step_order', step_order ?? 0)
298
324
  .eq('variant_label', 'A')
299
325
  .maybeSingle();
326
+ if (stepError)
327
+ return toolError(dbToolError(stepError));
300
328
  if (!currentStep)
301
- return txt(`Error: No campaign step found for step_order ${step_order ?? 0}, variant A.`);
329
+ return toolError({ code: 'not_found', error: `No campaign step found for step_order ${step_order ?? 0}, variant A.` });
302
330
  // 2. Top-performing historical subjects
303
331
  let logQuery = db()
304
332
  .from('email_log')
@@ -320,12 +348,6 @@ export function registerAdvisorTools(server) {
320
348
  .sort((a, b) => b.open_rate - a.open_rate)
321
349
  .slice(0, 5);
322
350
  // 3. Brand voice
323
- const { data: site } = await db()
324
- .from('sites')
325
- .select('name, brand_voice')
326
- .eq('id', site_id)
327
- .eq('project_id', projectId)
328
- .maybeSingle();
329
351
  const voicePrompt = buildVoicePrompt(site?.brand_voice);
330
352
  // 4. Call Claude
331
353
  const anthropic = new Anthropic({ apiKey });
@@ -354,7 +376,7 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
354
376
  suggestions = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
355
377
  }
356
378
  catch {
357
- return txt(`Error: Failed to parse AI response. Raw: ${raw.slice(0, 200)}`);
379
+ return toolError({ code: 'internal_error', error: 'Failed to parse AI response.' });
358
380
  }
359
381
  // 5. Optionally create variants
360
382
  let variantsCreated;
@@ -404,9 +426,14 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
404
426
  },
405
427
  }, async ({ site_id, days: daysParam }) => {
406
428
  const projectId = getProjectId();
429
+ const { data: site, error: siteError } = await advisorSite(site_id, projectId);
430
+ if (siteError)
431
+ return toolError(dbToolError(siteError));
432
+ if (!site)
433
+ return toolError({ code: 'not_found', error: 'Site not found' });
407
434
  const apiKey = process.env.ANTHROPIC_API_KEY;
408
435
  if (!apiKey)
409
- return txt('Error: ANTHROPIC_API_KEY not configured.');
436
+ return toolError({ code: 'internal_error', error: 'ANTHROPIC_API_KEY not configured.' });
410
437
  const days = daysParam ?? 90;
411
438
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
412
439
  // 1. Query email_log with campaign step subjects
@@ -418,7 +445,8 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
418
445
  .gte('created_at', since)
419
446
  .not('campaign_id', 'is', null));
420
447
  if (!logRows || logRows.length < 20) {
421
- return j({
448
+ return toolError({
449
+ code: 'conflict',
422
450
  error: 'insufficient_data',
423
451
  message: `Only ${logRows?.length ?? 0} campaign emails found in the last ${days} days. Need at least 20 for meaningful analysis.`,
424
452
  });
@@ -440,18 +468,13 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
440
468
  const topPerformers = ranked.slice(0, 10);
441
469
  const bottomPerformers = ranked.slice(-5);
442
470
  if (topPerformers.length < 3) {
443
- return j({
471
+ return toolError({
472
+ code: 'conflict',
444
473
  error: 'insufficient_data',
445
474
  message: `Only ${topPerformers.length} campaigns with enough sends to analyze. Need at least 3 for meaningful suggestions.`,
446
475
  });
447
476
  }
448
477
  // 4. Brand voice context
449
- const { data: site } = await db()
450
- .from('sites')
451
- .select('name, brand_voice')
452
- .eq('id', site_id)
453
- .eq('project_id', projectId)
454
- .maybeSingle();
455
478
  const voicePrompt = buildVoicePrompt(site?.brand_voice);
456
479
  // 5. Call Claude
457
480
  const anthropic = new Anthropic({ apiKey });
@@ -486,7 +509,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
486
509
  suggestions = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
487
510
  }
488
511
  catch {
489
- return txt(`Error: Failed to parse AI response. Raw: ${raw.slice(0, 300)}`);
512
+ return toolError({ code: 'internal_error', error: 'Failed to parse AI response.' });
490
513
  }
491
514
  return j({
492
515
  site: site?.name ?? site_id,
@@ -516,6 +539,19 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
516
539
  },
517
540
  }, async ({ site_id, segment_id, days: daysParam, metric }) => {
518
541
  const projectId = getProjectId();
542
+ const { data: site, error: siteError } = await advisorSite(site_id, projectId);
543
+ if (siteError)
544
+ return toolError(dbToolError(siteError));
545
+ if (!site)
546
+ return toolError({ code: 'not_found', error: 'Site not found' });
547
+ if (segment_id) {
548
+ const { data: segment, error } = await db()
549
+ .from('segments').select('id').eq('id', segment_id).eq('project_id', projectId).maybeSingle();
550
+ if (error)
551
+ return toolError(dbToolError(error));
552
+ if (!segment)
553
+ return toolError({ code: 'not_found', error: 'Segment not found' });
554
+ }
519
555
  const days = daysParam ?? 90;
520
556
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
521
557
  // 1. Get contact IDs for segment filter
@@ -545,7 +581,8 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
545
581
  }
546
582
  const rows = await readAllRows(query);
547
583
  if (!rows || rows.length < 30) {
548
- return j({
584
+ return toolError({
585
+ code: 'conflict',
549
586
  error: 'insufficient_data',
550
587
  message: `Only ${rows?.length ?? 0} qualifying emails found in the last ${days} days. Need at least 30 for meaningful analysis.`,
551
588
  coverage,
@@ -617,6 +654,11 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
617
654
  const dkimFailed = dkimValid === false;
618
655
  const dmarcValid = health?.dmarc_valid ?? null; // null = not checked yet (#3904)
619
656
  const dmarcFailed = dmarcValid === false;
657
+ const unchecked = [
658
+ ...(spfValid === null ? ['spf'] : []),
659
+ ...(dkimValid === null ? ['dkim'] : []),
660
+ ...(dmarcValid === null ? ['dmarc'] : []),
661
+ ];
620
662
  const warmupDay = health?.warmup_day ?? null;
621
663
  const warmupDailyLimit = health?.warmup_daily_limit ?? null;
622
664
  // 3. Fetch recent 7-day email_log stats
@@ -653,6 +695,8 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
653
695
  issues.push({ type: 'dns_invalid', severity: 'high', detail: `Missing DNS: ${spfFailed ? 'SPF' : ''}${spfFailed && dkimFailed ? ' + ' : ''}${dkimFailed ? 'DKIM' : ''}` });
654
696
  if (dmarcFailed)
655
697
  issues.push({ type: 'no_dmarc', severity: 'medium', detail: 'DMARC record missing or invalid' });
698
+ if (unchecked.length)
699
+ issues.push({ type: 'dns_unchecked', severity: 'medium', detail: `SPF/DKIM/DMARC not checked yet: ${unchecked.map(record => record.toUpperCase()).join(', ')}` });
656
700
  if (warmupDay != null && warmupDay < 42 && bounceRate > 3) {
657
701
  issues.push({ type: 'warmup_risk', severity: 'high', detail: `Domain on warmup day ${warmupDay} with ${bounceRate}% bounce rate — warmup at risk` });
658
702
  }
@@ -750,6 +794,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
750
794
  return j({
751
795
  site: { id: site.id, slug: site.slug, domain: site.sending_domain },
752
796
  health_score: totalSent === 0 ? null : healthScore,
797
+ health_score_basis: { unchecked },
753
798
  ...(totalSent === 0 ? { health_score_reason: 'no_send_data', reason: 'no_send_data' } : {}),
754
799
  recent_7d: {
755
800
  total_sent: totalSent,
@@ -766,11 +811,11 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
766
811
  new_suppressions_7d: newSuppressions ?? 0,
767
812
  issues,
768
813
  plan: {
769
- immediate: immediate.length > 0 ? immediate : ['No immediate action required'],
814
+ immediate: immediate.length > 0 ? immediate : [unchecked.length ? 'Run a DNS check for the sending domain (SPF/DKIM/DMARC are not verified yet)' : 'No immediate action required'],
770
815
  short_term: shortTerm.length > 0 ? shortTerm : ['Continue current sending practices'],
771
816
  monitoring,
772
817
  },
773
- ...(issues.some(issue => issue.type !== 'no_recent_sends') ? { estimated_recovery: estimatedRecovery } : {}),
818
+ ...(issues.some(issue => issue.type !== 'no_recent_sends' && issue.type !== 'dns_unchecked') ? { estimated_recovery: estimatedRecovery } : {}),
774
819
  });
775
820
  });
776
821
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.181",
3
+ "version": "1.0.182",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",