@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.
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 +10 -4
@@ -5,13 +5,260 @@ import { getProjectId } from '../project.js';
5
5
  import { checkPlanLimit } from '../plan-limits.js';
6
6
  import { buildVoicePrompt, buildEmailSystemPrompt, validateTemplate } from '../lib/voice.js';
7
7
  import { fireWebhookEvent } from '../lib/webhook-events.js';
8
- import { getApprovalTier, requestApproval, checkApproval } from '../lib/action-approvals.js';
8
+ import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
9
9
  import { getAnthropicModelId } from '../anthropic-model.js';
10
10
  import { getScheduleWindowBounds, groupScheduleByCampaign } from '../lib/schedule.js';
11
+ import { sendEmailForSite } from '../lib/email-provider.js';
12
+ import { renderTemplateString, resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
13
+ import { logAudit } from '../audit.js';
14
+ import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
11
15
  const txt = (text) => ({ content: [{ type: 'text', text }] });
12
16
  const j = (data) => txt(JSON.stringify(data, null, 2));
17
+ function normalizeTriggerValue(value) {
18
+ if (typeof value !== 'string')
19
+ return null;
20
+ const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, '_');
21
+ return normalized || null;
22
+ }
23
+ function normalizeTriggerList(value) {
24
+ const raw = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
25
+ return Array.from(new Set(raw.map(normalizeTriggerValue).filter((item) => Boolean(item))));
26
+ }
27
+ function buildCampaignTriggerConfig(value) {
28
+ if (!value || typeof value !== 'object' || Array.isArray(value))
29
+ return null;
30
+ const config = value;
31
+ const events = normalizeTriggerList(config.events ?? config.event_names ?? config.trigger_event_names);
32
+ const tags = normalizeTriggerList(config.tags ?? config.trigger_tags ?? config.entity_tags ?? config.project_tags);
33
+ if (!events.length && !tags.length)
34
+ return null;
35
+ return { events, tags };
36
+ }
37
+ async function applyCampaignTargetingFields(updates, input, projectId) {
38
+ if (input.trigger_event_name !== undefined)
39
+ updates.trigger_event_name = input.trigger_event_name?.trim() || null;
40
+ if (input.trigger_tag !== undefined)
41
+ updates.trigger_tag = input.trigger_tag?.trim().toLowerCase() || null;
42
+ if (input.trigger_type === 'date_field') {
43
+ const config = input.trigger_config ?? {};
44
+ const field = typeof config.field === 'string' ? config.field.trim() : '';
45
+ if (!/^[A-Za-z0-9_.-]{1,80}$/.test(field))
46
+ return 'date field is required';
47
+ updates.trigger_type = 'date_field';
48
+ updates.trigger_config = {
49
+ field,
50
+ days_before: Math.max(0, Math.min(30, Number(config.days_before ?? 0) || 0)),
51
+ recurring: config.recurring !== false,
52
+ };
53
+ }
54
+ else if (input.trigger_config !== undefined) {
55
+ if (input.trigger_config === null) {
56
+ updates.trigger_config = null;
57
+ }
58
+ else {
59
+ const config = buildCampaignTriggerConfig(input.trigger_config);
60
+ if (!config)
61
+ return 'trigger_config must include events or tags';
62
+ updates.trigger_config = config;
63
+ }
64
+ }
65
+ if (input.segment_id !== undefined) {
66
+ if (!input.segment_id) {
67
+ updates.segment_id = null;
68
+ }
69
+ else {
70
+ const { data: segment, error } = await db()
71
+ .from('segments')
72
+ .select('id')
73
+ .eq('id', input.segment_id)
74
+ .eq('project_id', projectId)
75
+ .maybeSingle();
76
+ if (error)
77
+ return error.message;
78
+ if (!segment)
79
+ return 'Segment not found';
80
+ updates.segment_id = input.segment_id;
81
+ }
82
+ }
83
+ return null;
84
+ }
85
+ function applySegmentPaymentRules(query, payment) {
86
+ if (!payment)
87
+ return query;
88
+ let q = query;
89
+ if (payment.plan)
90
+ q = q.contains('custom_properties', { payment: { plan: payment.plan } });
91
+ if (payment.mrr_cents !== undefined)
92
+ q = q.contains('custom_properties', { payment: { mrr_cents: payment.mrr_cents } });
93
+ if (payment.ltv_cents !== undefined)
94
+ q = q.contains('custom_properties', { payment: { ltv_cents: payment.ltv_cents } });
95
+ if (payment.subscription_status)
96
+ q = q.contains('custom_properties', { payment: { subscription_status: payment.subscription_status } });
97
+ q = applySegmentPaymentDateRule(q, 'trial_ends_at', payment.trial_ends_at);
98
+ q = applySegmentPaymentDateRule(q, 'churned_at', payment.churned_at);
99
+ q = applySegmentPaymentDateRule(q, 'paid_at', payment.paid_at);
100
+ return q;
101
+ }
102
+ function applySegmentPaymentDateRule(query, field, rule) {
103
+ if (!rule)
104
+ return query;
105
+ if (typeof rule === 'string') {
106
+ return query.contains('custom_properties', { payment: { [field]: rule } });
107
+ }
108
+ let q = query;
109
+ const column = `custom_properties->payment->>${field}`;
110
+ if (typeof rule.within_days === 'number') {
111
+ const start = new Date();
112
+ const end = new Date(start.getTime() + rule.within_days * 24 * 60 * 60 * 1000);
113
+ q = q.gte(column, start.toISOString()).lte(column, end.toISOString());
114
+ }
115
+ if (rule.after)
116
+ q = q.gte(column, rule.after);
117
+ if (rule.before)
118
+ q = q.lte(column, rule.before);
119
+ return q;
120
+ }
121
+ async function getSegmentContactIds(projectId, rules) {
122
+ let q = db()
123
+ .from('subscribers')
124
+ .select('id, email')
125
+ .eq('project_id', projectId)
126
+ .eq('global_unsubscribed', false)
127
+ .is('cooled_at', null);
128
+ if (rules.lifecycle_stage)
129
+ q = q.eq('lifecycle_stage', rules.lifecycle_stage);
130
+ q = applySegmentPaymentRules(q, rules.payment);
131
+ if (rules.min_score !== undefined)
132
+ q = q.gte('engagement_score', rules.min_score);
133
+ if (rules.max_score !== undefined)
134
+ q = q.lte('engagement_score', rules.max_score);
135
+ if (rules.source)
136
+ q = q.eq('source', rules.source);
137
+ if (rules.subscribed_after)
138
+ q = q.gte('created_at', `${rules.subscribed_after}T00:00:00Z`);
139
+ if (rules.subscribed_before)
140
+ q = q.lte('created_at', `${rules.subscribed_before}T23:59:59Z`);
141
+ if (rules.has_opened === true)
142
+ q = q.not('last_opened_at', 'is', null);
143
+ if (rules.has_opened === false)
144
+ q = q.is('last_opened_at', null);
145
+ if (rules.has_clicked === true)
146
+ q = q.not('last_clicked_at', 'is', null);
147
+ if (rules.has_clicked === false)
148
+ q = q.is('last_clicked_at', null);
149
+ if (rules.tag)
150
+ q = q.contains('tags', [rules.tag]);
151
+ if (rules.site_id) {
152
+ const { data: subs } = await db()
153
+ .from('site_subscriptions')
154
+ .select('contact_id')
155
+ .eq('site_id', rules.site_id)
156
+ .eq('subscribed', true);
157
+ const siteIds = (subs ?? []).map((s) => s.contact_id);
158
+ if (!siteIds.length)
159
+ return [];
160
+ q = q.in('id', siteIds);
161
+ }
162
+ const { data, error } = await q;
163
+ if (error || !data)
164
+ return [];
165
+ let ids = data.map((c) => c.id);
166
+ const { data: suppressed } = await db()
167
+ .from('suppressions')
168
+ .select('email')
169
+ .eq('project_id', projectId);
170
+ if (suppressed?.length) {
171
+ const suppressedEmails = new Set(suppressed.map((s) => s.email));
172
+ ids = data
173
+ .filter((c) => !suppressedEmails.has(c.email))
174
+ .map((c) => c.id);
175
+ }
176
+ return ids;
177
+ }
178
+ function extractJsonObject(raw) {
179
+ const cleaned = raw.replace(/```(?:json)?/gi, '').replace(/```/g, '').trim();
180
+ const start = cleaned.indexOf('{');
181
+ const end = cleaned.lastIndexOf('}');
182
+ if (start === -1 || end === -1 || end <= start) {
183
+ throw new Error('No JSON object found in consistency response');
184
+ }
185
+ return cleaned.slice(start, end + 1);
186
+ }
187
+ function cleanConsistencyText(value) {
188
+ return typeof value === 'string' ? value.trim().slice(0, 1000) : '';
189
+ }
190
+ function normalizeConsistencyResult(value) {
191
+ const obj = value && typeof value === 'object' ? value : {};
192
+ const rawFlags = Array.isArray(obj.flags) ? obj.flags : [];
193
+ const flags = rawFlags
194
+ .map((flag) => {
195
+ const record = flag && typeof flag === 'object' ? flag : {};
196
+ return {
197
+ claim_a: cleanConsistencyText(record.claim_a),
198
+ location_a: cleanConsistencyText(record.location_a),
199
+ claim_b: cleanConsistencyText(record.claim_b),
200
+ location_b: cleanConsistencyText(record.location_b),
201
+ explanation: cleanConsistencyText(record.explanation),
202
+ };
203
+ })
204
+ .filter((flag) => flag.claim_a && flag.claim_b && flag.explanation)
205
+ .slice(0, 50);
206
+ return {
207
+ consistent: typeof obj.consistent === 'boolean' ? obj.consistent : flags.length === 0,
208
+ flags,
209
+ };
210
+ }
13
211
  export function registerCampaignTools(server) {
212
+ server.registerTool('review_campaign_portfolio', {
213
+ title: 'Review Campaign Portfolio',
214
+ annotations: {
215
+ readOnlyHint: true,
216
+ destructiveHint: false,
217
+ idempotentHint: true,
218
+ openWorldHint: false,
219
+ },
220
+ description: 'Run the campaign portfolio review and optionally raise agent proposals for start/end/modify recommendations.',
221
+ inputSchema: {
222
+ dry_run: z.boolean().optional().default(false),
223
+ dormant_draft_days: z.number().int().min(1).max(365).optional(),
224
+ abandoned_days: z.number().int().min(1).max(365).optional(),
225
+ },
226
+ }, async ({ dry_run = false, dormant_draft_days, abandoned_days }) => {
227
+ const apiKey = process.env.SENDINEL_API_KEY;
228
+ if (!apiKey)
229
+ return j({ error: 'SENDINEL_API_KEY is required to run review_campaign_portfolio from MCP' });
230
+ const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
231
+ const res = await fetch(`${base}/api/v1/campaigns/review`, {
232
+ method: 'POST',
233
+ headers: {
234
+ Authorization: `Bearer ${apiKey}`,
235
+ 'Content-Type': 'application/json',
236
+ 'X-Sendinel-Project-Id': getProjectId(),
237
+ },
238
+ body: JSON.stringify({
239
+ dry_run,
240
+ ...(dormant_draft_days !== undefined ? { dormant_draft_days } : {}),
241
+ ...(abandoned_days !== undefined ? { abandoned_days } : {}),
242
+ }),
243
+ });
244
+ const body = await res.text();
245
+ if (!res.ok)
246
+ return j({ error: `Campaign portfolio review failed: ${res.status}`, body });
247
+ try {
248
+ return j(JSON.parse(body));
249
+ }
250
+ catch {
251
+ return txt(body);
252
+ }
253
+ });
14
254
  server.registerTool('get_campaign_calendar', {
255
+ title: 'Get Campaign Calendar',
256
+ annotations: {
257
+ readOnlyHint: true,
258
+ destructiveHint: false,
259
+ idempotentHint: true,
260
+ openWorldHint: false,
261
+ },
15
262
  description: 'Get campaigns grouped by day for a calendar month.',
16
263
  inputSchema: { year: z.number().int().min(2000).max(2100), month: z.number().int().min(1).max(12), site_id: z.string().uuid().optional() },
17
264
  }, async ({ year, month, site_id }) => {
@@ -41,6 +288,13 @@ export function registerCampaignTools(server) {
41
288
  return j({ days });
42
289
  });
43
290
  server.registerTool('preview_campaign_audience', {
291
+ title: 'Preview Campaign Audience',
292
+ annotations: {
293
+ readOnlyHint: true,
294
+ destructiveHint: false,
295
+ idempotentHint: true,
296
+ openWorldHint: false,
297
+ },
44
298
  description: 'Preview active campaign audience and suppression warnings.',
45
299
  inputSchema: { campaign_id: z.string().uuid() },
46
300
  }, async ({ campaign_id }) => {
@@ -59,6 +313,13 @@ export function registerCampaignTools(server) {
59
313
  return j({ total, suppressed_count: suppressedCount, will_receive: Math.max(total - suppressedCount, 0), warnings: suppressedCount ? [`${suppressedCount} contacts are suppressed and will be skipped`] : [], sample: (subscribers ?? []).filter((row) => !suppressed.has(row.email)).slice(0, 20) });
60
314
  });
61
315
  server.registerTool('list_campaigns', {
316
+ title: 'List Campaigns',
317
+ annotations: {
318
+ readOnlyHint: true,
319
+ destructiveHint: false,
320
+ idempotentHint: true,
321
+ openWorldHint: false,
322
+ },
62
323
  description: 'List campaigns, optionally filtered by site and/or status',
63
324
  inputSchema: {
64
325
  site_id: z.string().uuid().optional(),
@@ -68,7 +329,7 @@ export function registerCampaignTools(server) {
68
329
  }, async ({ site_id, status, limit }) => {
69
330
  let q = db()
70
331
  .from('campaigns')
71
- .select('id, site_id, name, type, status, total_enrolled, total_sent, total_opened, total_clicked, created_at')
332
+ .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at')
72
333
  .eq('project_id', getProjectId())
73
334
  .order('created_at', { ascending: false })
74
335
  .limit(limit ?? 25);
@@ -82,6 +343,13 @@ export function registerCampaignTools(server) {
82
343
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
83
344
  });
84
345
  server.registerTool('get_schedule', {
346
+ title: 'Get Schedule',
347
+ annotations: {
348
+ readOnlyHint: true,
349
+ destructiveHint: false,
350
+ idempotentHint: true,
351
+ openWorldHint: false,
352
+ },
85
353
  description: 'Get scheduled campaign sends for a site within a time window, grouped by campaign.',
86
354
  inputSchema: {
87
355
  site_id: z.string().uuid(),
@@ -113,22 +381,48 @@ export function registerCampaignTools(server) {
113
381
  });
114
382
  });
115
383
  server.registerTool('create_campaign', {
116
- description: 'Create a new email campaign',
384
+ title: 'Create Campaign',
385
+ annotations: {
386
+ readOnlyHint: false,
387
+ destructiveHint: false,
388
+ idempotentHint: false,
389
+ openWorldHint: false,
390
+ },
391
+ description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting.',
117
392
  inputSchema: {
118
393
  site_id: z.string().uuid(),
119
394
  name: z.string().min(1).max(200),
120
395
  type: z.enum(['sequence', 'broadcast', 'triggered']),
121
396
  description: z.string().optional(),
397
+ sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
122
398
  requires_approval: z.boolean().optional().default(true),
399
+ trigger_event_name: z.string().optional().describe('For type=triggered: event name that fires this campaign'),
400
+ trigger_tag: z.string().optional().describe('For type=triggered: contact tag that fires this campaign when applied'),
401
+ trigger_type: z.enum(['date_field']).optional().describe('Set for elapsed-time triggers keyed on a contact custom_properties date field'),
402
+ trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('For trigger_type=date_field: { field, days_before?, recurring? }. Otherwise: { events?: string[], tags?: string[] }.'),
403
+ segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
123
404
  },
124
- }, async ({ site_id, name, type, description, requires_approval }) => {
405
+ }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }) => {
125
406
  const limit = await checkPlanLimit('active_campaigns');
126
407
  if (!limit.allowed) {
127
408
  return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
128
409
  }
410
+ const projectId = getProjectId();
411
+ const insertPayload = {
412
+ project_id: projectId,
413
+ site_id,
414
+ name,
415
+ type,
416
+ description,
417
+ sends_per_hour: sends_per_hour ?? null,
418
+ requires_approval,
419
+ };
420
+ const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
421
+ if (targetingError)
422
+ return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
129
423
  const { data, error } = await db()
130
424
  .from('campaigns')
131
- .insert({ project_id: getProjectId(), site_id, name, type, description, requires_approval })
425
+ .insert(insertPayload)
132
426
  .select()
133
427
  .single();
134
428
  if (error)
@@ -136,6 +430,13 @@ export function registerCampaignTools(server) {
136
430
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
137
431
  });
138
432
  server.registerTool('add_campaign_step', {
433
+ title: 'Add Campaign Step',
434
+ annotations: {
435
+ readOnlyHint: false,
436
+ destructiveHint: false,
437
+ idempotentHint: false,
438
+ openWorldHint: false,
439
+ },
139
440
  description: 'Add an email step to a campaign. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
140
441
  inputSchema: {
141
442
  campaign_id: z.string().uuid(),
@@ -146,18 +447,47 @@ export function registerCampaignTools(server) {
146
447
  delay_days: z.number().int().min(0).optional().default(0),
147
448
  delay_hours: z.number().int().min(0).max(23).optional().default(0),
148
449
  variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
450
+ survey_id: z.string().uuid().optional(),
451
+ brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
149
452
  },
150
- }, async ({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label }) => {
453
+ }, async ({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
454
+ const renderVars = {};
455
+ if (survey_id) {
456
+ const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
457
+ if (!surveyLink)
458
+ return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
459
+ renderVars.survey_link = surveyLink;
460
+ }
461
+ const renderedSubject = survey_id ? renderTemplateString(subject, renderVars) : subject;
462
+ const renderedHtml = body_html !== undefined && survey_id ? renderTemplateString(body_html, renderVars) : body_html;
463
+ const renderedText = body_text !== undefined && survey_id ? renderTemplateString(body_text, renderVars) : body_text;
151
464
  const { data, error } = await db()
152
465
  .from('campaign_steps')
153
- .insert({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
466
+ .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
154
467
  .select()
155
468
  .single();
156
469
  if (error)
157
470
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
471
+ await recordBrandAssetUsage({
472
+ client: db(),
473
+ projectId: getProjectId(),
474
+ source: 'campaign_step',
475
+ resourceId: data.id,
476
+ html: renderedHtml,
477
+ assetIds: brand_asset_ids,
478
+ }).catch((err) => {
479
+ console.warn('Failed to record brand asset usage for MCP campaign step:', err);
480
+ });
158
481
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
159
482
  });
160
483
  server.registerTool('update_campaign_status', {
484
+ title: 'Update Campaign Status',
485
+ annotations: {
486
+ readOnlyHint: false,
487
+ destructiveHint: false,
488
+ idempotentHint: true,
489
+ openWorldHint: true,
490
+ },
161
491
  description: 'Activate, pause, or archive a campaign. Activating requires human approval via the dashboard — call without approval_id to request one, then call again with the approved token.',
162
492
  inputSchema: {
163
493
  campaign_id: z.string().uuid(),
@@ -167,13 +497,22 @@ export function registerCampaignTools(server) {
167
497
  }, async ({ campaign_id, status, approval_id }) => {
168
498
  // Human approval required for activation
169
499
  if (status === 'active') {
500
+ const approvalParams = { campaign_id, status };
170
501
  if (!approval_id) {
171
- const approval = await requestApproval('update_campaign_status', { campaign_id, status });
172
- return j(approval);
502
+ const channelContext = currentChannelContext();
503
+ const decision = assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
504
+ if (!decision.allowed)
505
+ return txt(decision.reason ?? 'This channel cannot request this action.');
506
+ if (decision.tier !== 'autonomous') {
507
+ const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext);
508
+ return j(approval);
509
+ }
173
510
  }
174
- const check = await checkApproval(approval_id, { consume: true });
175
- if (!check.approved) {
176
- return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
511
+ else {
512
+ const check = await checkApproval(approval_id, { consume: true, action: 'update_campaign_status', params: approvalParams });
513
+ if (!check.approved) {
514
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
515
+ }
177
516
  }
178
517
  }
179
518
  const { data, error } = await db()
@@ -196,6 +535,13 @@ export function registerCampaignTools(server) {
196
535
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
197
536
  });
198
537
  server.registerTool('enroll_contact', {
538
+ title: 'Enroll Contact',
539
+ annotations: {
540
+ readOnlyHint: false,
541
+ destructiveHint: false,
542
+ idempotentHint: false,
543
+ openWorldHint: false,
544
+ },
199
545
  description: 'Enroll a single contact in a campaign',
200
546
  inputSchema: {
201
547
  campaign_id: z.string().uuid(),
@@ -203,6 +549,20 @@ export function registerCampaignTools(server) {
203
549
  next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
204
550
  },
205
551
  }, async ({ campaign_id, contact_id, next_send_at }) => {
552
+ const { data: contact, error: contactError } = await db()
553
+ .from('subscribers')
554
+ .select('id, global_unsubscribed, cooled_at')
555
+ .eq('id', contact_id)
556
+ .eq('project_id', getProjectId())
557
+ .maybeSingle();
558
+ if (contactError)
559
+ return txt(`Error: ${contactError.message}`);
560
+ if (!contact)
561
+ return j({ error: 'Contact not found', code: 'NOT_FOUND' });
562
+ if (contact.global_unsubscribed)
563
+ return j({ enrolled: 0, skipped: 'global_unsubscribed' });
564
+ if (contact.cooled_at)
565
+ return j({ enrolled: 0, skipped: 'cooled_contact' });
206
566
  const { data, error } = await db()
207
567
  .from('campaign_enrollments')
208
568
  .insert({ campaign_id, contact_id, next_send_at: next_send_at ?? new Date().toISOString() })
@@ -218,6 +578,13 @@ export function registerCampaignTools(server) {
218
578
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
219
579
  });
220
580
  server.registerTool('get_enrollment_status', {
581
+ title: 'Get Enrollment Status',
582
+ annotations: {
583
+ readOnlyHint: true,
584
+ destructiveHint: false,
585
+ idempotentHint: true,
586
+ openWorldHint: false,
587
+ },
221
588
  description: "Get one contact's enrollment status and sequence position for a campaign",
222
589
  inputSchema: {
223
590
  campaign_id: z.string().uuid(),
@@ -275,6 +642,13 @@ export function registerCampaignTools(server) {
275
642
  });
276
643
  });
277
644
  server.registerTool('enroll_segment', {
645
+ title: 'Enroll Segment',
646
+ annotations: {
647
+ readOnlyHint: false,
648
+ destructiveHint: false,
649
+ idempotentHint: false,
650
+ openWorldHint: false,
651
+ },
278
652
  description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
279
653
  inputSchema: {
280
654
  campaign_id: z.string().uuid(),
@@ -297,7 +671,8 @@ export function registerCampaignTools(server) {
297
671
  .from('subscribers')
298
672
  .select('id, email')
299
673
  .in('id', ids)
300
- .eq('global_unsubscribed', false);
674
+ .eq('global_unsubscribed', false)
675
+ .is('cooled_at', null);
301
676
  const { data: suppressed } = await db().from('suppressions').select('email').eq('project_id', getProjectId());
302
677
  const suppressedSet = new Set((suppressed ?? []).map((s) => s.email));
303
678
  const eligible = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
@@ -316,7 +691,228 @@ export function registerCampaignTools(server) {
316
691
  }
317
692
  return { content: [{ type: 'text', text: JSON.stringify({ enrolled: eligible.length, campaign_id, site_id }, null, 2) }] };
318
693
  });
694
+ server.registerTool('broadcast_to_segment', {
695
+ title: 'Broadcast To Segment',
696
+ annotations: {
697
+ readOnlyHint: false,
698
+ destructiveHint: true,
699
+ idempotentHint: false,
700
+ openWorldHint: true,
701
+ },
702
+ description: 'Create a one-email broadcast campaign for an existing segment, enroll eligible contacts immediately, and activate it. Uses two-call confirmation plus approval before sending to real contacts.',
703
+ inputSchema: {
704
+ site_id: z.string().uuid().describe('Site/sender to send from'),
705
+ segment_id: z.string().uuid().describe('Existing segment to target'),
706
+ subject: z.string().min(1).max(300).describe('Broadcast subject line'),
707
+ body_html: z.string().min(1).describe('Full HTML email body'),
708
+ body_text: z.string().optional().describe('Plain text fallback'),
709
+ name: z.string().min(1).max(200).optional().describe('Optional campaign name. Defaults from subject.'),
710
+ sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
711
+ confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
712
+ approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
713
+ brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this broadcast body.'),
714
+ },
715
+ }, async ({ site_id, segment_id, subject, body_html, body_text, name, sends_per_hour, confirmed, approval_id, brand_asset_ids }) => {
716
+ const projectId = getProjectId();
717
+ const campaignName = name ?? `Broadcast - ${subject.slice(0, 120)}`;
718
+ const approvalParams = {
719
+ site_id,
720
+ segment_id,
721
+ subject,
722
+ body_html,
723
+ body_text,
724
+ name: campaignName,
725
+ sends_per_hour,
726
+ };
727
+ const { data: site, error: siteErr } = await db()
728
+ .from('sites')
729
+ .select('id, name, sending_domain, from_email')
730
+ .eq('id', site_id)
731
+ .eq('project_id', projectId)
732
+ .maybeSingle();
733
+ if (siteErr || !site)
734
+ return j({ error: siteErr?.message ?? 'Site not found', code: 'SITE_NOT_FOUND' });
735
+ const { data: segment, error: segmentErr } = await db()
736
+ .from('segments')
737
+ .select('id, name, rules, site_id')
738
+ .eq('id', segment_id)
739
+ .eq('project_id', projectId)
740
+ .maybeSingle();
741
+ if (segmentErr || !segment)
742
+ return j({ error: segmentErr?.message ?? 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
743
+ const rules = {
744
+ ...(segment.rules ?? {}),
745
+ ...(segment.site_id ? { site_id: segment.site_id } : {}),
746
+ };
747
+ const contactIds = await getSegmentContactIds(projectId, rules);
748
+ if (!confirmed) {
749
+ return j({
750
+ confirmation_required: true,
751
+ tool: 'broadcast_to_segment',
752
+ campaign: {
753
+ name: campaignName,
754
+ type: 'broadcast',
755
+ site: { id: site.id, name: site.name, from_email: site.from_email },
756
+ segment: { id: segment.id, name: segment.name },
757
+ subject,
758
+ sends_per_hour: sends_per_hour ?? null,
759
+ },
760
+ matching_contacts: contactIds.length,
761
+ will_send_at: 'immediately',
762
+ message: `Broadcast "${campaignName}" will enroll ${contactIds.length} eligible contact(s) from segment "${segment.name}" and activate immediately. Call again with confirmed=true to proceed.`,
763
+ });
764
+ }
765
+ if (contactIds.length === 0) {
766
+ return j({
767
+ error: 'No eligible contacts match this segment.',
768
+ code: 'EMPTY_SEGMENT',
769
+ segment_id,
770
+ site_id,
771
+ });
772
+ }
773
+ const channelContext = currentChannelContext();
774
+ const decision = assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
775
+ if (!decision.allowed)
776
+ return txt(decision.reason ?? 'This channel cannot request this action.');
777
+ if (decision.tier !== 'autonomous') {
778
+ if (!approval_id) {
779
+ const approval = await requestApproval('broadcast_to_segment', approvalParams, undefined, channelContext);
780
+ return j(approval);
781
+ }
782
+ const check = await checkApproval(approval_id, { consume: true, action: 'broadcast_to_segment', params: approvalParams });
783
+ if (!check.approved) {
784
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
785
+ }
786
+ }
787
+ const limit = await checkPlanLimit('active_campaigns');
788
+ if (!limit.allowed) {
789
+ return j({
790
+ error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`,
791
+ code: 'PLAN_LIMIT',
792
+ });
793
+ }
794
+ const { data: campaign, error: campaignErr } = await db()
795
+ .from('campaigns')
796
+ .insert({
797
+ project_id: projectId,
798
+ site_id,
799
+ segment_id,
800
+ name: campaignName,
801
+ type: 'broadcast',
802
+ status: 'draft',
803
+ sends_per_hour: sends_per_hour ?? null,
804
+ requires_approval: false,
805
+ metadata: { source: 'mcp_broadcast_to_segment' },
806
+ })
807
+ .select('id')
808
+ .single();
809
+ if (campaignErr || !campaign) {
810
+ return j({ error: campaignErr?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' });
811
+ }
812
+ const { data: step, error: stepErr } = await db()
813
+ .from('campaign_steps')
814
+ .insert({
815
+ campaign_id: campaign.id,
816
+ step_order: 0,
817
+ subject,
818
+ body_html,
819
+ body_text: body_text ?? null,
820
+ delay_days: 0,
821
+ delay_hours: 0,
822
+ })
823
+ .select('id')
824
+ .single();
825
+ if (stepErr || !step) {
826
+ return j({
827
+ error: stepErr?.message ?? 'Campaign created but failed to add broadcast step',
828
+ code: 'STEP_CREATE_FAILED',
829
+ campaign_id: campaign.id,
830
+ });
831
+ }
832
+ await recordBrandAssetUsage({
833
+ client: db(),
834
+ projectId,
835
+ source: 'campaign_step',
836
+ resourceId: step.id,
837
+ html: body_html,
838
+ assetIds: brand_asset_ids,
839
+ }).catch((err) => {
840
+ console.warn('Failed to record brand asset usage for MCP broadcast:', err);
841
+ });
842
+ const sendAt = new Date().toISOString();
843
+ const batchSize = 500;
844
+ for (let i = 0; i < contactIds.length; i += batchSize) {
845
+ const batch = contactIds.slice(i, i + batchSize).map((contact_id) => ({
846
+ campaign_id: campaign.id,
847
+ contact_id,
848
+ next_send_at: sendAt,
849
+ }));
850
+ const { error: enrollErr } = await db()
851
+ .from('campaign_enrollments')
852
+ .upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
853
+ if (enrollErr) {
854
+ return j({
855
+ error: enrollErr.message,
856
+ code: 'ENROLLMENT_FAILED',
857
+ campaign_id: campaign.id,
858
+ enrolled_before_failure: i,
859
+ });
860
+ }
861
+ }
862
+ const { error: activateErr } = await db()
863
+ .from('campaigns')
864
+ .update({
865
+ status: 'active',
866
+ total_enrolled: contactIds.length,
867
+ updated_at: new Date().toISOString(),
868
+ })
869
+ .eq('id', campaign.id)
870
+ .eq('project_id', projectId);
871
+ if (activateErr) {
872
+ return j({
873
+ error: activateErr.message,
874
+ code: 'ACTIVATION_FAILED',
875
+ campaign_id: campaign.id,
876
+ enrolled: contactIds.length,
877
+ });
878
+ }
879
+ await logAudit({
880
+ action: 'campaign.broadcast_to_segment',
881
+ resourceType: 'campaign',
882
+ resourceId: campaign.id,
883
+ details: {
884
+ site_id,
885
+ segment_id,
886
+ contacts_enrolled: contactIds.length,
887
+ source: 'mcp',
888
+ },
889
+ }).catch(() => { });
890
+ fireWebhookEvent('campaign.activated', {
891
+ campaign_id: campaign.id,
892
+ campaign_name: campaignName,
893
+ type: 'broadcast',
894
+ contacts_enrolled: contactIds.length,
895
+ steps: 1,
896
+ }).catch(() => { });
897
+ return j({
898
+ campaign_id: campaign.id,
899
+ step_id: step.id,
900
+ name: campaignName,
901
+ status: 'active',
902
+ segment: { id: segment.id, name: segment.name },
903
+ contacts_enrolled: contactIds.length,
904
+ next_send_at: sendAt,
905
+ message: `Broadcast "${campaignName}" is active with ${contactIds.length} contact(s) enrolled for immediate sending.`,
906
+ });
907
+ });
319
908
  server.registerTool('clone_campaign', {
909
+ title: 'Clone Campaign',
910
+ annotations: {
911
+ readOnlyHint: false,
912
+ destructiveHint: false,
913
+ idempotentHint: false,
914
+ openWorldHint: false,
915
+ },
320
916
  description: 'Clone an existing campaign (with all steps) into a new draft campaign',
321
917
  inputSchema: {
322
918
  campaign_id: z.string().uuid().describe('Source campaign to clone'),
@@ -344,6 +940,7 @@ export function registerCampaignTools(server) {
344
940
  name: new_name,
345
941
  type: original.type,
346
942
  description: original.description,
943
+ sends_per_hour: original.sends_per_hour ?? null,
347
944
  requires_approval: original.requires_approval,
348
945
  status: 'draft',
349
946
  })
@@ -369,6 +966,13 @@ export function registerCampaignTools(server) {
369
966
  return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id });
370
967
  });
371
968
  server.registerTool('generate_email', {
969
+ title: 'Generate Email',
970
+ annotations: {
971
+ readOnlyHint: true,
972
+ destructiveHint: false,
973
+ idempotentHint: true,
974
+ openWorldHint: true,
975
+ },
372
976
  description: 'Generate email content (subject, HTML, plain text) from a brief using AI. Optionally provide contact_id to adapt tone based on recipient engagement data.',
373
977
  inputSchema: {
374
978
  site_id: z.string().uuid(),
@@ -450,7 +1054,14 @@ Adapt your tone accordingly:
450
1054
  }
451
1055
  });
452
1056
  server.registerTool('launch_campaign', {
453
- description: 'Full campaign lifecycle in one call: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Requires confirmation.',
1057
+ title: 'Launch Campaign',
1058
+ annotations: {
1059
+ readOnlyHint: false,
1060
+ destructiveHint: true,
1061
+ idempotentHint: false,
1062
+ openWorldHint: true,
1063
+ },
1064
+ description: 'Full campaign lifecycle in one call: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Requires human approval via the dashboard before confirmed execution.',
454
1065
  inputSchema: {
455
1066
  name: z.string().min(1).describe('Campaign name'),
456
1067
  site_id: z.string().uuid(),
@@ -462,9 +1073,11 @@ Adapt your tone accordingly:
462
1073
  delay_days: z.number().int().min(0).optional().default(0),
463
1074
  delay_hours: z.number().int().min(0).optional().default(0),
464
1075
  })).min(1),
1076
+ sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
465
1077
  confirmed: z.boolean().optional().describe('Set true after previewing to execute'),
1078
+ approval_id: z.string().uuid().optional().describe('Required when executing. Get this by calling with confirmed=true and no approval_id, then have a human approve.'),
466
1079
  },
467
- }, async ({ name, site_id, type, segment_description, segment_id, emails, confirmed }) => {
1080
+ }, async ({ name, site_id, type, segment_description, segment_id, emails, sends_per_hour, confirmed, approval_id }) => {
468
1081
  if (!segment_description && !segment_id)
469
1082
  return txt('Provide segment_description or segment_id.');
470
1083
  const apiKey = process.env.ANTHROPIC_API_KEY;
@@ -472,6 +1085,31 @@ Adapt your tone accordingly:
472
1085
  return txt('Error: ANTHROPIC_API_KEY not configured');
473
1086
  const anthropic = new Anthropic({ apiKey });
474
1087
  const projectId = getProjectId();
1088
+ const approvalParams = {
1089
+ name,
1090
+ site_id,
1091
+ type: type ?? 'sequence',
1092
+ segment_description,
1093
+ segment_id,
1094
+ emails,
1095
+ sends_per_hour,
1096
+ };
1097
+ if (confirmed) {
1098
+ const channelContext = currentChannelContext();
1099
+ const decision = assertChannelCanExecute('launch_campaign', approvalParams, channelContext);
1100
+ if (!decision.allowed)
1101
+ return txt(decision.reason ?? 'This channel cannot request this action.');
1102
+ if (decision.tier !== 'autonomous') {
1103
+ if (!approval_id) {
1104
+ const approval = await requestApproval('launch_campaign', approvalParams, undefined, channelContext);
1105
+ return j(approval);
1106
+ }
1107
+ const check = await checkApproval(approval_id, { consume: true, action: 'launch_campaign', params: approvalParams });
1108
+ if (!check.approved) {
1109
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1110
+ }
1111
+ }
1112
+ }
475
1113
  function applyPaymentRules(query, payment) {
476
1114
  if (!payment)
477
1115
  return query;
@@ -510,7 +1148,7 @@ Adapt your tone accordingly:
510
1148
  }
511
1149
  // Helper to get segment contact IDs (simplified inline)
512
1150
  const getContactIds = async (rules) => {
513
- let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false);
1151
+ let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false).is('cooled_at', null);
514
1152
  if (rules.lifecycle_stage)
515
1153
  q = q.eq('lifecycle_stage', rules.lifecycle_stage);
516
1154
  q = applyPaymentRules(q, rules.payment);
@@ -610,6 +1248,7 @@ Adapt your tone accordingly:
610
1248
  emails: emails.map((e, i) => ({
611
1249
  step: i, brief: e.brief, subject_preview: subjectPreviews[i], delay_days: e.delay_days ?? 0, delay_hours: e.delay_hours ?? 0,
612
1250
  })),
1251
+ sends_per_hour: sends_per_hour ?? null,
613
1252
  message: `Campaign "${name}" will target ${contactCount} contacts with ${emails.length} email(s). Call again with confirmed: true to create everything.`,
614
1253
  });
615
1254
  }
@@ -631,7 +1270,7 @@ Adapt your tone accordingly:
631
1270
  return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
632
1271
  const { data: campaign, error: campErr } = await db()
633
1272
  .from('campaigns')
634
- .insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, requires_approval: false })
1273
+ .insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
635
1274
  .select('id')
636
1275
  .single();
637
1276
  if (campErr)
@@ -693,6 +1332,13 @@ Adapt your tone accordingly:
693
1332
  });
694
1333
  });
695
1334
  server.registerTool('preview_email', {
1335
+ title: 'Preview Email',
1336
+ annotations: {
1337
+ readOnlyHint: true,
1338
+ destructiveHint: false,
1339
+ idempotentHint: true,
1340
+ openWorldHint: false,
1341
+ },
696
1342
  description: 'Preview email content with merge variables resolved using sample contact data. Provide content via step_id, draft_id, or raw body_html/subject.',
697
1343
  inputSchema: {
698
1344
  step_id: z.string().uuid().optional().describe('Campaign step UUID'),
@@ -751,7 +1397,14 @@ Adapt your tone accordingly:
751
1397
  return j({ subject: resolve(subj), body_html: resolve(html), body_text: resolve(text), sample_contact: contact });
752
1398
  });
753
1399
  server.registerTool('send_test_email', {
754
- description: 'Send a test email to a specified address via Resend. Content from step_id, draft_id, or raw subject/body_html. Logged as test, not counted in campaign metrics.',
1400
+ title: 'Send Test Email',
1401
+ annotations: {
1402
+ readOnlyHint: false,
1403
+ destructiveHint: true,
1404
+ idempotentHint: false,
1405
+ openWorldHint: true,
1406
+ },
1407
+ description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html. Logged as test, not counted in campaign metrics.',
755
1408
  inputSchema: {
756
1409
  to_email: z.string().email().describe('Email address to send the test to'),
757
1410
  site_id: z.string().uuid().describe('Site UUID (determines from address)'),
@@ -808,26 +1461,22 @@ Adapt your tone accordingly:
808
1461
  .maybeSingle();
809
1462
  if (!site)
810
1463
  return txt('Error: Site not found');
811
- // Use Resend via env var (MCP server context)
812
- const { Resend } = await import('resend');
813
- const resendKey = process.env.RESEND_API_KEY;
814
- if (!resendKey)
815
- return txt('Error: RESEND_API_KEY not configured');
816
- const resend = new Resend(resendKey);
1464
+ const resolvedHtml = resolve(html);
1465
+ const resolvedText = resolve(text);
817
1466
  const payload = {
818
1467
  from: `${site.from_name} <${site.from_email}>`,
819
1468
  to: to_email,
820
1469
  subject: `[TEST] ${resolve(subj)}`,
1470
+ html: resolvedHtml || undefined,
1471
+ text: resolvedText || undefined,
821
1472
  };
822
- const resolvedHtml = resolve(html);
823
- const resolvedText = resolve(text);
824
- if (resolvedHtml)
825
- payload.html = resolvedHtml;
826
- if (resolvedText)
827
- payload.text = resolvedText;
828
- const { data: resendData, error: sendError } = await resend.emails.send(payload);
829
- if (sendError)
830
- return txt(`Error: Send failed — ${sendError.message}`);
1473
+ let sendResult;
1474
+ try {
1475
+ sendResult = await sendEmailForSite(projectId, site_id, payload);
1476
+ }
1477
+ catch (error) {
1478
+ return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
1479
+ }
831
1480
  await db().from('email_log').insert({
832
1481
  project_id: projectId,
833
1482
  site_id,
@@ -838,13 +1487,26 @@ Adapt your tone accordingly:
838
1487
  status: 'sent',
839
1488
  category: 'test',
840
1489
  sent_at: new Date().toISOString(),
841
- provider_message_id: resendData?.id ?? null,
1490
+ provider_message_id: sendResult.messageId ?? null,
842
1491
  body_html: resolvedHtml ?? null,
843
1492
  });
844
- return j({ sent: true, to: to_email, resend_id: resendData?.id });
1493
+ return j({
1494
+ sent: true,
1495
+ to: to_email,
1496
+ provider: sendResult.provider,
1497
+ provider_message_id: sendResult.messageId,
1498
+ resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
1499
+ });
845
1500
  });
846
1501
  server.registerTool('send_transactional', {
847
- description: 'Send a transactional email (password reset, receipt, notification). Checks suppressions and plan limits. Logged with category transactional.',
1502
+ title: 'Send Transactional',
1503
+ annotations: {
1504
+ readOnlyHint: false,
1505
+ destructiveHint: true,
1506
+ idempotentHint: false,
1507
+ openWorldHint: true,
1508
+ },
1509
+ description: 'Send a transactional email (password reset, receipt, notification) using the site\'s configured provider. Checks suppressions and plan limits. Logged with category transactional.',
848
1510
  inputSchema: {
849
1511
  to: z.string().email().describe('Recipient email address'),
850
1512
  site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
@@ -876,8 +1538,14 @@ Adapt your tone accordingly:
876
1538
  .eq('project_id', projectId)
877
1539
  .eq('idempotency_key', idempotency_key)
878
1540
  .maybeSingle();
879
- if (existing)
880
- return j({ error: 'Duplicate send', id: existing.id, resend_id: existing.provider_message_id });
1541
+ if (existing) {
1542
+ return j({
1543
+ error: 'Duplicate send',
1544
+ id: existing.id,
1545
+ provider_message_id: existing.provider_message_id,
1546
+ resend_id: existing.provider_message_id,
1547
+ });
1548
+ }
881
1549
  }
882
1550
  // Site verification
883
1551
  const { data: site } = await db()
@@ -888,26 +1556,21 @@ Adapt your tone accordingly:
888
1556
  .maybeSingle();
889
1557
  if (!site)
890
1558
  return txt('Error: Site not found');
891
- // Send via Resend
892
- const { Resend } = await import('resend');
893
- const resendKey = process.env.RESEND_API_KEY;
894
- if (!resendKey)
895
- return txt('Error: RESEND_API_KEY not configured');
896
- const resend = new Resend(resendKey);
897
1559
  const payload = {
898
1560
  from: `${site.from_name} <${site.from_email}>`,
899
1561
  to,
900
1562
  subject,
1563
+ html,
1564
+ text,
1565
+ replyTo: reply_to,
901
1566
  };
902
- if (html)
903
- payload.html = html;
904
- if (text)
905
- payload.text = text;
906
- if (reply_to)
907
- payload.reply_to = reply_to;
908
- const { data: resendData, error: sendError } = await resend.emails.send(payload);
909
- if (sendError)
910
- return txt(`Error: Send failed — ${sendError.message}`);
1567
+ let sendResult;
1568
+ try {
1569
+ sendResult = await sendEmailForSite(projectId, site_id, payload);
1570
+ }
1571
+ catch (error) {
1572
+ return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
1573
+ }
911
1574
  // Log to email_log
912
1575
  const { data: logRow } = await db().from('email_log').insert({
913
1576
  project_id: projectId,
@@ -919,13 +1582,27 @@ Adapt your tone accordingly:
919
1582
  status: 'sent',
920
1583
  category: 'transactional',
921
1584
  sent_at: new Date().toISOString(),
922
- provider_message_id: resendData?.id ?? null,
1585
+ provider_message_id: sendResult.messageId ?? null,
923
1586
  idempotency_key: idempotency_key ?? null,
924
1587
  body_html: html ?? null,
925
1588
  }).select('id').single();
926
- return j({ sent: true, id: logRow?.id, to, resend_id: resendData?.id });
1589
+ return j({
1590
+ sent: true,
1591
+ id: logRow?.id,
1592
+ to,
1593
+ provider: sendResult.provider,
1594
+ provider_message_id: sendResult.messageId,
1595
+ resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
1596
+ });
927
1597
  });
928
1598
  server.registerTool('validate_template', {
1599
+ title: 'Validate Template',
1600
+ annotations: {
1601
+ readOnlyHint: true,
1602
+ destructiveHint: false,
1603
+ idempotentHint: true,
1604
+ openWorldHint: false,
1605
+ },
929
1606
  description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, stock phrases, excessive CTAs, missing sign-off, and more.',
930
1607
  inputSchema: {
931
1608
  html: z.string().describe('HTML email content to validate'),
@@ -945,11 +1622,91 @@ Adapt your tone accordingly:
945
1622
  const result = validateTemplate(html, voiceConfig);
946
1623
  return j(result);
947
1624
  });
1625
+ server.registerTool('check_content_consistency', {
1626
+ title: 'Check Content Consistency',
1627
+ annotations: {
1628
+ readOnlyHint: true,
1629
+ destructiveHint: false,
1630
+ idempotentHint: true,
1631
+ openWorldHint: true,
1632
+ },
1633
+ description: 'Analyze long-form campaign or nurture content for cross-document factual and numeric inconsistencies. Returns contradictions between entities, dates, dollar amounts, percentages, and other concrete claims without rewriting the content.',
1634
+ inputSchema: {
1635
+ content: z.string().min(1).max(120000).describe('Full drafted content to scan, including section labels or multiple documents when available'),
1636
+ site_id: z.string().uuid().optional().describe('Optional site UUID for audit context only'),
1637
+ },
1638
+ }, async ({ content, site_id }) => {
1639
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1640
+ if (!apiKey)
1641
+ return txt('Error: ANTHROPIC_API_KEY not configured');
1642
+ const anthropic = new Anthropic({ apiKey });
1643
+ const system = `You are Sendinel's factual consistency auditor for outbound marketing drafts.
1644
+
1645
+ PREMISES
1646
+ - Analyze only the supplied content. Do not use outside knowledge.
1647
+ - Find contradictions across sections, variants, or documents.
1648
+ - Prioritize concrete factual and numeric claims: names, companies, places, dates, times, dollars, percentages, counts, product facts, eligibility rules, links, and deadlines.
1649
+ - Similar wording is not a contradiction. Only flag claims that cannot both be true or are likely to confuse a recipient.
1650
+ - Do not rewrite the content.
1651
+
1652
+ EXECUTION TRACE
1653
+ 1. Extract concrete claims with their section/location clues.
1654
+ 2. Compare claims across the full input.
1655
+ 3. Keep only material inconsistencies.
1656
+
1657
+ CONCLUSION
1658
+ Return ONLY valid JSON with this exact shape:
1659
+ {
1660
+ "consistent": boolean,
1661
+ "flags": [
1662
+ {
1663
+ "claim_a": "first conflicting claim",
1664
+ "location_a": "section, paragraph, or document label for claim_a",
1665
+ "claim_b": "second conflicting claim",
1666
+ "location_b": "section, paragraph, or document label for claim_b",
1667
+ "explanation": "why the claims conflict"
1668
+ }
1669
+ ]
1670
+ }`;
1671
+ let result;
1672
+ try {
1673
+ const response = await anthropic.messages.create({
1674
+ model: getAnthropicModelId(),
1675
+ max_tokens: 1500,
1676
+ system,
1677
+ messages: [{ role: 'user', content }],
1678
+ });
1679
+ const block = response.content.find((item) => item.type === 'text');
1680
+ const raw = block && 'text' in block ? block.text : '';
1681
+ result = normalizeConsistencyResult(JSON.parse(extractJsonObject(raw)));
1682
+ }
1683
+ catch (error) {
1684
+ return txt(`Error: Consistency check failed — ${error instanceof Error ? error.message : 'unknown error'}`);
1685
+ }
1686
+ await logAudit({
1687
+ action: 'check_content_consistency',
1688
+ resourceType: 'content_consistency_check',
1689
+ details: {
1690
+ site_id: site_id ?? null,
1691
+ content_length: content.length,
1692
+ flag_count: result.flags.length,
1693
+ flags: result.flags,
1694
+ },
1695
+ });
1696
+ return j(result);
1697
+ });
948
1698
  // ── Orchestrator fast path ──────────────────────────────────────────────
949
1699
  // Creates a campaign with pre-built content in a single call.
950
1700
  // Designed for external orchestrators (any MCP client, HTTP API, or automation)
951
1701
  // that draft their own content and just need Sendinel to deliver it.
952
1702
  server.registerTool('create_campaign_with_content', {
1703
+ title: 'Create Campaign With Content',
1704
+ annotations: {
1705
+ readOnlyHint: false,
1706
+ destructiveHint: false,
1707
+ idempotentHint: false,
1708
+ openWorldHint: true,
1709
+ },
953
1710
  description: 'Create a campaign with pre-built email content in one call. Creates the campaign, adds email step(s), optionally enrolls a segment, and activates. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
954
1711
  inputSchema: {
955
1712
  site_id: z.string().uuid().describe('Site to send from'),
@@ -963,22 +1720,27 @@ Adapt your tone accordingly:
963
1720
  delay_hours: z.number().int().min(0).max(23).optional().default(0),
964
1721
  })).min(1).describe('Email steps with pre-built content'),
965
1722
  segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. If omitted, campaign is created in draft without enrollments.'),
1723
+ sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
966
1724
  activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id.'),
967
1725
  confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
968
1726
  approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
969
1727
  },
970
- }, async ({ site_id, name, type, steps, segment_id, activate, confirmed, approval_id }) => {
1728
+ }, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id }) => {
971
1729
  // Human-in-the-loop gate for activation
972
1730
  if (activate && confirmed) {
973
- const tier = getApprovalTier('create_campaign_with_content', { activate });
974
- if (tier !== 'autonomous') {
1731
+ const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate };
1732
+ const channelContext = currentChannelContext();
1733
+ const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
1734
+ if (!decision.allowed)
1735
+ return txt(decision.reason ?? 'This channel cannot request this action.');
1736
+ if (decision.tier !== 'autonomous') {
975
1737
  if (!approval_id) {
976
1738
  // No approval token — request one
977
- const approval = await requestApproval('create_campaign_with_content', { site_id, name, type, steps_count: steps.length, segment_id, activate });
1739
+ const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext);
978
1740
  return j(approval);
979
1741
  }
980
1742
  // Have a token — verify it's approved
981
- const status = await checkApproval(approval_id, { consume: true });
1743
+ const status = await checkApproval(approval_id, { consume: true, action: 'create_campaign_with_content', params: approvalParams });
982
1744
  if (!status.approved) {
983
1745
  return txt(`Approval ${approval_id} is ${status.status}. ${status.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
984
1746
  }
@@ -1021,6 +1783,7 @@ Adapt your tone accordingly:
1021
1783
  has_text: !!s.body_text,
1022
1784
  })),
1023
1785
  segment_id: segment_id ?? null,
1786
+ sends_per_hour: sends_per_hour ?? null,
1024
1787
  estimated_contacts: contactCount,
1025
1788
  will_activate: activate && !!segment_id,
1026
1789
  message: `Campaign "${name}" with ${steps.length} step(s) on ${site.name}. ${segment_id ? `Will enroll ~${contactCount} contacts.` : 'No segment — created as draft.'} ${activate && segment_id ? 'Will activate immediately.' : ''} Call again with confirmed=true to proceed.`,
@@ -1037,6 +1800,7 @@ Adapt your tone accordingly:
1037
1800
  type,
1038
1801
  status: 'draft',
1039
1802
  segment_id: segment_id ?? null,
1803
+ sends_per_hour: sends_per_hour ?? null,
1040
1804
  })
1041
1805
  .select()
1042
1806
  .single();
@@ -1071,7 +1835,8 @@ Adapt your tone accordingly:
1071
1835
  .from('subscribers')
1072
1836
  .select('id, email')
1073
1837
  .in('id', ids)
1074
- .eq('global_unsubscribed', false);
1838
+ .eq('global_unsubscribed', false)
1839
+ .is('cooled_at', null);
1075
1840
  const { data: suppressed } = await db()
1076
1841
  .from('suppressions')
1077
1842
  .select('email')
@@ -1120,6 +1885,13 @@ Adapt your tone accordingly:
1120
1885
  });
1121
1886
  });
1122
1887
  server.registerTool('list_campaign_steps', {
1888
+ title: 'List Campaign Steps',
1889
+ annotations: {
1890
+ readOnlyHint: true,
1891
+ destructiveHint: false,
1892
+ idempotentHint: true,
1893
+ openWorldHint: false,
1894
+ },
1123
1895
  description: 'List all steps for a campaign in send order.',
1124
1896
  inputSchema: { campaign_id: z.string().uuid() },
1125
1897
  }, async ({ campaign_id }) => {
@@ -1142,6 +1914,13 @@ Adapt your tone accordingly:
1142
1914
  return j({ steps: data ?? [], count: data?.length ?? 0 });
1143
1915
  });
1144
1916
  server.registerTool('update_campaign_step', {
1917
+ title: 'Update Campaign Step',
1918
+ annotations: {
1919
+ readOnlyHint: false,
1920
+ destructiveHint: false,
1921
+ idempotentHint: true,
1922
+ openWorldHint: false,
1923
+ },
1145
1924
  description: 'Update a campaign step — change subject, body, delay, or step_order.',
1146
1925
  inputSchema: {
1147
1926
  step_id: z.string().uuid(),
@@ -1151,9 +1930,11 @@ Adapt your tone accordingly:
1151
1930
  delay_days: z.number().int().min(0).optional(),
1152
1931
  delay_hours: z.number().int().min(0).optional(),
1153
1932
  step_order: z.number().int().min(0).optional(),
1933
+ survey_id: z.string().uuid().optional(),
1154
1934
  },
1155
1935
  }, async ({ step_id, ...updates }) => {
1156
1936
  const projectId = getProjectId();
1937
+ const { survey_id, ...stepUpdates } = updates;
1157
1938
  const { data: step, error: stepErr } = await db()
1158
1939
  .from('campaign_steps')
1159
1940
  .select('id, campaign_id')
@@ -1169,9 +1950,22 @@ Adapt your tone accordingly:
1169
1950
  .maybeSingle();
1170
1951
  if (!campaign)
1171
1952
  return j({ error: 'Step not found', code: 'NOT_FOUND' });
1953
+ const renderVars = {};
1954
+ if (survey_id) {
1955
+ const surveyLink = await resolveSurveyShortLink(db(), projectId, survey_id);
1956
+ if (!surveyLink)
1957
+ return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
1958
+ renderVars.survey_link = surveyLink;
1959
+ }
1960
+ const renderedUpdates = {
1961
+ ...stepUpdates,
1962
+ ...(survey_id && stepUpdates.subject !== undefined ? { subject: renderTemplateString(stepUpdates.subject, renderVars) } : {}),
1963
+ ...(survey_id && stepUpdates.body_html !== undefined ? { body_html: renderTemplateString(stepUpdates.body_html, renderVars) } : {}),
1964
+ ...(survey_id && stepUpdates.body_text !== undefined ? { body_text: renderTemplateString(stepUpdates.body_text, renderVars) } : {}),
1965
+ };
1172
1966
  const { data, error } = await db()
1173
1967
  .from('campaign_steps')
1174
- .update({ ...updates, updated_at: new Date().toISOString() })
1968
+ .update({ ...renderedUpdates, updated_at: new Date().toISOString() })
1175
1969
  .eq('id', step_id)
1176
1970
  .select('id, step_order, subject, delay_days, delay_hours, updated_at')
1177
1971
  .single();
@@ -1180,6 +1974,13 @@ Adapt your tone accordingly:
1180
1974
  return j({ step: data, ok: true });
1181
1975
  });
1182
1976
  server.registerTool('delete_campaign_step', {
1977
+ title: 'Delete Campaign Step',
1978
+ annotations: {
1979
+ readOnlyHint: false,
1980
+ destructiveHint: true,
1981
+ idempotentHint: false,
1982
+ openWorldHint: false,
1983
+ },
1183
1984
  description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
1184
1985
  inputSchema: { step_id: z.string().uuid() },
1185
1986
  }, async ({ step_id }) => {
@@ -1211,6 +2012,13 @@ Adapt your tone accordingly:
1211
2012
  return j({ ok: true, deleted: step_id });
1212
2013
  });
1213
2014
  server.registerTool('delete_campaign', {
2015
+ title: 'Delete Campaign',
2016
+ annotations: {
2017
+ readOnlyHint: false,
2018
+ destructiveHint: true,
2019
+ idempotentHint: false,
2020
+ openWorldHint: false,
2021
+ },
1214
2022
  description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
1215
2023
  inputSchema: { campaign_id: z.string().uuid() },
1216
2024
  }, async ({ campaign_id }) => {
@@ -1236,6 +2044,13 @@ Adapt your tone accordingly:
1236
2044
  return j({ success: true, deleted: campaign.name });
1237
2045
  });
1238
2046
  server.registerTool('update_campaign', {
2047
+ title: 'Update Campaign',
2048
+ annotations: {
2049
+ readOnlyHint: false,
2050
+ destructiveHint: false,
2051
+ idempotentHint: true,
2052
+ openWorldHint: false,
2053
+ },
1239
2054
  description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. Only provided fields are updated.',
1240
2055
  inputSchema: {
1241
2056
  campaign_id: z.string().uuid(),
@@ -1246,27 +2061,29 @@ Adapt your tone accordingly:
1246
2061
  topic: z.string().nullable().optional(),
1247
2062
  trigger_event_name: z.string().nullable().optional(),
1248
2063
  trigger_tag: z.string().nullable().optional(),
2064
+ trigger_type: z.enum(['date_field']).nullable().optional(),
2065
+ trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
2066
+ sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
1249
2067
  send_in_contact_timezone: z.boolean().optional(),
1250
2068
  },
1251
- }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, send_in_contact_timezone }) => {
2069
+ }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone }) => {
1252
2070
  const projectId = getProjectId();
1253
2071
  const updates = { updated_at: new Date().toISOString() };
1254
2072
  if (name !== undefined)
1255
2073
  updates.name = name;
1256
2074
  if (site_id !== undefined)
1257
2075
  updates.site_id = site_id;
1258
- if (segment_id !== undefined)
1259
- updates.segment_id = segment_id;
1260
2076
  if (send_at !== undefined)
1261
2077
  updates.send_at = send_at;
1262
2078
  if (topic !== undefined)
1263
2079
  updates.topic = topic;
1264
- if (trigger_event_name !== undefined)
1265
- updates.trigger_event_name = trigger_event_name;
1266
- if (trigger_tag !== undefined)
1267
- updates.trigger_tag = trigger_tag;
2080
+ if (sends_per_hour !== undefined)
2081
+ updates.sends_per_hour = sends_per_hour;
1268
2082
  if (send_in_contact_timezone !== undefined)
1269
2083
  updates.send_in_contact_timezone = send_in_contact_timezone;
2084
+ const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
2085
+ if (targetingError)
2086
+ return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
1270
2087
  const { data, error } = await db()
1271
2088
  .from('campaigns')
1272
2089
  .update(updates)
@@ -1279,6 +2096,13 @@ Adapt your tone accordingly:
1279
2096
  return j(data);
1280
2097
  });
1281
2098
  server.registerTool('get_campaign', {
2099
+ title: 'Get Campaign',
2100
+ annotations: {
2101
+ readOnlyHint: true,
2102
+ destructiveHint: false,
2103
+ idempotentHint: true,
2104
+ openWorldHint: false,
2105
+ },
1282
2106
  description: 'Get full details of a single campaign including all steps, current enrollment counts, and settings.',
1283
2107
  inputSchema: { campaign_id: z.string().uuid() },
1284
2108
  }, async ({ campaign_id }) => {
@@ -1306,6 +2130,13 @@ Adapt your tone accordingly:
1306
2130
  return j({ ...campaign, steps: steps ?? [], active_enrollments: enrollCount ?? 0 });
1307
2131
  });
1308
2132
  server.registerTool('unenroll_contact', {
2133
+ title: 'Unenroll Contact',
2134
+ annotations: {
2135
+ readOnlyHint: false,
2136
+ destructiveHint: false,
2137
+ idempotentHint: true,
2138
+ openWorldHint: false,
2139
+ },
1309
2140
  description: 'Remove a contact from an active campaign enrollment. Use when a contact should no longer receive campaign emails.',
1310
2141
  inputSchema: {
1311
2142
  campaign_id: z.string().uuid(),