@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.
- package/build/tools/campaigns.d.ts +5 -1
- package/build/tools/campaigns.js +105 -9
- package/package.json +1 -1
|
@@ -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;
|
package/build/tools/campaigns.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|