@sendinel/mcp-server 1.0.149 → 1.0.151

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.
@@ -1,3 +1,4 @@
1
+ import { validateStepMultiEventRules, stepMultiEventRulesView } from "@sendinel/domain";
1
2
  import { readCampaignMetricGroups, SENT_STATUSES } from "@sendinel/domain";
2
3
  import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
3
4
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
@@ -40,6 +41,12 @@ const reentryFields = {
40
41
  reentry_mode: z.enum(['once_ever', 'per_event_period']).optional().describe(REENTRY_DESCRIPTION),
41
42
  period_key: z.string().min(1).max(64).nullable().optional().describe('Flat event-data key naming the period (starts with a letter; letters, digits and underscores only). Key alone selects per_event_period; null clears to once ever. ' + REENTRY_DESCRIPTION),
42
43
  };
44
+ // Schema rejects empty/oversized writes; shared validation trims and deduplicates before storage.
45
+ const multiEventFields = {
46
+ skip_if_events: z.array(z.string().trim().min(1)).min(1).max(10).nullable().optional().describe('Skip if ANY listed event has occurred. ANDed with legacy and required gates. null clears.'),
47
+ send_only_if_events: z.array(z.string().trim().min(1)).min(1).max(10).nullable().optional().describe('Required events at send time, ALL by default; null clears. ANDed with legacy gates.'),
48
+ send_only_if_events_mode: z.enum(['all', 'any']).nullable().optional().describe('Required-event list mode; omitted/null defaults to all on new steps. Omission preserves the mode on updates.'),
49
+ };
43
50
  function stripHtml(value) {
44
51
  return value
45
52
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
@@ -891,11 +898,15 @@ export function registerCampaignTools(server) {
891
898
  brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
892
899
  skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
893
900
  send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
901
+ ...multiEventFields,
894
902
  skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
895
903
  send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
896
904
  },
897
- }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
905
+ }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property }) => {
898
906
  try {
907
+ const events = validateStepMultiEventRules({ skip_if_events, send_only_if_events, send_only_if_events_mode });
908
+ if (!events.ok)
909
+ return j({ error: events.error, code: 'INVALID_INPUT' });
899
910
  const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
900
911
  if (!properties.ok)
901
912
  return j({ error: properties.error, code: "INVALID_INPUT" });
@@ -932,7 +943,7 @@ export function registerCampaignTools(server) {
932
943
  const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
933
944
  const { data, error } = await db()
934
945
  .from('campaign_steps')
935
- .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
946
+ .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...properties.rules })
936
947
  .select()
937
948
  .single();
938
949
  if (error)
@@ -2936,7 +2947,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2936
2947
  : row.status === 'expired' ? 'no_reply_by_deadline'
2937
2948
  : 'unknown',
2938
2949
  }));
2939
- return j({ items, count: items.length });
2950
+ return j({ items, count: items.length, truncated: items.length === limit });
2940
2951
  });
2941
2952
  server.registerTool('validate_template', {
2942
2953
  title: 'Validate Template',
@@ -3112,6 +3123,9 @@ Return ONLY valid JSON with this exact shape:
3112
3123
  trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
3113
3124
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists with optional where filter, or date_field/contact_event configuration.'),
3114
3125
  steps: z.array(z.object({
3126
+ ...multiEventFields,
3127
+ skip_if_event: z.string().trim().min(1).nullable().optional(),
3128
+ send_only_if_event: z.string().trim().min(1).nullable().optional(),
3115
3129
  subject: z.string().min(1).describe('Email subject line'),
3116
3130
  body_html: z.string().min(1).describe('Full HTML email body'),
3117
3131
  body_text: z.string().optional().describe('Plain text fallback'),
@@ -3126,6 +3140,12 @@ Return ONLY valid JSON with this exact shape:
3126
3140
  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.'),
3127
3141
  },
3128
3142
  }, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, unsub_footer_template, reentry_mode, period_key }) => {
3143
+ for (const step of steps) {
3144
+ const events = validateStepMultiEventRules(step);
3145
+ if (!events.ok)
3146
+ return j({ error: events.error, code: 'INVALID_INPUT' });
3147
+ Object.assign(step, events.rules);
3148
+ }
3129
3149
  const footerTemplate = unsub_footer_template?.trim() ?? null;
3130
3150
  if (footerTemplate !== null) {
3131
3151
  const footerError = validateUnsubFooterTemplate(footerTemplate);
@@ -3202,6 +3222,9 @@ Return ONLY valid JSON with this exact shape:
3202
3222
  delay: s.delay_days ? `${s.delay_days}d ${s.delay_hours ?? 0}h` : `${s.delay_hours ?? 0}h`,
3203
3223
  has_html: !!s.body_html,
3204
3224
  has_text: !!s.body_text,
3225
+ skip_if_event: s.skip_if_event ?? null,
3226
+ send_only_if_event: s.send_only_if_event ?? null,
3227
+ ...stepMultiEventRulesView(s),
3205
3228
  })),
