@sendinel/mcp-server 1.0.70 → 1.0.71

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.
@@ -4,11 +4,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  * row. Includes a warning when the campaign cannot fire yet (#3855).
5
5
  */
6
6
  export declare function buildCampaignFiresOn(campaign: Record<string, unknown>): string;
7
+ /**
8
+ * Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
9
+ */
10
+ export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>): string[];
7
11
  export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
8
12
  trigger_event_name?: string | null;
9
13
  trigger_tag?: string | null;
10
14
  trigger_type?: string | null;
11
15
  trigger_config?: Record<string, unknown> | null;
12
16
  segment_id?: string | null;
13
- }, projectId: string): Promise<string | null>;
17
+ }, projectId: string, existingCampaign?: Record<string, unknown> | null): Promise<string | null>;
14
18
  export declare function registerCampaignTools(server: McpServer): void;
@@ -93,6 +93,37 @@ export function buildCampaignFiresOn(campaign) {
93
93
  summary += ` Warning: ${warnings.join('; ')}.`;
94
94
  return summary;
95
95
  }
96
+ /**
97
+ * Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
98
+ */
99
+ export function buildCampaignTriggerDiagnostics(campaign) {
100
+ const config = (campaign.trigger_config && typeof campaign.trigger_config === 'object' ? campaign.trigger_config : {});
101
+ const events = normalizeTriggerList([...normalizeTriggerList(config.events), campaign.trigger_event_name]);
102
+ const tags = normalizeTriggerList([...normalizeTriggerList(config.tags), campaign.trigger_tag]);
103
+ const diagnostics = [];
104
+ if ((events.length || tags.length) && campaign.type !== 'triggered') {
105
+ diagnostics.push(`campaign type is "${String(campaign.type)}"; event and tag triggers only start campaigns of type "triggered" (it will never fire)`);
106
+ }
107
+ if (campaign.trigger_type === 'date_field') {
108
+ const field = typeof config.field === 'string' && config.field ? config.field : null;
109
+ if (!field) {
110
+ diagnostics.push('trigger_type is "date_field", but no date field is set (it will never fire)');
111
+ }
112
+ }
113
+ else if (campaign.trigger_type === 'contact_event') {
114
+ const eventType = typeof config.event_type === 'string' && config.event_type ? config.event_type : null;
115
+ if (!eventType) {
116
+ diagnostics.push('trigger_type is "contact_event", but no event_type is set (it will never fire)');
117
+ }
118
+ }
119
+ if ((campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event') && (events.length || tags.length) && campaign.type === 'triggered') {
120
+ diagnostics.push(`trigger_type is "${String(campaign.trigger_type)}" but event/tag triggers are also set; event intake will start it immediately, outside the ${String(campaign.trigger_type)} schedule`);
121
+ }
122
+ if (campaign.status !== 'active') {
123
+ diagnostics.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
124
+ }
125
+ return diagnostics;
126
+ }
96
127
  function applyCampaignSendAtField(updates, sendAt, campaignType) {
97
128
  if (sendAt === undefined)
98
129
  return null;
@@ -108,19 +139,22 @@ function applyCampaignSendAtField(updates, sendAt, campaignType) {
108
139
  updates.send_at = parsed.scheduledAt;
109
140
  return null;
110
141
  }
111
- export async function applyCampaignTargetingFields(updates, input, projectId) {
142
+ export async function applyCampaignTargetingFields(updates, input, projectId, existingCampaign) {
143
+ const hasTriggerEvent = input.trigger_event_name !== undefined && input.trigger_event_name !== null && input.trigger_event_name.trim() !== '';
112
144
  if (input.trigger_event_name !== undefined)
113
145
  updates.trigger_event_name = input.trigger_event_name?.trim() || null;
114
146
  if (input.trigger_tag !== undefined)
115
147
  updates.trigger_tag = input.trigger_tag?.trim().toLowerCase() || null;
116
148
  if (input.trigger_type === null || input.trigger_type === 'manual') {
117
149
  // campaigns.trigger_type is NOT NULL DEFAULT 'manual', so "clear" means back to 'manual'.
118
- // Also drops a stale date_field config unless the caller sent a new trigger_config (#3855).
150
+ // Also drops a stale date_field or contact_event config unless the caller sent a new trigger_config (#3855, #3852).
119
151
  updates.trigger_type = 'manual';
120
152
  if (input.trigger_config === undefined)
121
153
  updates.trigger_config = null;
122
154
  }
123
155
  if (input.trigger_type === 'date_field') {
156
+ if (hasTriggerEvent)
157
+ return 'cannot set trigger_event_name on a date_field campaign';
124
158
  const config = input.trigger_config ?? {};
125
159
  const field = typeof config.field === 'string' ? config.field.trim() : '';
126
160
  if (!/^[A-Za-z0-9_.-]{1,80}$/.test(field))
@@ -132,6 +166,23 @@ export async function applyCampaignTargetingFields(updates, input, projectId) {
132
166
  recurring: config.recurring !== false,
133
167
  };
134
168
  }
169
+ else if (input.trigger_type === 'contact_event') {
170
+ // trigger_event_name would also make event intake enroll at once, skipping days_after.
171
+ if (hasTriggerEvent)
172
+ return 'cannot set trigger_event_name on a contact_event campaign; use trigger_config.event_type';
173
+ const config = input.trigger_config ?? {};
174
+ const eventType = typeof config.event_type === 'string' ? config.event_type.trim() : '';
175
+ const daysAfter = Number(config.days_after ?? 0);
176
+ if (!eventType)
177
+ return 'event_type is required';
178
+ if (!Number.isInteger(daysAfter))
179
+ return 'days_after must be an integer';
180
+ updates.trigger_type = 'contact_event';
181
+ updates.trigger_config = {
182
+ event_type: eventType,
183
+ days_after: Math.max(0, Math.min(365, daysAfter)),
184
+ };
185
+ }
135
186
  else if (input.trigger_config !== undefined) {
136
187
  if (input.trigger_config === null) {
137
188
  updates.trigger_config = null;
@@ -143,6 +194,14 @@ export async function applyCampaignTargetingFields(updates, input, projectId) {
143
194
  updates.trigger_config = config;
144
195
  }
145
196
  }
197
+ // If trigger_type was not specified, but caller is setting an event trigger on a campaign
198
+ // whose current trigger_type is date_field or contact_event, normalize trigger_type to 'manual' (#3852).
199
+ const setsEventTrigger = hasTriggerEvent || (updates.trigger_config && typeof updates.trigger_config === 'object' && Array.isArray(updates.trigger_config.events) && updates.trigger_config.events.length > 0);
200
+ if (input.trigger_type === undefined && setsEventTrigger) {
201
+ if (existingCampaign?.trigger_type === 'date_field' || existingCampaign?.trigger_type === 'contact_event') {
202
+ updates.trigger_type = 'manual';
203
+ }
204
+ }
146
205
  if (input.segment_id !== undefined) {
147
206
  if (!input.segment_id) {
148
207
  updates.segment_id = null;
@@ -3287,7 +3346,7 @@ Return ONLY valid JSON with this exact shape:
3287
3346
  idempotentHint: true,
3288
3347
  openWorldHint: false,
3289
3348
  },
3290
- description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). trigger_type null (or manual) clears a date_field trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
3349
+ description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). trigger_type null (or manual) clears a date_field or contact_event trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on, and trigger_diagnostics naming any trigger problems. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
3291
3350
  inputSchema: {
3292
3351
  campaign_id: z.string().uuid(),
3293
3352
  name: z.string().min(1).max(200).optional(),
@@ -3297,7 +3356,7 @@ Return ONLY valid JSON with this exact shape:
3297
3356
  topic: z.string().nullable().optional(),
3298
3357
  trigger_event_name: z.string().nullable().optional(),
3299
3358
  trigger_tag: z.string().nullable().optional(),
3300
- trigger_type: z.enum(['date_field', 'manual']).nullable().optional().describe("'date_field' fires relative to a contact date field (needs trigger_config.field). null or 'manual' clears a date_field trigger."),
3359
+ trigger_type: z.enum(['date_field', 'manual', 'contact_event']).nullable().optional().describe("'date_field' fires relative to a contact date field (needs trigger_config.field). 'contact_event' fires relative to a contact event (needs trigger_config.event_type and days_after). null or 'manual' clears a date_field or contact_event trigger."),
3301
3360
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
3302
3361
  type: z.enum(['sequence', 'triggered']).optional().describe("Switch a DRAFT campaign between 'sequence' and 'triggered'. Event and tag triggers only start campaigns of type 'triggered'."),
3303
3362
  sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
@@ -3309,12 +3368,21 @@ Return ONLY valid JSON with this exact shape:
3309
3368
  let campaignType;
3310
3369
  const loadExisting = () => db()
3311
3370
  .from('campaigns')
3312
- .select('id, type, status')
3371
+ .select('id, type, status, trigger_type, trigger_config, trigger_event_name, trigger_tag')
3313
3372
  .eq('id', campaign_id)
3314
3373
  .eq('project_id', projectId)
3315
3374
  .maybeSingle();
3375
+ let cachedExisting = null;
3376
+ const getExisting = async () => {
3377
+ if (cachedExisting)
3378
+ return { data: cachedExisting, error: null };
3379
+ const { data, error } = await loadExisting();
3380
+ if (!error && data)
3381
+ cachedExisting = data;
3382
+ return { data, error };
3383
+ };
3316
3384
  if (send_at !== undefined && send_at !== null) {
3317
- const { data: existing, error: fetchError } = await loadExisting();
3385
+ const { data: existing, error: fetchError } = await getExisting();
3318
3386
  if (fetchError)
3319
3387
  return j({ error: fetchError.message });
3320
3388
  if (!existing)
@@ -3322,7 +3390,7 @@ Return ONLY valid JSON with this exact shape:
3322
3390
  campaignType = existing.type;
3323
3391
  }
3324
3392
  if (type !== undefined) {
3325
- const { data: existing, error: fetchError } = await loadExisting();
3393
+ const { data: existing, error: fetchError } = await getExisting();
3326
3394
  if (fetchError)
3327
3395
  return j({ error: fetchError.message });
3328
3396
  if (!existing)
@@ -3334,6 +3402,16 @@ Return ONLY valid JSON with this exact shape:
3334
3402
  return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'INVALID_FIELD' });
3335
3403
  }
3336
3404
  }
3405
+ const hasTriggerUpdate = trigger_event_name !== undefined || trigger_tag !== undefined || trigger_type !== undefined || trigger_config !== undefined;
3406
+ let existingCampaignForTargeting = null;
3407
+ if (hasTriggerUpdate) {
3408
+ const { data: existing, error: fetchError } = await getExisting();
3409
+ if (fetchError)
3410
+ return j({ error: fetchError.message });
3411
+ if (!existing)
3412
+ return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3413
+ existingCampaignForTargeting = existing;
3414
+ }
3337
3415
  const updates = { updated_at: new Date().toISOString() };
3338
3416
  if (name !== undefined)
3339
3417
  updates.name = name;
@@ -3352,9 +3430,19 @@ Return ONLY valid JSON with this exact shape:
3352
3430
  updates.send_in_contact_timezone = send_in_contact_timezone;
3353
3431
  if (send_class !== undefined)
3354
3432
  updates.send_class = send_class;
3355
- const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
3433
+ const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId, existingCampaignForTargeting);
3356
3434
  if (targetingError)
3357
3435
  return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
3436
+ // If type was not explicitly changed, but this update sets an event trigger on a draft sequence,
3437
+ // normalize type to 'triggered' so that event intake matches and enrolls it (#3852).
3438
+ if (type === undefined && existingCampaignForTargeting?.type === 'sequence' && existingCampaignForTargeting?.status === 'draft') {
3439
+ const isSettingEvent = (updates.trigger_event_name !== undefined && updates.trigger_event_name !== null)
3440
+ || (updates.trigger_tag !== undefined && updates.trigger_tag !== null)
3441
+ || (updates.trigger_config && typeof updates.trigger_config === 'object' && Array.isArray(updates.trigger_config.events) && updates.trigger_config.events.length > 0);
3442
+ if (isSettingEvent) {
3443
+ updates.type = 'triggered';
3444
+ }
3445
+ }
3358
3446
  const { data, error } = await db()
3359
3447
  .from('campaigns')
3360
3448
  .update(updates)
@@ -3364,7 +3452,15 @@ Return ONLY valid JSON with this exact shape:
3364
3452
  .single();
3365
3453
  if (error)
3366
3454
  return j({ error: error.message });
3367
- return j({ ...data, fires_on: buildCampaignFiresOn(data) });
3455
+ const diagnostics = buildCampaignTriggerDiagnostics(data);
3456
+ const warning = diagnostics.find((d) => d.includes('never fire')) || (diagnostics.length ? diagnostics[0] : null);
3457
+ return j({
3458
+ ...data,
3459
+ fires_on: buildCampaignFiresOn(data),
3460
+ trigger_diagnostics: diagnostics,
3461
+ warnings: diagnostics,
3462
+ ...(warning ? { warning } : {}),
3463
+ });
3368
3464
  });
3369
3465
  server.registerTool('get_campaign', {
3370
3466
  title: 'Get Campaign',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.70",
3
+ "version": "1.0.71",
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",