@sendinel/mcp-server 1.0.4 → 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.
Files changed (60) hide show
  1. package/build/audit.d.ts +1 -0
  2. package/build/audit.js +9 -3
  3. package/build/auth.d.ts +10 -0
  4. package/build/auth.js +82 -9
  5. package/build/db.d.ts +12 -0
  6. package/build/db.js +37 -2
  7. package/build/index.js +38 -12
  8. package/build/lib/action-approvals.d.ts +28 -1
  9. package/build/lib/action-approvals.js +89 -2
  10. package/build/lib/brand-asset-persist.d.ts +35 -0
  11. package/build/lib/brand-asset-persist.js +161 -0
  12. package/build/lib/brand-asset-usage.d.ts +13 -0
  13. package/build/lib/brand-asset-usage.js +46 -0
  14. package/build/lib/email-provider.d.ts +19 -0
  15. package/build/lib/email-provider.js +281 -0
  16. package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
  17. package/build/lib/ssrf-pinned-fetch.js +101 -0
  18. package/build/lib/survey-link-resolver.d.ts +6 -0
  19. package/build/lib/survey-link-resolver.js +23 -0
  20. package/build/lib/svg-sanitize.d.ts +2 -0
  21. package/build/lib/svg-sanitize.js +110 -0
  22. package/build/lib/webhook-events.d.ts +1 -1
  23. package/build/scoped-token.d.ts +11 -0
  24. package/build/scoped-token.js +59 -0
  25. package/build/server.js +30 -9
  26. package/build/tools/ab-testing.js +28 -0
  27. package/build/tools/advisor.js +52 -2
  28. package/build/tools/ai-work.d.ts +2 -0
  29. package/build/tools/ai-work.js +460 -0
  30. package/build/tools/analytics.d.ts +1 -0
  31. package/build/tools/analytics.js +203 -0
  32. package/build/tools/approvals.js +15 -1
  33. package/build/tools/automations.js +288 -65
  34. package/build/tools/brand-kit.d.ts +2 -0
  35. package/build/tools/brand-kit.js +277 -0
  36. package/build/tools/campaigns.js +900 -69
  37. package/build/tools/compound.js +28 -0
  38. package/build/tools/contacts.js +338 -17
  39. package/build/tools/content.js +266 -1
  40. package/build/tools/data-proposals.js +21 -0
  41. package/build/tools/data.js +121 -5
  42. package/build/tools/delivery-ops.js +84 -0
  43. package/build/tools/drafts.js +64 -1
  44. package/build/tools/forms.js +42 -9
  45. package/build/tools/gdpr.js +22 -3
  46. package/build/tools/newsletters.d.ts +2 -0
  47. package/build/tools/newsletters.js +583 -0
  48. package/build/tools/org.js +297 -3
  49. package/build/tools/segments.js +45 -3
  50. package/build/tools/short-links.d.ts +2 -0
  51. package/build/tools/short-links.js +157 -0
  52. package/build/tools/sites.js +35 -0
  53. package/build/tools/sms.js +49 -0
  54. package/build/tools/social-posts.js +81 -5
  55. package/build/tools/surveys.d.ts +2 -0
  56. package/build/tools/surveys.js +222 -0
  57. package/build/tools/templates.js +78 -2
  58. package/build/tools/warmup.js +28 -0
  59. package/build/tools/webhooks.js +29 -0
  60. package/package.json +9 -3
@@ -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('list_hygiene_config').select('*').eq('project_id', getProjectId()).maybeSingle();
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 { error } = await db().from('list_hygiene_config').upsert({ project_id: getProjectId(), ...config, updated_at: new Date().toISOString() }, { onConflict: 'project_id' });
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 approval = await requestApproval('merge_subscribers', {
1392
- target_id, target_email: target?.email,
1393
- source_ids, source_count: (sources ?? []).length,
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'),