3206
3229
  segment_id: segment_id ?? null,
3207
3230
  sends_per_hour: sends_per_hour ?? null,
@@ -3240,6 +3263,9 @@ Return ONLY valid JSON with this exact shape:
3240
3263
  blocks: s.blocks ? normalizeComposerBlocks(s.blocks) : null,
3241
3264
  delay_days: s.delay_days ?? 0,
3242
3265
  delay_hours: s.delay_hours ?? 0,
3266
+ skip_if_event: s.skip_if_event ?? null,
3267
+ send_only_if_event: s.send_only_if_event ?? null,
3268
+ ...stepMultiEventRulesView(s),
3243
3269
  }));
3244
3270
  const { error: stepErr } = await db()
3245
3271
  .from('campaign_steps')
@@ -3337,7 +3363,7 @@ Return ONLY valid JSON with this exact shape:
3337
3363
  return j({ error: (campaignErr ? dbToolError(campaignErr).error : undefined) ?? 'Campaign not found', code: 'NOT_FOUND' });
3338
3364
  const { data, error } = await db()
3339
3365
  .from('campaign_steps')
3340
- .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, created_at, updated_at')
3366
+ .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, created_at, updated_at')
3341
3367
  .eq('campaign_id', campaign_id)
3342
3368
  .order('step_order', { ascending: true });
3343
3369
  if (error)
@@ -3365,6 +3391,7 @@ Return ONLY valid JSON with this exact shape:
3365
3391
  survey_id: z.string().uuid().optional(),
3366
3392
  skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
3367
3393
  send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
3394
+ ...multiEventFields,
3368
3395
  skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
3369
3396
  send_only_if_event: z.string().trim().min(1).nullable().optional().describe('Event required for this step to send; null clears the rule.'),
