@sendinel/mcp-server 1.0.3 → 1.0.5
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/audit.d.ts +1 -0
- package/build/audit.js +9 -3
- package/build/auth.d.ts +10 -0
- package/build/auth.js +82 -9
- package/build/db.d.ts +12 -0
- package/build/db.js +37 -2
- package/build/index.js +38 -12
- package/build/lib/action-approvals.d.ts +28 -1
- package/build/lib/action-approvals.js +89 -2
- package/build/lib/brand-asset-persist.d.ts +35 -0
- package/build/lib/brand-asset-persist.js +161 -0
- package/build/lib/brand-asset-usage.d.ts +13 -0
- package/build/lib/brand-asset-usage.js +46 -0
- package/build/lib/email-provider.d.ts +19 -0
- package/build/lib/email-provider.js +281 -0
- package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
- package/build/lib/ssrf-pinned-fetch.js +101 -0
- package/build/lib/survey-link-resolver.d.ts +6 -0
- package/build/lib/survey-link-resolver.js +23 -0
- package/build/lib/svg-sanitize.d.ts +2 -0
- package/build/lib/svg-sanitize.js +110 -0
- package/build/lib/webhook-events.d.ts +1 -1
- package/build/scoped-token.d.ts +11 -0
- package/build/scoped-token.js +59 -0
- package/build/server.js +30 -9
- package/build/tools/ab-testing.js +28 -0
- package/build/tools/advisor.js +52 -2
- package/build/tools/ai-work.d.ts +2 -0
- package/build/tools/ai-work.js +460 -0
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +203 -0
- package/build/tools/approvals.js +15 -1
- package/build/tools/automations.js +288 -65
- package/build/tools/brand-kit.d.ts +2 -0
- package/build/tools/brand-kit.js +277 -0
- package/build/tools/campaigns.js +900 -69
- package/build/tools/compound.js +28 -0
- package/build/tools/contacts.js +338 -17
- package/build/tools/content.js +266 -1
- package/build/tools/data-proposals.js +21 -0
- package/build/tools/data.js +121 -5
- package/build/tools/delivery-ops.js +84 -0
- package/build/tools/drafts.js +64 -1
- package/build/tools/forms.js +42 -9
- package/build/tools/gdpr.js +22 -3
- package/build/tools/newsletters.d.ts +2 -0
- package/build/tools/newsletters.js +583 -0
- package/build/tools/org.js +297 -3
- package/build/tools/segments.js +45 -3
- package/build/tools/short-links.d.ts +2 -0
- package/build/tools/short-links.js +157 -0
- package/build/tools/sites.js +35 -0
- package/build/tools/sms.js +49 -0
- package/build/tools/social-posts.js +81 -5
- package/build/tools/surveys.d.ts +2 -0
- package/build/tools/surveys.js +222 -0
- package/build/tools/templates.js +78 -2
- package/build/tools/warmup.js +28 -0
- package/build/tools/webhooks.js +29 -0
- package/package.json +10 -4
package/build/tools/contacts.js
CHANGED
|
@@ -1,17 +1,96 @@
|
|
|
1
1
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
2
2
|
import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { db } from '../db.js';
|
|
4
|
+
import { db, projectClientForWriteback } from '../db.js';
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
6
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
7
7
|
import { logConsent, logAudit } from '../audit.js';
|
|
8
|
-
import { requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
8
|
+
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
9
9
|
import { decrypt } from '../crypto.js';
|
|
10
10
|
import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
|
|
11
11
|
import Anthropic from '@anthropic-ai/sdk';
|
|
12
12
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
13
13
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
14
14
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
15
|
+
async function byodWritebackClient(projectId) {
|
|
16
|
+
const customer = projectClientForWriteback();
|
|
17
|
+
if (!customer)
|
|
18
|
+
return null;
|
|
19
|
+
const { data } = await db()
|
|
20
|
+
.from('projects')
|
|
21
|
+
.select('connector_type, connector_view_installed')
|
|
22
|
+
.eq('id', projectId)
|
|
23
|
+
.maybeSingle();
|
|
24
|
+
return data?.connector_type === 'byod' && data.connector_view_installed === true ? customer : null;
|
|
25
|
+
}
|
|
26
|
+
async function resolveMcpByodTarget(projectId, contact, customer) {
|
|
27
|
+
let sourceId = typeof contact.custom_properties?.db_import_source_id === 'string' ? contact.custom_properties.db_import_source_id : null;
|
|
28
|
+
if (sourceId?.startsWith('sendinel:'))
|
|
29
|
+
return { kind: 'contact', sourceId, rowId: sourceId.slice('sendinel:'.length) };
|
|
30
|
+
if (sourceId)
|
|
31
|
+
return { kind: 'state', sourceId };
|
|
32
|
+
const inserted = await customer
|
|
33
|
+
.schema('email')
|
|
34
|
+
.from('subscribers')
|
|
35
|
+
.insert({
|
|
36
|
+
project_id: projectId,
|
|
37
|
+
email: contact.email,
|
|
38
|
+
first_name: contact.first_name ?? null,
|
|
39
|
+
last_name: contact.last_name ?? null,
|
|
40
|
+
source: 'sendinel',
|
|
41
|
+
custom_properties: contact.custom_properties ?? {},
|
|
42
|
+
})
|
|
43
|
+
.select('id')
|
|
44
|
+
.single();
|
|
45
|
+
let rowId = inserted.data?.id;
|
|
46
|
+
if (inserted.error?.code === '23505') {
|
|
47
|
+
const existing = await customer.schema('email').from('subscribers').select('id').eq('project_id', projectId).eq('email', contact.email).maybeSingle();
|
|
48
|
+
rowId = existing.data?.id;
|
|
49
|
+
}
|
|
50
|
+
else if (inserted.error) {
|
|
51
|
+
throw inserted.error;
|
|
52
|
+
}
|
|
53
|
+
if (!rowId)
|
|
54
|
+
return null;
|
|
55
|
+
sourceId = `sendinel:${rowId}`;
|
|
56
|
+
await db().from('subscribers').update({
|
|
57
|
+
custom_properties: {
|
|
58
|
+
...(contact.custom_properties ?? {}),
|
|
59
|
+
db_import_source_id: sourceId,
|
|
60
|
+
db_import_source_table: 'email.subscribers',
|
|
61
|
+
},
|
|
62
|
+
}).eq('project_id', projectId).eq('id', contact.id);
|
|
63
|
+
return { kind: 'contact', sourceId, rowId };
|
|
64
|
+
}
|
|
65
|
+
async function writeBackMcpContact(projectId, contact, patch) {
|
|
66
|
+
try {
|
|
67
|
+
const customer = await byodWritebackClient(projectId);
|
|
68
|
+
if (!customer || !contact?.id || !contact?.email)
|
|
69
|
+
return;
|
|
70
|
+
const target = await resolveMcpByodTarget(projectId, contact, customer);
|
|
71
|
+
if (!target)
|
|
72
|
+
return;
|
|
73
|
+
const customPatch = patch.custom_properties;
|
|
74
|
+
if (target.kind === 'state') {
|
|
75
|
+
const statePatch = Object.fromEntries(Object.entries(patch).filter(([key]) => !['email', 'first_name', 'last_name', 'company_id'].includes(key)));
|
|
76
|
+
if (customPatch) {
|
|
77
|
+
const existing = await customer.schema('public').from('sendinel_subscriber_state').select('custom_properties').eq('source_id', target.sourceId).maybeSingle();
|
|
78
|
+
statePatch.custom_properties = { ...(existing.data?.custom_properties ?? {}), ...customPatch };
|
|
79
|
+
}
|
|
80
|
+
await customer.schema('public').from('sendinel_subscriber_state').upsert({ source_id: target.sourceId, ...statePatch, updated_at: new Date().toISOString() }, { onConflict: 'source_id' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const update = { ...patch, updated_at: new Date().toISOString() };
|
|
84
|
+
if (customPatch) {
|
|
85
|
+
const existing = await customer.schema('email').from('subscribers').select('custom_properties').eq('id', target.rowId).maybeSingle();
|
|
86
|
+
update.custom_properties = { ...(existing.data?.custom_properties ?? {}), ...customPatch };
|
|
87
|
+
}
|
|
88
|
+
await customer.schema('email').from('subscribers').update(update).eq('id', target.rowId);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
logAudit({ action: 'byod_writeback_failed', resourceType: 'contact', resourceId: contact?.id, details: { email: contact?.email, error: error instanceof Error ? error.message : String(error) } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
15
94
|
function daysAgo(days) {
|
|
16
95
|
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
17
96
|
}
|
|
@@ -248,6 +327,13 @@ async function exportSubscriberData(projectId, contactId) {
|
|
|
248
327
|
}
|
|
249
328
|
export function registerContactTools(server) {
|
|
250
329
|
server.registerTool('get_subscriber_timeline', {
|
|
330
|
+
title: 'Get Subscriber Timeline',
|
|
331
|
+
annotations: {
|
|
332
|
+
readOnlyHint: true,
|
|
333
|
+
destructiveHint: false,
|
|
334
|
+
idempotentHint: true,
|
|
335
|
+
openWorldHint: false,
|
|
336
|
+
},
|
|
251
337
|
description: 'Get a merged activity timeline for a contact.',
|
|
252
338
|
inputSchema: { subscriber_id: z.string().uuid(), limit: z.number().int().min(1).max(100).optional().default(50) },
|
|
253
339
|
}, async ({ subscriber_id, limit = 50 }) => {
|
|
@@ -270,15 +356,29 @@ export function registerContactTools(server) {
|
|
|
270
356
|
return j({ events: timeline });
|
|
271
357
|
});
|
|
272
358
|
server.registerTool('get_hygiene_config', {
|
|
359
|
+
title: 'Get Hygiene Config',
|
|
360
|
+
annotations: {
|
|
361
|
+
readOnlyHint: true,
|
|
362
|
+
destructiveHint: false,
|
|
363
|
+
idempotentHint: true,
|
|
364
|
+
openWorldHint: false,
|
|
365
|
+
},
|
|
273
366
|
description: 'Get list hygiene automation config.',
|
|
274
367
|
inputSchema: {},
|
|
275
368
|
}, async () => {
|
|
276
|
-
const { data, error } = await db().from('
|
|
369
|
+
const { data, error } = await db().from('hygiene_config').select('*').eq('project_id', getProjectId()).maybeSingle();
|
|
277
370
|
if (error)
|
|
278
371
|
return j({ error: error.message });
|
|
279
|
-
return j(data ?? { enabled: false, inactive_days: 90, action: 'tag', tag_name: 'cold', suppress_after_days: 180, last_run_at: null });
|
|
372
|
+
return j(data ?? { enabled: false, inactive_days: 90, action: 'tag', tag_name: 'cold', suppress_after_days: 180, activity_property: null, last_run_at: null });
|
|
280
373
|
});
|
|
281
374
|
server.registerTool('update_hygiene_config', {
|
|
375
|
+
title: 'Update Hygiene Config',
|
|
376
|
+
annotations: {
|
|
377
|
+
readOnlyHint: false,
|
|
378
|
+
destructiveHint: false,
|
|
379
|
+
idempotentHint: true,
|
|
380
|
+
openWorldHint: false,
|
|
381
|
+
},
|
|
282
382
|
description: 'Update list hygiene automation config.',
|
|
283
383
|
inputSchema: {
|
|
284
384
|
enabled: z.boolean().optional(),
|
|
@@ -286,14 +386,32 @@ export function registerContactTools(server) {
|
|
|
286
386
|
action: z.enum(['tag', 'suppress', 'tag_and_suppress']).optional(),
|
|
287
387
|
tag_name: z.string().optional(),
|
|
288
388
|
suppress_after_days: z.number().int().min(1).optional(),
|
|
389
|
+
activity_property: z.string().optional(),
|
|
289
390
|
},
|
|
290
391
|
}, async (config) => {
|
|
291
|
-
const
|
|
392
|
+
const activityProperty = typeof config.activity_property === 'string' && config.activity_property.trim()
|
|
393
|
+
? config.activity_property.trim().slice(0, 128)
|
|
394
|
+
: null;
|
|
395
|
+
const row = {
|
|
396
|
+
project_id: getProjectId(),
|
|
397
|
+
...config,
|
|
398
|
+
updated_at: new Date().toISOString(),
|
|
399
|
+
};
|
|
400
|
+
if (Object.prototype.hasOwnProperty.call(config, 'activity_property'))
|
|
401
|
+
row.activity_property = activityProperty;
|
|
402
|
+
const { error } = await db().from('hygiene_config').upsert(row, { onConflict: 'project_id' });
|
|
292
403
|
if (error)
|
|
293
404
|
return j({ error: error.message });
|
|
294
405
|
return j({ updated: true });
|
|
295
406
|
});
|
|
296
407
|
server.registerTool('run_list_hygiene', {
|
|
408
|
+
title: 'Run List Hygiene',
|
|
409
|
+
annotations: {
|
|
410
|
+
readOnlyHint: false,
|
|
411
|
+
destructiveHint: true,
|
|
412
|
+
idempotentHint: false,
|
|
413
|
+
openWorldHint: false,
|
|
414
|
+
},
|
|
297
415
|
description: 'Run list hygiene immediately for the project.',
|
|
298
416
|
inputSchema: {},
|
|
299
417
|
}, async () => {
|
|
@@ -307,6 +425,13 @@ export function registerContactTools(server) {
|
|
|
307
425
|
return j({ tagged: ids.length, suppressed: 0, skipped: 0 });
|
|
308
426
|
});
|
|
309
427
|
server.registerTool('list_hygiene_audit', {
|
|
428
|
+
title: 'List Hygiene Audit',
|
|
429
|
+
annotations: {
|
|
430
|
+
readOnlyHint: true,
|
|
431
|
+
destructiveHint: false,
|
|
432
|
+
idempotentHint: true,
|
|
433
|
+
openWorldHint: false,
|
|
434
|
+
},
|
|
310
435
|
description: 'Audit list engagement health and identify sunset candidates while excluding active paying customers.',
|
|
311
436
|
inputSchema: {
|
|
312
437
|
siteId: z.string().uuid(),
|
|
@@ -371,13 +496,21 @@ export function registerContactTools(server) {
|
|
|
371
496
|
});
|
|
372
497
|
});
|
|
373
498
|
server.registerTool('list_win_back_draft', {
|
|
499
|
+
title: 'List Win Back Draft',
|
|
500
|
+
annotations: {
|
|
501
|
+
readOnlyHint: true,
|
|
502
|
+
destructiveHint: false,
|
|
503
|
+
idempotentHint: true,
|
|
504
|
+
openWorldHint: true,
|
|
505
|
+
},
|
|
374
506
|
description: 'Draft a human-approved win-back email for dormant or sunset contacts. Does not enqueue or send.',
|
|
375
507
|
inputSchema: {
|
|
376
508
|
siteId: z.string().uuid(),
|
|
377
509
|
segmentId: z.string().uuid().optional(),
|
|
378
510
|
cohort: z.enum(['dormant', 'sunset_candidates']).optional().default('dormant'),
|
|
511
|
+
draftStyle: z.enum(['short_question', 'full_recap']).optional().default('short_question'),
|
|
379
512
|
},
|
|
380
|
-
}, async ({ siteId, segmentId, cohort = 'dormant' }) => {
|
|
513
|
+
}, async ({ siteId, segmentId, cohort = 'dormant', draftStyle = 'short_question' }) => {
|
|
381
514
|
const projectId = getProjectId();
|
|
382
515
|
const [{ data: site }, { data: subs }] = await Promise.all([
|
|
383
516
|
db().from('sites').select('id, name, sending_domain, brand_voice, projects(org_id, organizations(brand_settings))').eq('id', siteId).eq('project_id', projectId).maybeSingle(),
|
|
@@ -408,10 +541,19 @@ export function registerContactTools(server) {
|
|
|
408
541
|
if (!apiKey)
|
|
409
542
|
return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_UNAVAILABLE' });
|
|
410
543
|
const client = new Anthropic({ apiKey });
|
|
544
|
+
// Short single-question re-engagement copy outperforms full recaps in
|
|
545
|
+
// practice (median document view time ~17-32s -- see #1986) -- default to
|
|
546
|
+
// it, same style contract as buildCertificateEmailUser's reengagement
|
|
547
|
+
// branch, but this tool builds its own prompt directly rather than going
|
|
548
|
+
// through voice.ts.
|
|
549
|
+
const styleInstruction = draftStyle === 'full_recap'
|
|
550
|
+
? 'Style: FULL RECAP -- briefly restate the offer/value before the CTA.'
|
|
551
|
+
: 'Style: SHORT SINGLE QUESTION (default) -- 2-3 short sentences total, ending in ONE direct, easy-to-answer question. Do not recap the full offer, list features, or repeat prior email content. Assume the reader skims in under 30 seconds.';
|
|
411
552
|
const prompt = `Draft a win-back email for ${site.name}.
|
|
412
553
|
Cohort: ${cohort}
|
|
413
554
|
Sample contacts: ${JSON.stringify((sample ?? []).slice(0, 10))}
|
|
414
555
|
Brand settings: ${JSON.stringify({ site_brand_voice: site.brand_voice, org_brand_settings: site.projects?.organizations?.brand_settings })}
|
|
556
|
+
${styleInstruction}
|
|
415
557
|
|
|
416
558
|
Return JSON only with subject, preview_text, body_html, cta_text, reasoning.
|
|
417
559
|
Requirements: urgency without desperation, acknowledge the gap, one clear CTA, visible one-click unsubscribe link in body using {{unsubscribe_url}}, no auto-send language.`;
|
|
@@ -431,6 +573,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
431
573
|
site_id: siteId,
|
|
432
574
|
segment_id: segmentId ?? null,
|
|
433
575
|
cohort,
|
|
576
|
+
draft_style: draftStyle,
|
|
434
577
|
sample_size: sample?.length ?? 0,
|
|
435
578
|
draft,
|
|
436
579
|
auto_enqueued: false,
|
|
@@ -438,6 +581,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
438
581
|
});
|
|
439
582
|
});
|
|
440
583
|
server.registerTool('list_subscribers', {
|
|
584
|
+
title: 'List Subscribers',
|
|
585
|
+
annotations: {
|
|
586
|
+
readOnlyHint: true,
|
|
587
|
+
destructiveHint: false,
|
|
588
|
+
idempotentHint: true,
|
|
589
|
+
openWorldHint: false,
|
|
590
|
+
},
|
|
441
591
|
description: 'List contacts, optionally filtered by site and/or search term',
|
|
442
592
|
inputSchema: {
|
|
443
593
|
site_id: z.string().uuid().optional(),
|
|
@@ -490,6 +640,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
490
640
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
491
641
|
});
|
|
492
642
|
server.registerTool('list_companies', {
|
|
643
|
+
title: 'List Companies',
|
|
644
|
+
annotations: {
|
|
645
|
+
readOnlyHint: true,
|
|
646
|
+
destructiveHint: false,
|
|
647
|
+
idempotentHint: true,
|
|
648
|
+
openWorldHint: false,
|
|
649
|
+
},
|
|
493
650
|
description: 'List linked companies for the current project with rolled-up contact engagement metrics.',
|
|
494
651
|
inputSchema: {
|
|
495
652
|
search: z.string().optional().describe('Partial match on company name or domain'),
|
|
@@ -535,6 +692,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
535
692
|
})));
|
|
536
693
|
});
|
|
537
694
|
server.registerTool('add_subscriber', {
|
|
695
|
+
title: 'Add Subscriber',
|
|
696
|
+
annotations: {
|
|
697
|
+
readOnlyHint: false,
|
|
698
|
+
destructiveHint: false,
|
|
699
|
+
idempotentHint: false,
|
|
700
|
+
openWorldHint: false,
|
|
701
|
+
},
|
|
538
702
|
description: 'Add a contact and subscribe them to a site',
|
|
539
703
|
inputSchema: {
|
|
540
704
|
email: z.string().email(),
|
|
@@ -612,14 +776,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
612
776
|
if (needsConfirm) {
|
|
613
777
|
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
614
778
|
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
779
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
615
780
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
616
781
|
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.' }, null, 2) }] };
|
|
617
782
|
}
|
|
783
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
618
784
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
619
785
|
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
|
620
786
|
return { content: [{ type: 'text', text: JSON.stringify(contact, null, 2) }] };
|
|
621
787
|
});
|
|
622
788
|
server.registerTool('import_subscribers', {
|
|
789
|
+
title: 'Import Subscribers',
|
|
790
|
+
annotations: {
|
|
791
|
+
readOnlyHint: false,
|
|
792
|
+
destructiveHint: false,
|
|
793
|
+
idempotentHint: false,
|
|
794
|
+
openWorldHint: false,
|
|
795
|
+
},
|
|
623
796
|
description: 'Bulk import contacts and subscribe them to a site. Max 200 per call.',
|
|
624
797
|
inputSchema: {
|
|
625
798
|
contacts: z.array(z.object({
|
|
@@ -693,7 +866,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
693
866
|
const { data: upserted, error: uErr } = await db()
|
|
694
867
|
.from('subscribers')
|
|
695
868
|
.upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: false })
|
|
696
|
-
.select('id, email');
|
|
869
|
+
.select('id, email, first_name, last_name, custom_properties');
|
|
697
870
|
if (uErr)
|
|
698
871
|
return { content: [{ type: 'text', text: `Error: ${uErr.message}` }] };
|
|
699
872
|
const contactRows = upserted ?? [];
|
|
@@ -701,11 +874,19 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
701
874
|
if (subRows.length) {
|
|
702
875
|
await db().from('site_subscriptions').upsert(subRows, { onConflict: 'contact_id,site_id', ignoreDuplicates: true });
|
|
703
876
|
}
|
|
877
|
+
await Promise.allSettled(contactRows.map((contact) => writeBackMcpContact(projectId, contact, { email: contact.email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null })));
|
|
704
878
|
const created = contactRows.filter((c) => !existingEmails.has(c.email)).length;
|
|
705
879
|
const updated = contactRows.length - created;
|
|
706
880
|
return { content: [{ type: 'text', text: JSON.stringify({ created, updated, total: contactRows.length }, null, 2) }] };
|
|
707
881
|
});
|
|
708
882
|
server.registerTool('unsubscribe_subscriber', {
|
|
883
|
+
title: 'Unsubscribe Subscriber',
|
|
884
|
+
annotations: {
|
|
885
|
+
readOnlyHint: false,
|
|
886
|
+
destructiveHint: true,
|
|
887
|
+
idempotentHint: false,
|
|
888
|
+
openWorldHint: false,
|
|
889
|
+
},
|
|
709
890
|
description: 'Globally unsubscribe a contact from all sites',
|
|
710
891
|
inputSchema: {
|
|
711
892
|
email: z.string().email(),
|
|
@@ -717,7 +898,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
717
898
|
.update({ global_unsubscribed: true, global_unsubscribed_at: now })
|
|
718
899
|
.eq('project_id', getProjectId())
|
|
719
900
|
.eq('email', email)
|
|
720
|
-
.select('id, email')
|
|
901
|
+
.select('id, email, first_name, last_name, custom_properties')
|
|
721
902
|
.single();
|
|
722
903
|
if (error)
|
|
723
904
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
@@ -730,10 +911,18 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
730
911
|
.eq('subscribed', true);
|
|
731
912
|
logConsent({ contactId: data.id, action: 'revoke', consentType: 'email_marketing', source: 'mcp_unsubscribe' });
|
|
732
913
|
logAudit({ action: 'contact.unsubscribed', resourceType: 'contact', resourceId: data.id, details: { email } });
|
|
914
|
+
await writeBackMcpContact(getProjectId(), data, { global_unsubscribed: true, custom_properties: { global_unsubscribed_at: now } });
|
|
733
915
|
}
|
|
734
916
|
return { content: [{ type: 'text', text: JSON.stringify({ unsubscribed: true, ...data }, null, 2) }] };
|
|
735
917
|
});
|
|
736
918
|
server.registerTool('list_email_log', {
|
|
919
|
+
title: 'List Email Log',
|
|
920
|
+
annotations: {
|
|
921
|
+
readOnlyHint: true,
|
|
922
|
+
destructiveHint: false,
|
|
923
|
+
idempotentHint: true,
|
|
924
|
+
openWorldHint: false,
|
|
925
|
+
},
|
|
737
926
|
description: 'Recent email sends with status, filterable by site and/or recipient',
|
|
738
927
|
inputSchema: {
|
|
739
928
|
site_id: z.string().uuid().optional(),
|
|
@@ -760,6 +949,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
760
949
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
761
950
|
});
|
|
762
951
|
server.registerTool('list_suppressions', {
|
|
952
|
+
title: 'List Suppressions',
|
|
953
|
+
annotations: {
|
|
954
|
+
readOnlyHint: true,
|
|
955
|
+
destructiveHint: false,
|
|
956
|
+
idempotentHint: true,
|
|
957
|
+
openWorldHint: false,
|
|
958
|
+
},
|
|
763
959
|
description: 'List suppressed email addresses (hard bounces, complaints, manual blocks)',
|
|
764
960
|
inputSchema: {
|
|
765
961
|
limit: z.number().int().min(1).max(500).optional().default(100),
|
|
@@ -780,6 +976,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
780
976
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
781
977
|
});
|
|
782
978
|
server.registerTool('add_suppression', {
|
|
979
|
+
title: 'Add Suppression',
|
|
980
|
+
annotations: {
|
|
981
|
+
readOnlyHint: false,
|
|
982
|
+
destructiveHint: true,
|
|
983
|
+
idempotentHint: false,
|
|
984
|
+
openWorldHint: false,
|
|
985
|
+
},
|
|
783
986
|
description: 'Manually add an email to the suppression list',
|
|
784
987
|
inputSchema: {
|
|
785
988
|
email: z.string().email(),
|
|
@@ -797,6 +1000,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
797
1000
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
798
1001
|
});
|
|
799
1002
|
server.registerTool('list_subscribers_by_segment', {
|
|
1003
|
+
title: 'List Subscribers By Segment',
|
|
1004
|
+
annotations: {
|
|
1005
|
+
readOnlyHint: true,
|
|
1006
|
+
destructiveHint: false,
|
|
1007
|
+
idempotentHint: true,
|
|
1008
|
+
openWorldHint: false,
|
|
1009
|
+
},
|
|
800
1010
|
description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
|
|
801
1011
|
inputSchema: {
|
|
802
1012
|
lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
|
|
@@ -853,6 +1063,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
853
1063
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
854
1064
|
});
|
|
855
1065
|
server.registerTool('update_subscriber', {
|
|
1066
|
+
title: 'Update Subscriber',
|
|
1067
|
+
annotations: {
|
|
1068
|
+
readOnlyHint: false,
|
|
1069
|
+
destructiveHint: false,
|
|
1070
|
+
idempotentHint: true,
|
|
1071
|
+
openWorldHint: false,
|
|
1072
|
+
},
|
|
856
1073
|
description: 'Update a contact\'s email, name, tags, or custom properties',
|
|
857
1074
|
inputSchema: {
|
|
858
1075
|
contact_id: z.string().uuid(),
|
|
@@ -863,7 +1080,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
863
1080
|
company_name: z.string().nullable().optional(),
|
|
864
1081
|
company_domain: z.string().nullable().optional(),
|
|
865
1082
|
tags: z.array(z.string()).optional(),
|
|
866
|
-
custom_properties: z.record(z.unknown()).optional().describe('Merged into existing custom_properties'),
|
|
1083
|
+
custom_properties: z.record(z.string(), z.unknown()).optional().describe('Merged into existing custom_properties'),
|
|
867
1084
|
},
|
|
868
1085
|
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties }) => {
|
|
869
1086
|
const projectId = getProjectId();
|
|
@@ -925,9 +1142,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
925
1142
|
.single();
|
|
926
1143
|
if (error)
|
|
927
1144
|
return txt(`Error: ${error.message}`);
|
|
1145
|
+
await writeBackMcpContact(projectId, data, {
|
|
1146
|
+
...(updates.email !== undefined ? { email: updates.email } : {}),
|
|
1147
|
+
...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
|
|
1148
|
+
...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
|
|
1149
|
+
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
1150
|
+
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
1151
|
+
});
|
|
928
1152
|
return j(data);
|
|
929
1153
|
});
|
|
930
1154
|
server.registerTool('get_subscriber', {
|
|
1155
|
+
title: 'Get Subscriber',
|
|
1156
|
+
annotations: {
|
|
1157
|
+
readOnlyHint: true,
|
|
1158
|
+
destructiveHint: false,
|
|
1159
|
+
idempotentHint: true,
|
|
1160
|
+
openWorldHint: false,
|
|
1161
|
+
},
|
|
931
1162
|
description: 'Get full contact details including site subscriptions, by ID or email',
|
|
932
1163
|
inputSchema: {
|
|
933
1164
|
contact_id: z.string().uuid().optional(),
|
|
@@ -952,6 +1183,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
952
1183
|
return j(data);
|
|
953
1184
|
});
|
|
954
1185
|
server.registerTool('enrich_subscriber', {
|
|
1186
|
+
title: 'Enrich Subscriber',
|
|
1187
|
+
annotations: {
|
|
1188
|
+
readOnlyHint: false,
|
|
1189
|
+
destructiveHint: false,
|
|
1190
|
+
idempotentHint: false,
|
|
1191
|
+
openWorldHint: true,
|
|
1192
|
+
},
|
|
955
1193
|
description: 'Enrich a subscriber from a connected CRM/ESP provider and merge provider metadata plus company info when available.',
|
|
956
1194
|
inputSchema: {
|
|
957
1195
|
contact_id: z.string().uuid(),
|
|
@@ -1013,6 +1251,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1013
1251
|
return j({ contact: updated, enriched: true, provider: enriched.provider, applied_fields: appliedFields });
|
|
1014
1252
|
});
|
|
1015
1253
|
server.registerTool('subscriber_export', {
|
|
1254
|
+
title: 'Subscriber Export',
|
|
1255
|
+
annotations: {
|
|
1256
|
+
readOnlyHint: true,
|
|
1257
|
+
destructiveHint: false,
|
|
1258
|
+
idempotentHint: true,
|
|
1259
|
+
openWorldHint: false,
|
|
1260
|
+
},
|
|
1016
1261
|
description: 'Export a contact data portability bundle with contact metadata, email history, and suppression status.',
|
|
1017
1262
|
inputSchema: {
|
|
1018
1263
|
contactId: z.string().uuid(),
|
|
@@ -1022,6 +1267,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1022
1267
|
return j(result);
|
|
1023
1268
|
});
|
|
1024
1269
|
server.registerTool('set_subscriber_tags', {
|
|
1270
|
+
title: 'Set Subscriber Tags',
|
|
1271
|
+
annotations: {
|
|
1272
|
+
readOnlyHint: false,
|
|
1273
|
+
destructiveHint: false,
|
|
1274
|
+
idempotentHint: true,
|
|
1275
|
+
openWorldHint: false,
|
|
1276
|
+
},
|
|
1025
1277
|
description: 'Add or remove tags from one or more contacts',
|
|
1026
1278
|
inputSchema: {
|
|
1027
1279
|
contact_ids: z.array(z.string().uuid()).min(1),
|
|
@@ -1036,7 +1288,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1036
1288
|
for (const cid of contact_ids) {
|
|
1037
1289
|
const { data: contact } = await db()
|
|
1038
1290
|
.from('subscribers')
|
|
1039
|
-
.select('tags')
|
|
1291
|
+
.select('id, email, first_name, last_name, custom_properties, tags')
|
|
1040
1292
|
.eq('id', cid)
|
|
1041
1293
|
.eq('project_id', projectId)
|
|
1042
1294
|
.maybeSingle();
|
|
@@ -1054,12 +1306,21 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1054
1306
|
.update({ tags: merged })
|
|
1055
1307
|
.eq('id', cid)
|
|
1056
1308
|
.eq('project_id', projectId);
|
|
1057
|
-
if (!error)
|
|
1309
|
+
if (!error) {
|
|
1058
1310
|
updated++;
|
|
1311
|
+
await writeBackMcpContact(projectId, contact, { tags: merged });
|
|
1312
|
+
}
|
|
1059
1313
|
}
|
|
1060
1314
|
return j({ updated, total: contact_ids.length, add_tags, remove_tags });
|
|
1061
1315
|
});
|
|
1062
1316
|
server.registerTool('estimate_segment_size', {
|
|
1317
|
+
title: 'Estimate Segment Size',
|
|
1318
|
+
annotations: {
|
|
1319
|
+
readOnlyHint: true,
|
|
1320
|
+
destructiveHint: false,
|
|
1321
|
+
idempotentHint: true,
|
|
1322
|
+
openWorldHint: false,
|
|
1323
|
+
},
|
|
1063
1324
|
description: 'Estimate how many contacts match filter criteria, with a sample. Useful before creating a segment.',
|
|
1064
1325
|
inputSchema: {
|
|
1065
1326
|
lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
|
|
@@ -1174,17 +1435,26 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1174
1435
|
return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
|
|
1175
1436
|
});
|
|
1176
1437
|
server.registerTool('identify_inactive', {
|
|
1438
|
+
title: 'Identify Inactive',
|
|
1439
|
+
annotations: {
|
|
1440
|
+
readOnlyHint: true,
|
|
1441
|
+
destructiveHint: false,
|
|
1442
|
+
idempotentHint: true,
|
|
1443
|
+
openWorldHint: false,
|
|
1444
|
+
},
|
|
1177
1445
|
description: 'Find contacts with no engagement in N days. Dry run by default; set dry_run=false to suppress them.',
|
|
1178
1446
|
inputSchema: {
|
|
1179
1447
|
days: z.number().int().min(1).optional().default(90).describe('Inactivity threshold in days'),
|
|
1180
1448
|
site_id: z.string().uuid().optional(),
|
|
1181
1449
|
dry_run: z.boolean().optional().default(true).describe('Preview only (true) or suppress (false)'),
|
|
1182
1450
|
limit: z.number().int().min(1).max(200).optional().default(20),
|
|
1451
|
+
approval_id: z.string().uuid().optional().describe('Required when dry_run=false. Get this by calling once without it, then have a human approve via dashboard.'),
|
|
1183
1452
|
},
|
|
1184
|
-
}, async ({ days = 90, site_id, dry_run = true, limit = 20 }) => {
|
|
1453
|
+
}, async ({ days = 90, site_id, dry_run = true, limit = 20, approval_id }) => {
|
|
1185
1454
|
const projectId = getProjectId();
|
|
1186
1455
|
const sampleLimit = Math.min(limit, 200);
|
|
1187
1456
|
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
1457
|
+
const approvalParams = { days, site_id, dry_run: false, limit: sampleLimit };
|
|
1188
1458
|
let q = db()
|
|
1189
1459
|
.from('subscribers')
|
|
1190
1460
|
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_emails_sent')
|
|
@@ -1213,6 +1483,24 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1213
1483
|
return txt(`Error: ${error.message}`);
|
|
1214
1484
|
const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
|
|
1215
1485
|
if (!dry_run && inactive.length > 0) {
|
|
1486
|
+
const channelContext = currentChannelContext();
|
|
1487
|
+
const decision = assertChannelCanExecute('identify_inactive', approvalParams, channelContext);
|
|
1488
|
+
if (!decision.allowed)
|
|
1489
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1490
|
+
if (decision.tier !== 'autonomous') {
|
|
1491
|
+
if (!approval_id) {
|
|
1492
|
+
const approval = await requestApproval('identify_inactive', approvalParams, undefined, channelContext);
|
|
1493
|
+
return j({
|
|
1494
|
+
...approval,
|
|
1495
|
+
preview: { inactive_count: inactive.length, sample: inactive },
|
|
1496
|
+
message: `Will suppress ${inactive.length} contact(s) inactive for ${days} days. Requires human approval.`,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'identify_inactive', params: approvalParams });
|
|
1500
|
+
if (!check.approved) {
|
|
1501
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1216
1504
|
const suppressionRows = inactive.map((c) => ({
|
|
1217
1505
|
project_id: projectId,
|
|
1218
1506
|
email: c.email,
|
|
@@ -1239,6 +1527,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1239
1527
|
});
|
|
1240
1528
|
});
|
|
1241
1529
|
server.registerTool('remove_suppression', {
|
|
1530
|
+
title: 'Remove Suppression',
|
|
1531
|
+
annotations: {
|
|
1532
|
+
readOnlyHint: false,
|
|
1533
|
+
destructiveHint: true,
|
|
1534
|
+
idempotentHint: false,
|
|
1535
|
+
openWorldHint: false,
|
|
1536
|
+
},
|
|
1242
1537
|
description: 'Remove an email address from the suppression list',
|
|
1243
1538
|
inputSchema: {
|
|
1244
1539
|
email: z.string().email(),
|
|
@@ -1258,6 +1553,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1258
1553
|
return j({ removed: true, email, previous_reason: data.reason });
|
|
1259
1554
|
});
|
|
1260
1555
|
server.registerTool('find_duplicates', {
|
|
1556
|
+
title: 'Find Duplicates',
|
|
1557
|
+
annotations: {
|
|
1558
|
+
readOnlyHint: true,
|
|
1559
|
+
destructiveHint: false,
|
|
1560
|
+
idempotentHint: true,
|
|
1561
|
+
openWorldHint: false,
|
|
1562
|
+
},
|
|
1261
1563
|
description: 'Find duplicate contacts using fuzzy email matching and exact name matching. Returns grouped candidates with confidence scores.',
|
|
1262
1564
|
inputSchema: {
|
|
1263
1565
|
confidence_threshold: z.number().min(0.5).max(1).optional().describe('Minimum similarity (0.5-1.0, default 0.8)'),
|
|
@@ -1368,6 +1670,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1368
1670
|
return j({ duplicate_groups: result, total_groups: result.length });
|
|
1369
1671
|
});
|
|
1370
1672
|
server.registerTool('merge_subscribers', {
|
|
1673
|
+
title: 'Merge Subscribers',
|
|
1674
|
+
annotations: {
|
|
1675
|
+
readOnlyHint: false,
|
|
1676
|
+
destructiveHint: true,
|
|
1677
|
+
idempotentHint: false,
|
|
1678
|
+
openWorldHint: false,
|
|
1679
|
+
},
|
|
1371
1680
|
description: 'Merge duplicate contacts into a target. Consolidates subscriptions, enrollments, email history, and consent records. Source contacts are deleted. Requires human approval via dashboard.',
|
|
1372
1681
|
inputSchema: {
|
|
1373
1682
|
target_id: z.string().uuid().describe('Contact to keep'),
|
|
@@ -1387,18 +1696,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1387
1696
|
.select('id, email, first_name, last_name')
|
|
1388
1697
|
.in('id', source_ids)
|
|
1389
1698
|
.eq('project_id', getProjectId());
|
|
1699
|
+
const approvalParams = {
|
|
1700
|
+
target_id, target_email: target?.email,
|
|
1701
|
+
source_ids, source_count: (sources ?? []).length,
|
|
1702
|
+
};
|
|
1390
1703
|
if (!approval_id) {
|
|
1391
|
-
const
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1704
|
+
const channelContext = currentChannelContext();
|
|
1705
|
+
const decision = assertChannelCanExecute('merge_subscribers', { target_id, source_ids }, channelContext);
|
|
1706
|
+
if (!decision.allowed)
|
|
1707
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1708
|
+
const approval = await requestApproval('merge_subscribers', approvalParams, undefined, channelContext);
|
|
1395
1709
|
return j({
|
|
1396
1710
|
...approval,
|
|
1397
1711
|
preview: { target, sources: sources ?? [] },
|
|
1398
1712
|
message: `Will merge ${(sources ?? []).length} contact(s) into ${target?.email ?? target_id}. Source contacts will be deleted. Requires human approval.`,
|
|
1399
1713
|
});
|
|
1400
1714
|
}
|
|
1401
|
-
const check = await checkApproval(approval_id, { consume: true });
|
|
1715
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'merge_subscribers', params: approvalParams });
|
|
1402
1716
|
if (!check.approved) {
|
|
1403
1717
|
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
1404
1718
|
}
|
|
@@ -1413,6 +1727,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1413
1727
|
return j(data);
|
|
1414
1728
|
});
|
|
1415
1729
|
server.registerTool('get_contact_schema', {
|
|
1730
|
+
title: 'Get Contact Schema',
|
|
1731
|
+
annotations: {
|
|
1732
|
+
readOnlyHint: true,
|
|
1733
|
+
destructiveHint: false,
|
|
1734
|
+
idempotentHint: true,
|
|
1735
|
+
openWorldHint: false,
|
|
1736
|
+
},
|
|
1416
1737
|
description: 'Discover all custom_properties keys used across contacts for a site. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
|
|
1417
1738
|
inputSchema: {
|
|
1418
1739
|
site_id: z.string().uuid().describe('Site to inspect — only contacts belonging to this site are sampled'),
|