@sendinel/mcp-server 1.0.216 → 1.0.217

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.
@@ -36,8 +36,9 @@ export declare function assertChannelCanExecute(action: string, params: Record<s
36
36
  /** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
37
37
  export declare function currentChannelContext(): ChannelExecutionContext;
38
38
  /**
39
- * Display-only facts for the approval summary (segment name, count, subject, excerpt). Never
40
- * stored in params, so it can't change the params an approval is checked against.
39
+ * Display-only facts for the approval summary (segment name, count, subject, excerpt).
40
+ * merge_variable_summary is frozen in __approval_snapshot.display; the other facts
41
+ * do not change the params an approval is checked against.
41
42
  */
42
43
  export type ApprovalDisplay = Record<string, string | number | null | undefined>;
43
44
  /** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
@@ -252,8 +252,10 @@ export function buildApprovalSummary(action, params, display = {}) {
252
252
  remove_suppression: (p) => `Remove ${p.email} from the suppression list — mail to this address will resume`,
253
253
  };
254
254
  const fn = summaries[action];
255
- if (fn)
256
- return fn(params);
255
+ if (fn) {
256
+ const line = params.__approval_snapshot?.display?.merge_variable_summary;
257
+ return fn(params) + (line ? `\n${line}` : '');
258
+ }
257
259
  const payload = JSON.stringify(normalizeApprovalParams(params));
258
260
  return `Execute ${action} with: ${payload.length > 400 ? `${payload.slice(0, 399)}…` : payload}`;
259
261
  }
@@ -356,6 +358,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
356
358
  const cleanParams = withoutApprovalSnapshot(params);
357
359
  const snapshot = { ...await captureSnapshot(action, cleanParams), ...(effective.applied ? { approval_class: effective.class, approval_channel_rule: effective.channelRule } : {}),
358
360
  ...(display && 'recipient_count' in display ? { display_recipient_count: display.recipient_count } : {}) };
361
+ if (typeof display?.merge_variable_summary === 'string') {
362
+ snapshot.display = { ...snapshot.display, merge_variable_summary: display.merge_variable_summary };
363
+ }
359
364
  const payload = cleanParams.payload;
360
365
  if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
361
366
  !approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
@@ -1,4 +1,4 @@
1
- import { type ContentIssue } from '@sendinel/domain';
1
+ import { type MergeVariableWarning, type ContentIssue } from '@sendinel/domain';
2
2
  /** Data keys seen on an event in the project's history, or null when the lookup failed. */
3
3
  export declare function eventKeysFromHistory(projectId: string, eventName: string): Promise<string[] | null>;
4
4
  /**
@@ -32,7 +32,9 @@ export type CampaignContentFinding = ContentIssue & {
32
32
  * every email of the given campaigns (#3853). Placeholder text and sending-domain links are
33
33
  * blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
34
34
  */
35
- export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[]): Promise<{
35
+ export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[], options?: {
36
+ mergeVariables?: boolean;
37
+ }): Promise<{
36
38
  blocking: CampaignContentFinding[];
37
39
  warnings: CampaignContentFinding[];
38
40
  lookup_errors: string[];
@@ -59,4 +61,22 @@ export declare function summarizeContentWarnings(warnings: Array<{
59
61
  email?: number;
60
62
  warning: string;
61
63
  }>): string;
64
+ export type CampaignMergeVariableWarning = MergeVariableWarning & {
65
+ step_order: number;
66
+ checked_at: string;
67
+ warning: string;
68
+ };
69
+ /** Uses the shared domain rules; unavailable sources stay unknown and never stop activation. */
70
+ export declare function checkCampaignMergeVariables(projectId: string, campaign: {
71
+ trigger_event_name: string | null;
72
+ }, steps: Array<{
73
+ step_order: number;
74
+ subject?: string | null;
75
+ body_html?: string | null;
76
+ body_text?: string | null;
77
+ }>): Promise<CampaignMergeVariableWarning[]>;
78
+ /** One extra line on the approval, including explicit uncertainty for unknown sources. */
79
+ export declare function summarizeMergeVariableWarnings(warnings: CampaignMergeVariableWarning[]): string | undefined;
80
+ /** Best-effort note. Compare-and-swap preserves concurrent metadata edits; never gate activation. */
81
+ export declare function persistMergeVariableWarnings(projectId: string, campaignId: string, warnings: CampaignMergeVariableWarning[]): Promise<void>;
62
82
  export {};
@@ -1,4 +1,4 @@
1
- import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf } from '@sendinel/domain';
1
+ import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf, findUnprovidedMergeTags } from '@sendinel/domain';
2
2
  import { db } from '../db.js';
