@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.
- package/build/tools/advisor.d.ts +4 -4
- package/build/tools/advisor.js +79 -34
- package/package.json +1 -1
package/build/tools/advisor.d.ts
CHANGED
|
@@ -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 {
|
package/build/tools/advisor.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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.
|
|
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.
|
|
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:
|
|
257
|
-
click_rate_delta:
|
|
258
|
-
bounce_rate_delta:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|