3370
3397
  link_actions: z.array(z.discriminatedUnion('action', [
@@ -3381,6 +3408,9 @@ Return ONLY valid JSON with this exact shape:
3381
3408
  }, async ({ step_id, ...updates }) => {
3382
3409
  const projectId = getProjectId();
3383
3410
  const { survey_id, ...stepUpdates } = updates;
3411
+ const events = validateStepMultiEventRules(stepUpdates);
3412
+ if (!events.ok)
3413
+ return j({ error: events.error, code: 'INVALID_INPUT' });
3384
3414
  const properties = validateStepPropertyRules(stepUpdates);
3385
3415
  if (!properties.ok)
3386
3416
  return j({ error: properties.error, code: "INVALID_INPUT" });
@@ -3409,6 +3439,7 @@ Return ONLY valid JSON with this exact shape:
3409
3439
  const normalizedBlocks = Array.isArray(stepUpdates.blocks) ? normalizeComposerBlocks(stepUpdates.blocks) : undefined;
3410
3440
  const renderedUpdates = {
3411
3441
  ...stepUpdates,
3442
+ ...events.rules,
3412
3443
  ...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
3413
3444
  ...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
3414
3445
  ...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
@@ -3420,7 +3451,7 @@ Return ONLY valid JSON with this exact shape:
3420
3451
  .from('campaign_steps')
3421
3452
  .update({ ...renderedUpdates, updated_at: new Date().toISOString() })
3422
3453
  .eq('id', step_id)
3423
- .select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, updated_at')
3454
+ .select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, updated_at')
3424
3455
  .single();
3425
3456
  if (error)
3426
3457
  return j(dbToolError(error));
@@ -3798,7 +3829,7 @@ Return ONLY valid JSON with this exact shape:
3798
3829
  total_sent: periods?.totals?.sent ?? sentCount ?? 0,
3799
3830
  ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
3800
3831
  ...campaignPeriodFields(periods, period),
3801
- steps: (steps ?? []).map((step) => ({ ...step, total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
3832
+ steps: (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
3802
3833
  active_enrollments: enrollCount ?? 0,
3803
3834
  send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
3804
3835
  queued_email_count: queuedCount,
@@ -341,7 +341,7 @@ export function registerDeliveryOpsTools(server) {
341
341
  if (error)
342
342
  return j(dbToolError(error));
343
343
  const failed = (data ?? []).filter((row) => row.status === 'failed');
344
- return j({ runs: data ?? [], recent_failures: failed.length, total: data?.length ?? 0 });
344
+ return j({ runs: data ?? [], recent_failures: failed.length, total: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
345
345
  });
346
346
  server.registerTool('get_queue_status', {
347
347
  title: 'Get Queue Status',
@@ -406,7 +406,7 @@ export function registerDeliveryOpsTools(server) {
406
406
  const { data, error } = await query;
407
407
  if (error)
408
408
  return j(dbToolError(error));
409
- return j({ dlq: data ?? [], count: data?.length ?? 0 });
409
+ return j({ dlq: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
410
410
  });
411
411
  server.registerTool('get_enrollment_dlq', {
412
412
  title: 'Get Enrollment DLQ',
@@ -436,7 +436,7 @@ export function registerDeliveryOpsTools(server) {
436
436
  const { data, error } = await query;
437
437
  if (error)
438
438
  return j(dbToolError(error));
439
- return j({ dlq: data ?? [], count: data?.length ?? 0 });
439
+ return j({ dlq: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
440
440
  });
441
441
  server.registerTool('get_send_queue_rows', {
442
442
  title: 'Get Send Queue Rows',
@@ -110,7 +110,7 @@ export function registerGdprTools(server) {
110
110
  .limit(limit ?? 50);
111
111
  if (error)
112
112
  return txt(`Error: ${dbToolError(error).error}`);
113
- return j({ requests: data ?? [], count: data?.length ?? 0 });
113
+ return j({ requests: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === (limit ?? 50) });
114
114
  });
115
115
  server.registerTool('action_ccpa_request', {
116
116
  title: 'Action CCPA Request',
@@ -183,7 +183,7 @@ export function registerNewsletterTools(server) {
183
183
  const { data, error } = await query;
184
184
  if (error)
185
185
  return j(dbToolError(error));
186
- return j({ newsletters: data ?? [], count: data?.length ?? 0 });
186
+ return j({ newsletters: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
187
187
  });
188
188
  server.registerTool('get_newsletter', {
189
189
  title: 'Get Newsletter',
@@ -376,7 +376,7 @@ export function registerNewsletterTools(server) {
376
376
  const { data, error } = await query;
377
377
  if (error)
378
378
  return j(dbToolError(error));
379
- return j({ issues: data ?? [], count: data?.length ?? 0 });
379
+ return j({ issues: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
380
380
  });
381
381
  server.registerTool('schedule_newsletter_issue', {
382
382
  title: 'Schedule Newsletter Issue',
@@ -587,7 +587,7 @@ export function registerNewsletterTools(server) {
587
587
  const { data, error } = await query;
588
588
  if (error)
589
589
  return j(dbToolError(error));
590
- return j({ subscribers: data ?? [], count: data?.length ?? 0 });
590
+ return j({ subscribers: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
591
591
  });
592
592
  server.registerTool('get_newsletter_stats', {
593
593
  title: 'Get Newsletter Stats',
@@ -107,9 +107,9 @@ export function registerShortLinkTools(server) {
107
107
  return j(dbToolError(linkError));
108
108
  if (!link)
109
109
  return j({ error: 'Short link not found', code: 'not_found' });
110
- const { data: clicks, error: clickError } = await db()
110
+ const { data: clicks, error: clickError, count: total } = await db()
111
111
  .from('short_link_clicks')
112
- .select('clicked_at, ip_hash, referer')
112
+ .select('clicked_at, ip_hash, referer', { count: 'exact' })
113
113
  .eq('short_link_id', short_link_id)
114
114
  .order('clicked_at', { ascending: false })
115
115
  .limit(500);
@@ -118,7 +118,9 @@ export function registerShortLinkTools(server) {
118
118
  const uniqueClicks = new Set((clicks ?? []).map((click) => click.ip_hash).filter(Boolean)).size;
119
119
  return j({
120
120
  short_link: link,
121
- total_clicks: link.click_count ?? clicks?.length ?? 0,
121
+ total_clicks: link.click_count ?? total ?? clicks?.length ?? 0,
122
+ ...(total != null ? { total } : {}),
123
+ truncated: (clicks?.length ?? 0) === 500,
122
124
  unique_clicks: uniqueClicks,
123
125
  recent_clicks: clicks ?? [],
124
126
  });
@@ -140,7 +140,7 @@ export function registerSurveyTools(server) {
140
140
  status: z.enum(['draft', 'active', 'closed']).optional(),
141
141
  limit: z.number().int().min(1).max(200).optional().default(50),
142
142
  },
143
- }, async ({ status, limit }) => {
143
+ }, async ({ status, limit = 50 }) => {
144
144
  const projectId = getProjectId();
145
145
  let query = db().from('surveys').select('id, title, status, short_link_id, created_at, updated_at').eq('project_id', projectId).order('created_at', { ascending: false }).limit(limit);
146
146
  if (status)
@@ -148,7 +148,7 @@ export function registerSurveyTools(server) {
148
148
  const { data, error } = await query;
149
149
  if (error)
150
150
  return j(dbToolError(error));
151
- return j({ surveys: data ?? [], count: data?.length ?? 0 });
151
+ return j({ surveys: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
152
152
  });
153
153
  server.registerTool('get_survey', {
154
154
  title: 'Get Survey',
@@ -93,7 +93,7 @@ export function registerTemplateTools(server) {
93
93
  const { data, error } = await query;
94
94
  if (error)
95
95
  return j(dbToolError(error));
96
- return j({ templates: data ?? [], count: data?.length ?? 0 });
96
+ return j({ templates: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
97
97
  });
98
98
  server.registerTool('translate_template', {
99
99
  title: 'Translate Template',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.149",
3
+ "version": "1.0.151",
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",