3
3
  /** Data keys seen on an event in the project's history, or null when the lookup failed. */
4
4
  export async function eventKeysFromHistory(projectId, eventName) {
@@ -50,7 +50,7 @@ export async function contactPropertyKeys(projectId) {
50
50
  * every email of the given campaigns (#3853). Placeholder text and sending-domain links are
51
51
  * blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
52
52
  */
53
- export async function checkCampaignContent(projectId, campaigns, steps) {
53
+ export async function checkCampaignContent(projectId, campaigns, steps, options = {}) {
54
54
  const lookupErrors = [];
55
55
  const sendingHostsBySite = new Map();
56
56
  const allHosts = new Set();
@@ -63,11 +63,11 @@ export async function checkCampaignContent(projectId, campaigns, steps) {
63
63
  for (const host of hosts)
64
64
  allHosts.add(host);
65
65
  }
66
- const contactProps = await contactPropertyKeys(projectId);
67
- if (contactProps === null)
66
+ const contactProps = options.mergeVariables === false ? null : await contactPropertyKeys(projectId);
67
+ if (options.mergeVariables !== false && contactProps === null)
68
68
  lookupErrors.push('Could not read your contacts, so merge tags that use contact properties were not confirmed.');
69
69
  const eventKeys = new Map();
70
- for (const eventName of new Set(campaigns.map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
70
+ for (const eventName of new Set((options.mergeVariables === false ? [] : campaigns).map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
71
71
  const keys = await eventKeysFromHistory(projectId, eventName);
72
72
  eventKeys.set(eventName, keys);
73
73
  if (keys === null)
@@ -82,7 +82,7 @@ export async function checkCampaignContent(projectId, campaigns, steps) {
82
82
  : [...events, ...contactProps.keys.map((key) => `custom.${key}`)];
83
83
  const hosts = (campaign.site_id ? sendingHostsBySite.get(campaign.site_id) : undefined) ?? [...allHosts];
84
84
  for (const step of steps.filter((s) => s.campaign_id === campaign.id)) {
85
- for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys })) {
85
+ for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys: options.mergeVariables === false ? undefined : sourcedKeys })) {
86
86
  const finding = { ...issue, campaign: campaign.name, email: step.step_order + 1 };
87
87
  (BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind) ? blocking : warnings).push(finding);
88
88
  }
@@ -109,3 +109,60 @@ export function summarizeContentWarnings(warnings) {
109
109
  const first = warnings.slice(0, 3).map((w) => (w.campaign ? `${w.campaign}, email ${w.email}: ${w.warning}` : w.warning));
110
110
  return `${warnings.length} warning${warnings.length === 1 ? '' : 's'}. ${first.join(' | ')}${warnings.length > 3 ? ` | and ${warnings.length - 3} more` : ''}`;
111
111
  }
112
+ /** Uses the shared domain rules; unavailable sources stay unknown and never stop activation. */
113
+ export async function checkCampaignMergeVariables(projectId, campaign, steps) {
114
+ const safe = async (load) => {
115
+ try {
116
+ return await load();
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ };
122
+ const contacts = await safe(() => contactPropertyKeys(projectId));
123
+ const events = campaign.trigger_event_name
124
+ ? await safe(() => eventKeysFromHistory(projectId, campaign.trigger_event_name)) : [];
125
+ const knowledge = {
126
+ eventKeys: events === null ? null : new Set(events),
127
+ contactPropertyKeys: contacts === null ? null : { keys: new Set(contacts.keys), complete: contacts.complete },
128
+ };
129
+ const checked_at = new Date().toISOString();
130
+ return steps.flatMap(step => findUnprovidedMergeTags({ subject: step.subject, html: step.body_html, text: step.body_text }, knowledge).map(finding => ({ ...finding, step_order: step.step_order, checked_at,
131
+ warning: finding.kind === 'unknown_event'
132
+ ? `Can't tell whether ${finding.tag} can be filled: event history is empty or couldn't be checked.`
133
+ : finding.kind === 'unknown_contacts'
134
+ ? `Couldn't check whether ${finding.tag} can be filled: the contact scan failed or is incomplete.`
135
+ : `${finding.tag} may print blank: ${finding.kind === 'no_event_data' ? 'no observed trigger event data provides it' : 'no contact has this property'}.`,
136
+ })));
137
+ }
138
+ /** One extra line on the approval, including explicit uncertainty for unknown sources. */
139
+ export function summarizeMergeVariableWarnings(warnings) {
140
+ if (!warnings.length)
141
+ return undefined;
142
+ return `${warnings.length} merge variables may print blank: ${warnings.map(w => `${w.tag} (email ${w.step_order + 1}${w.kind.startsWith('unknown_') ? ": can't tell / couldn't check" : ''})`).join(', ')}`;
143
+ }
144
+ /** Best-effort note. Compare-and-swap preserves concurrent metadata edits; never gate activation. */
145
+ export async function persistMergeVariableWarnings(projectId, campaignId, warnings) {
146
+ try {
147
+ const source = db().schema('email');
148
+ for (let attempt = 0; attempt < 3; attempt++) {
149
+ const { data: current, error: readError } = await source.from('campaigns').select('metadata')
150
+ .eq('id', campaignId).eq('project_id', projectId).maybeSingle();
151
+ if (readError || !current)
152
+ break;
153
+ const metadata = current.metadata;
154
+ const next = { ...(metadata ?? {}), merge_variable_warnings: warnings
155
+ .filter(w => w.kind === 'no_event_data' || w.kind === 'no_contact_has_it')
156
+ .map(({ tag, step_order, kind, checked_at }) => ({ tag, step_order, kind, checked_at })) };
157
+ let update = source.from('campaigns').update({ metadata: next }).eq('id', campaignId).eq('project_id', projectId);
158
+ update = metadata == null ? update.is('metadata', null) : update.eq('metadata', JSON.stringify(metadata));
159
+ const { data, error } = await update.select('id');
160
+ if (error)
161
+ break;
162
+ if (data?.length)
163
+ return;
164
+ }
165
+ }
166
+ catch { /* A failed note write must not prevent activation. */ }
167
+ console.error('[merge-variable-warnings] Could not save campaign warning note.');
168
+ }
@@ -11,7 +11,7 @@ import { generateEmailFromBrief } from '../lib/email-generation.js';
11
11
  import { fireWebhookEvent } from '../lib/webhook-events.js';
12
12
  import { logAudit } from '../audit.js';
13
13
  import { applyCampaignTargetingFields, campaignTriggerKind } from './campaigns.js';
14
- import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
14
+ import { checkCampaignMergeVariables, persistMergeVariableWarnings, summarizeMergeVariableWarnings, checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
15
15
  import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
16
16
  import { dbToolError } from '../lib/db-error.js';
17
17
  /**
@@ -291,9 +291,11 @@ export function registerCampaignSetTools(server) {
291
291
  const projectId = getProjectId();
292
292
  const { approval_id } = args;
293
293
  async function applyCampaigns(set, ids, approval_id, decidedBy) {
294
+ const mergeVariableWarnings = [];
294
295
  const activated = [];
295
296
  const failed = [];
296
297
  for (const c of set.campaigns) {
298
+ const warnings = await checkCampaignMergeVariables(projectId, c, set.steps.filter(s => s.campaign_id === c.id));
297
299
  const { error } = await db()
298
300
  .from('campaigns')
299
301
  .update({ status: 'active', updated_at: new Date().toISOString() })
@@ -305,12 +307,15 @@ export function registerCampaignSetTools(server) {
305
307
  continue;
306
308
  }
307
309
  activated.push(c.id);
310
+ await persistMergeVariableWarnings(projectId, c.id, warnings);
311
+ mergeVariableWarnings.push(...warnings.map(w => ({ ...w, campaign_id: c.id })));
308
312
  fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
309
313
  }
310
314
  await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: decidedBy ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
311
315
  return j({
312
316
  activated,
313
317
  failed,
318
+ merge_variable_warnings: mergeVariableWarnings,
314
319
  ...(set.campaigns.some(c => c.type === 'broadcast') ? {
315
320
  campaigns: previewOf(set.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), set.steps),
316
321
  broadcast_activation: BROADCAST_ACTIVATION,
@@ -348,7 +353,7 @@ export function registerCampaignSetTools(server) {
348
353
  return j({ error: 'this campaign has no trigger, so it would never fire', code: 'validation_failed' });
349
354
  }
350
355
  // Same content gate as the request, so a bad email can't ride through on an old approval.
351
- const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps);
356
+ const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps, { mergeVariables: false });
352
357
  if (readiness.blocking.length)
353
358
  return j(contentNotReadyResponse(readiness.blocking));
354
359
  const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
@@ -396,10 +401,12 @@ export function registerCampaignSetTools(server) {
396
401
  });
397
402
  }
398
403
  // Placeholder text and links to the sending domain never go out (#3853). Merge tags with no data source are warnings.
399
- const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps);
404
+ const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps, { mergeVariables: false });
400
405
  if (readiness.blocking.length)
401
406
  return j(contentNotReadyResponse(readiness.blocking));
402
407
  const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
408
+ const mergeVariableWarnings = (await Promise.all(set.campaigns.map(async (c) => (await checkCampaignMergeVariables(projectId, c, set.steps.filter(s => s.campaign_id === c.id)))
409
+ .map(w => ({ ...w, campaign_id: c.id }))))).flat();
403
410
  const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
404
411
  const channelContext = currentChannelContext();
405
412
  const decision = await assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
@@ -409,6 +416,7 @@ export function registerCampaignSetTools(server) {
409
416
  return applyCampaigns(set, ids);
410
417
  const names = set.campaigns.map((c) => c.name);
411
418
  const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
419
+ merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings),
412
420
  campaign_count: set.campaigns.length,
413
421
  email_count: set.steps.length,
414
422
  campaign_names: names.slice(0, 10).join(', ') + (names.length > 10 ? `, and ${names.length - 10} more` : ''),
@@ -417,6 +425,7 @@ export function registerCampaignSetTools(server) {
417
425
  return j({
418
426
  ...approval,
419
427
  campaigns: previewOf(set.campaigns, set.steps),
428
+ merge_variable_warnings: mergeVariableWarnings,
420
429
  ...(contentWarnings.length ? { content_warnings: contentWarnings } : {}),
421
430
  next: approvalNext('activate_campaigns', approval),
422
431
  });
@@ -32,7 +32,7 @@ import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } fro
32
32
  import { sendEmailForSite } from '../lib/email-provider.js';
33
33
  import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
34
34
  import { logAudit } from '../audit.js';
35
- import { checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
35
+ import { checkCampaignMergeVariables, persistMergeVariableWarnings, summarizeMergeVariableWarnings, checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
36
36
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
37
37
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
38
38
  import { callSendinelV1 } from '../lib/v1-client.js';
@@ -1189,6 +1189,7 @@ export function registerCampaignTools(server) {
1189
1189
  approval_id: z.string().uuid().optional().describe('Required when activating. Get this by calling once without it, then have a human approve.'),
1190
1190
  },
1191
1191
  }, async ({ campaign_id, status, approval_id }) => {
1192
+ let mergeVariableWarnings = [];
1192
1193
  // Human approval required for activation
1193
1194
  if (status === 'active') {
1194
1195
  // An imported email whose wait wasn't in the source has no delay; it would never send (#3689).
@@ -1227,10 +1228,11 @@ export function registerCampaignTools(server) {
1227
1228
  .eq('campaign_id', campaign_id);
1228
1229
  if (contentErr)
1229
1230
  return j({ error: `Could not check the campaign's emails: ${dbToolError(contentErr).error}`, code: 'STEP_LOOKUP_FAILED' });
1230
- const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? []);
1231
+ const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? [], { mergeVariables: false });
1231
1232
  if (readiness.blocking.length)
1232
1233
  return j(contentNotReadyResponse(readiness.blocking));
1233
1234
  contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
1235
+ mergeVariableWarnings = await checkCampaignMergeVariables(getProjectId(), campRow, contentSteps ?? []);
1234
1236
  }
1235
1237
  const approvalParams = { campaign_id, status };
1236
1238
  if (!approval_id) {
@@ -1246,12 +1248,13 @@ export function registerCampaignTools(server) {
1246
1248
  .eq('project_id', getProjectId())
1247
1249
  .maybeSingle();
1248
1250
  const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext, {
1251
+ merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings),
1249
1252
  campaign_name: camp?.name ?? null,
1250
1253
  enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
1251
1254
  trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
1252
1255
  ...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
1253
1256
  });
1254
- return j(contentWarnings.length ? { ...approval, content_warnings: contentWarnings } : approval);
1257
+ return j({ ...approval, merge_variable_warnings: mergeVariableWarnings, ...(contentWarnings.length ? { content_warnings: contentWarnings } : {}) });
1255
1258
  }
1256
1259
  }
1257
1260
  else {
@@ -1269,6 +1272,11 @@ export function registerCampaignTools(server) {
1269
1272
  .single();
1270
1273
  if (error)
1271
1274
  return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1275
+ if (status === 'active') {
1276
+ await persistMergeVariableWarnings(getProjectId(), campaign_id, mergeVariableWarnings);
1277
+ await logAudit({ action: 'campaign.status_changed', resourceType: 'campaign', resourceId: campaign_id,
1278
+ details: { status, merge_variable_warnings: mergeVariableWarnings } }).catch(() => { });
1279
+ }
1272
1280
  // Fire webhook for status changes
1273
1281
  const eventMap = { active: 'campaign.activated', completed: 'campaign.completed', paused: 'campaign.paused' };
1274
1282
  if (eventMap[status]) {
@@ -1278,7 +1286,7 @@ export function registerCampaignTools(server) {
1278
1286
  status,
1279
1287
  }).catch(() => { });
1280
1288
  }
1281
- return { content: [{ type: 'text', text: JSON.stringify(campaignMetricsView(data), null, 2) }] };
1289
+ return { content: [{ type: 'text', text: JSON.stringify({ ...campaignMetricsView(data), ...(status === 'active' ? { merge_variable_warnings: mergeVariableWarnings } : {}) }, null, 2) }] };
1282
1290
  });
1283
1291
  server.registerTool('enroll_contact', {
1284
1292
  title: 'Enroll Contact',
@@ -3411,6 +3419,7 @@ Return ONLY valid JSON with this exact shape:
3411
3419
  if (reentry.period_key !== undefined)
3412
3420
  targeting.period_key = reentry.period_key;
3413
3421
  const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
3422
+ let mergeVariableWarnings = activate && !approval_id ? await checkCampaignMergeVariables(getProjectId(), { trigger_event_name: typeof targeting.trigger_event_name === 'string' ? targeting.trigger_event_name : null }, steps.map((step, step_order) => ({ ...step, step_order }))) : [];
3414
3423
  // Human-in-the-loop gate for activation
3415
3424
  if (activate && confirmed) {
3416
3425
  const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, reentry_mode, period_key, ...triggerInput };
@@ -3421,8 +3430,8 @@ Return ONLY valid JSON with this exact shape:
3421
3430
  if (decision.tier !== 'autonomous') {
3422
3431
  if (!approval_id) {
3423
3432
  // No approval token — request one
3424
- const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext);
3425
- return j(approval);
3433
+ const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext, { merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings) });
3434
+ return j({ ...approval, merge_variable_warnings: mergeVariableWarnings });
3426
3435
  }
3427
3436
  // Have a token — verify it's approved
3428
3437
  const status = await checkApproval(approval_id, { consume: true, action: 'create_campaign_with_content', params: approvalParams });
@@ -3459,6 +3468,7 @@ Return ONLY valid JSON with this exact shape:
3459
3468
  if (!confirmed) {
3460
3469
  return j({
3461
3470
  confirmation_required: true,
3471
+ ...(activate ? { merge_variable_warnings: mergeVariableWarnings } : {}),
3462
3472
  campaign: { name, type: campaignType, site: site.name, send_class: sendClass, unsub_footer_template: footerTemplate, ...targeting },
3463
3473
  steps: steps.map((s, i) => ({
3464
3474
  step: i + 1,
@@ -3559,6 +3569,8 @@ Return ONLY valid JSON with this exact shape:
3559
3569
  // 4. Activate if requested
3560
3570
  const willActivate = Boolean(activate && ((segment_id && enrolled > 0) || canActivateOnTrigger));
3561
3571
  if (willActivate) {
3572
+ mergeVariableWarnings = await checkCampaignMergeVariables(getProjectId(), { trigger_event_name: typeof targeting.trigger_event_name === 'string' ? targeting.trigger_event_name : null }, stepInserts);
3573
+ await persistMergeVariableWarnings(getProjectId(), campaign.id, mergeVariableWarnings);
3562
3574
  await db()
3563
3575
  .from('campaigns')
3564
3576
  .update({ status: 'active' })
@@ -3586,6 +3598,7 @@ Return ONLY valid JSON with this exact shape:
3586
3598
  steps_created: steps.length,
3587
3599
  contacts_enrolled: enrolled,
3588
3600
  status: willActivate ? 'active' : 'draft',
3601
+ ...(willActivate ? { merge_variable_warnings: mergeVariableWarnings } : {}),
3589
3602
  message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${willActivate ? 'Campaign is now active — emails will begin sending on the next cron cycle.' : 'Campaign saved as draft.'}`,
3590
3603
  });
3591
3604
  });
@@ -4089,6 +4102,7 @@ Return ONLY valid JSON with this exact shape:
4089
4102
  const unresolvedCount = dlqUnresolvedCount ?? 0;
4090
4103
  return j({
4091
4104
  ...campaignMetricsView(campaign),
4105
+ merge_variable_warnings: campaign.metadata?.merge_variable_warnings ?? [],
4092
4106
  reentry_mode: campaign.period_key ? "per_event_period" : "once_ever",
4093
4107
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
4094
4108
  ...await campaignTriggerOutput(campaign),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.216",
3
+ "version": "1.0.217",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",