@sendinel/mcp-server 1.0.31 → 1.0.33

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.
@@ -0,0 +1,327 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import Anthropic from '@anthropic-ai/sdk';
4
+ import { db } from '../db.js';
5
+ import { getProjectId } from '../project.js';
6
+ import { checkPlanLimit } from '../plan-limits.js';
7
+ import { validateTemplate } from '../lib/voice.js';
8
+ import { approvalExcerpt, assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
9
+ import { generateEmailFromBrief } from '../lib/email-generation.js';
10
+ import { fireWebhookEvent } from '../lib/webhook-events.js';
11
+ import { logAudit } from '../audit.js';
12
+ import { applyCampaignTargetingFields } from './campaigns.js';
13
+ /**
14
+ * Campaign sets (#3669): build a whole set of triggered campaigns as drafts in one call, then
15
+ * activate the set behind one human approval. Built for Cyber Wingman's lifecycle emails
16
+ * (trial and onboarding events), generic for any project.
17
+ *
18
+ * - create_campaign_set writes drafts only. Drafts send nothing, so it needs no approval.
19
+ * - activate_campaigns asks for one approval covering every campaign's exact content, and the
20
+ * approval_id-only call refuses if anything changed after approval.
21
+ */
22
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
23
+ const j = (data) => txt(JSON.stringify(data, null, 2));
24
+ const EmailInput = z.object({
25
+ subject: z.string().min(1).max(300).optional().describe('Subject line. Required with body_html; overrides the drafted subject with brief.'),
26
+ body_html: z.string().min(1).optional().describe('Finished HTML. Use this or brief.'),
27
+ brief: z.string().min(10).max(4000).optional().describe('What this email should say; drafted in the brand voice. Use this or body_html.'),
28
+ body_text: z.string().optional(),
29
+ delay_days: z.number().int().min(0).max(365).optional().default(0).describe('Wait after the previous email (or after the trigger, for the first).'),
30
+ delay_hours: z.number().int().min(0).max(23).optional().default(0),
31
+ });
32
+ const CampaignInput = z.object({
33
+ name: z.string().min(1).max(200),
34
+ trigger_event_name: z.string().min(1).max(120).optional().describe('Event name sent to POST /v1/events that starts this campaign for a contact.'),
35
+ trigger_tag: z.string().min(1).max(120).optional().describe('Or: a tag that starts it when added to a contact.'),
36
+ send_class: z.enum(['broadcast', 'product']).optional().describe('product for account, trial and onboarding email; broadcast for marketing.'),
37
+ description: z.string().max(1000).optional(),
38
+ emails: z.array(EmailInput).min(1).max(10),
39
+ });
40
+ /** Fingerprint of everything an approver saw: triggers and every email's exact content. */
41
+ export function campaignSetContentHash(campaigns, steps) {
42
+ const ordered = [...campaigns].sort((a, b) => a.id.localeCompare(b.id)).map((c) => ({
43
+ id: c.id,
44
+ trigger_event_name: c.trigger_event_name,
45
+ trigger_tag: c.trigger_tag,
46
+ send_class: c.send_class,
47
+ site_id: c.site_id,
48
+ steps: steps
49
+ .filter((s) => s.campaign_id === c.id)
50
+ .sort((a, b) => a.step_order - b.step_order || String(a.variant_label).localeCompare(String(b.variant_label)))
51
+ .map((s) => [s.step_order, s.variant_label ?? 'A', s.subject, s.body_html ?? '', s.body_text ?? '', s.delay_days ?? 0, s.delay_hours ?? 0]),
52
+ }));
53
+ return createHash('sha256').update(JSON.stringify(ordered)).digest('hex');
54
+ }
55
+ function previewOf(campaigns, steps) {
56
+ return campaigns.map((c) => ({
57
+ campaign_id: c.id,
58
+ name: c.name,
59
+ status: c.status,
60
+ trigger: c.trigger_event_name ? { event: c.trigger_event_name } : c.trigger_tag ? { tag: c.trigger_tag } : null,
61
+ send_class: c.send_class,
62
+ emails: steps
63
+ .filter((s) => s.campaign_id === c.id)
64
+ .sort((a, b) => a.step_order - b.step_order)
65
+ .map((s) => ({
66
+ step_order: s.step_order,
67
+ wait: `${s.delay_days ?? 0}d ${s.delay_hours ?? 0}h`,
68
+ subject: s.subject,
69
+ body_text: s.body_text ?? approvalExcerpt(s.body_html, 100000),
70
+ })),
71
+ }));
72
+ }
73
+ async function loadSet(projectId, campaignIds) {
74
+ const { data: campaigns, error } = await db()
75
+ .from('campaigns')
76
+ .select('id, name, status, trigger_event_name, trigger_tag, send_class, site_id')
77
+ .eq('project_id', projectId)
78
+ .in('id', campaignIds);
79
+ if (error)
80
+ throw error;
81
+ const { data: steps, error: stepsError } = await db()
82
+ .from('campaign_steps')
83
+ .select('campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours')
84
+ .in('campaign_id', campaignIds);
85
+ if (stepsError)
86
+ throw stepsError;
87
+ return { campaigns: (campaigns ?? []), steps: (steps ?? []) };
88
+ }
89
+ export function registerCampaignSetTools(server) {
90
+ server.registerTool('create_campaign_set', {
91
+ title: 'Create Campaign Set',
92
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
93
+ description: 'Create several triggered campaigns (for example trial and onboarding emails) as drafts in one call, and get back a preview of every campaign: its trigger, and each email\'s subject and text. Drafts send nothing. Show the preview to a person, then use activate_campaigns to turn the set on behind one approval. Emails can use {{first_name}} and event data as {{ctx.field}}.',
94
+ inputSchema: {
95
+ campaigns: z.array(CampaignInput).min(1).max(20),
96
+ site_id: z.string().uuid().optional().describe('Sender. Optional when the project has exactly one sender.'),
97
+ },
98
+ }, async ({ campaigns, site_id }) => {
99
+ const projectId = getProjectId();
100
+ // Sender: the given one, or the only one. Never guessed between several.
101
+ const { data: sites, error: sitesErr } = await db().from('sites').select('id, name, from_email, unsub_link_enabled').eq('project_id', projectId);
102
+ if (sitesErr)
103
+ return j({ error: sitesErr.message, code: 'SITE_LOOKUP_FAILED' });
104
+ const site = site_id ? (sites ?? []).find((s) => s.id === site_id) : (sites?.length === 1 ? sites[0] : null);
105
+ if (!site) {
106
+ return j({
107
+ error: site_id ? 'Site not found in this project.' : 'This project has more than one sender. Pass site_id.',
108
+ code: site_id ? 'SITE_NOT_FOUND' : 'SITE_REQUIRED',
109
+ sites: (sites ?? []).map((s) => ({ id: s.id, name: s.name, from_email: s.from_email })),
110
+ });
111
+ }
112
+ // Check everything before writing anything: one trigger per campaign, content per email.
113
+ const problems = [];
114
+ campaigns.forEach((c, i) => {
115
+ if (Boolean(c.trigger_event_name) === Boolean(c.trigger_tag))
116
+ problems.push(`campaigns[${i}] "${c.name}": give exactly one of trigger_event_name or trigger_tag.`);
117
+ c.emails.forEach((e, k) => {
118
+ if (Boolean(e.brief) === Boolean(e.body_html))
119
+ problems.push(`campaigns[${i}].emails[${k}]: give exactly one of brief or body_html.`);
120
+ if (e.body_html && !e.subject)
121
+ problems.push(`campaigns[${i}].emails[${k}]: subject is required with body_html.`);
122
+ });
123
+ });
124
+ if (problems.length)
125
+ return j({ error: 'Nothing was created. Fix these first.', code: 'INVALID_SET', problems });
126
+ const limit = await checkPlanLimit('active_campaigns');
127
+ if (!limit.allowed) {
128
+ return j({ error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`, code: 'PLAN_LIMIT' });
129
+ }
130
+ // Draft any briefs, then validate every email.
131
+ const needsAi = campaigns.some((c) => c.emails.some((e) => e.brief));
132
+ const apiKey = process.env.ANTHROPIC_API_KEY;
133
+ if (needsAi && !apiKey)
134
+ return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_NOT_CONFIGURED' });
135
+ const anthropic = needsAi ? new Anthropic({ apiKey: apiKey }) : null;
136
+ const footerAdded = site.unsub_link_enabled !== false;
137
+ const drafted = [];
138
+ for (let i = 0; i < campaigns.length; i++) {
139
+ const out = [];
140
+ for (let k = 0; k < campaigns[i].emails.length; k++) {
141
+ const e = campaigns[i].emails[k];
142
+ let email;
143
+ if (e.brief) {
144
+ try {
145
+ const g = await generateEmailFromBrief(anthropic, projectId, site.id, e.brief);
146
+ email = { subject: e.subject ?? g.subject, body_html: g.body_html, body_text: g.body_text, blocks: g.blocks, delay_days: e.delay_days ?? 0, delay_hours: e.delay_hours ?? 0 };
147
+ }
148
+ catch (error) {
149
+ return j({ error: `Nothing was created. Drafting campaigns[${i}].emails[${k}] failed: ${error instanceof Error ? error.message : String(error)}`, code: 'DRAFT_FAILED' });
150
+ }
151
+ }
152
+ else {
153
+ email = { subject: e.subject, body_html: e.body_html, body_text: e.body_text ?? null, blocks: null, delay_days: e.delay_days ?? 0, delay_hours: e.delay_hours ?? 0 };
154
+ }
155
+ const check = validateTemplate(email.body_html, null, { subject: email.subject, bodyText: email.body_text ?? undefined });
156
+ // The send step appends the unsubscribe footer for this sender, so a body without a link is fine.
157
+ const blocking = check.errors.filter((msg) => !(footerAdded && msg.startsWith('Missing unsubscribe link')));
158
+ if (blocking.length)
159
+ problems.push(...blocking.map((msg) => `campaigns[${i}].emails[${k}] "${email.subject}": ${msg}`));
160
+ out.push(email);
161
+ }
162
+ drafted.push(out);
163
+ }
164
+ if (problems.length)
165
+ return j({ error: 'Nothing was created. Fix these first.', code: 'INVALID_SET', problems });
166
+ // Write the drafts.
167
+ const setId = randomUUID();
168
+ const createdIds = [];
169
+ for (let i = 0; i < campaigns.length; i++) {
170
+ const c = campaigns[i];
171
+ const insertPayload = {
172
+ project_id: projectId,
173
+ site_id: site.id,
174
+ name: c.name,
175
+ type: 'triggered',
176
+ status: 'draft',
177
+ description: c.description ?? null,
178
+ requires_approval: true,
179
+ ...(c.send_class ? { send_class: c.send_class } : {}),
180
+ metadata: { source: 'mcp_create_campaign_set', campaign_set_id: setId },
181
+ };
182
+ const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name: c.trigger_event_name ?? null, trigger_tag: c.trigger_tag ?? null }, projectId);
183
+ if (targetingError)
184
+ return j({ error: targetingError, code: 'INVALID_FIELD', campaign: c.name, created_before_failure: createdIds });
185
+ const { data: campaign, error } = await db().from('campaigns').insert(insertPayload).select('id').single();
186
+ if (error || !campaign)
187
+ return j({ error: error?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds });
188
+ createdIds.push(campaign.id);
189
+ const rows = drafted[i].map((e, k) => ({
190
+ campaign_id: campaign.id,
191
+ step_order: k,
192
+ subject: e.subject,
193
+ body_html: e.body_html,
194
+ body_text: e.body_text,
195
+ blocks: e.blocks,
196
+ delay_days: e.delay_days,
197
+ delay_hours: e.delay_hours,
198
+ variant_label: 'A',
199
+ }));
200
+ const { error: stepError } = await db().from('campaign_steps').insert(rows);
201
+ if (stepError)
202
+ return j({ error: stepError.message, code: 'STEP_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds });
203
+ }
204
+ await logAudit({ action: 'campaign.set_created', resourceType: 'campaign_set', resourceId: setId, details: { campaign_ids: createdIds, site_id: site.id, source: 'mcp' } }).catch(() => { });
205
+ let preview;
206
+ try {
207
+ const loaded = await loadSet(projectId, createdIds);
208
+ preview = previewOf(loaded.campaigns, loaded.steps);
209
+ }
210
+ catch (error) {
211
+ return j({ campaign_set_id: setId, campaign_ids: createdIds, status: 'draft', warning: `Created, but the preview could not be read back: ${error instanceof Error ? error.message : String(error)}` });
212
+ }
213
+ return j({
214
+ campaign_set_id: setId,
215
+ campaign_ids: createdIds,
216
+ status: 'draft',
217
+ site: { id: site.id, name: site.name, from_email: site.from_email },
218
+ campaigns: preview,
219
+ next: 'These are drafts and send nothing. Show the person every subject and text exactly as above. To turn them on, call activate_campaigns with campaign_set_id (or campaign_ids); it returns one approval for the whole set.',
220
+ });
221
+ });
222
+ server.registerTool('activate_campaigns', {
223
+ title: 'Activate Campaigns',
224
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
225
+ description: 'Turn on a set of draft or paused campaigns behind one human approval. Call 1: give campaign_set_id or campaign_ids; it returns one approval_id with a summary of every campaign, and activates nothing. After a person approves (approve_url works on a phone), call again with only approval_id. It refuses if any campaign or email changed after approval.',
226
+ inputSchema: {
227
+ campaign_set_id: z.string().uuid().optional(),
228
+ campaign_ids: z.array(z.string().uuid()).min(1).max(50).optional(),
229
+ approval_id: z.string().uuid().optional().describe('Call 2 only: the approved approval_id. Pass nothing else.'),
230
+ },
231
+ }, async (args) => {
232
+ const projectId = getProjectId();
233
+ const { approval_id } = args;
234
+ if (approval_id) {
235
+ const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
236
+ if (extras.length)
237
+ return j({ error: `Call activate_campaigns with only approval_id. Remove: ${extras.join(', ')}.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
238
+ const { data: row, error: rowErr } = await db().from('action_approvals').select('params').eq('id', approval_id).eq('project_id', projectId).eq('action', 'activate_campaigns').maybeSingle();
239
+ if (rowErr)
240
+ return j({ error: rowErr.message, code: 'APPROVAL_LOOKUP_FAILED' });
241
+ if (!row)
242
+ return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
243
+ const stored = (row.params ?? {});
244
+ const ids = Array.isArray(stored.campaign_ids) ? stored.campaign_ids : [];
245
+ // Check before consuming: what is live now must be exactly what was approved.
246
+ let current;
247
+ try {
248
+ current = await loadSet(projectId, ids);
249
+ }
250
+ catch (error) {
251
+ return j({ error: `Could not load the campaigns: ${error instanceof Error ? error.message : String(error)}`, code: 'SET_LOOKUP_FAILED' });
252
+ }
253
+ if (current.campaigns.length !== ids.length || campaignSetContentHash(current.campaigns, current.steps) !== stored.content_hash) {
254
+ return j({ error: 'A campaign or email changed (or was deleted) after approval. Nothing was activated. Request a new approval.', code: 'CONTENT_CHANGED_SINCE_APPROVAL' });
255
+ }
256
+ const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
257
+ if (!check.approved)
258
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
259
+ const activated = [];
260
+ const failed = [];
261
+ for (const c of current.campaigns) {
262
+ const { error } = await db()
263
+ .from('campaigns')
264
+ .update({ status: 'active', updated_at: new Date().toISOString() })
265
+ .eq('id', c.id)
266
+ .eq('project_id', projectId)
267
+ .in('status', ['draft', 'paused']);
268
+ if (error) {
269
+ failed.push({ campaign_id: c.id, error: error.message });
270
+ continue;
271
+ }
272
+ activated.push(c.id);
273
+ fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
274
+ }
275
+ await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: check.decided_by ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
276
+ return j({
277
+ activated,
278
+ failed,
279
+ message: failed.length
280
+ ? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
281
+ : `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
282
+ });
283
+ }
284
+ // Call 1: build one approval for the exact set.
285
+ let ids = args.campaign_ids ?? [];
286
+ if (args.campaign_set_id) {
287
+ const { data, error } = await db().from('campaigns').select('id').eq('project_id', projectId).eq('metadata->>campaign_set_id', args.campaign_set_id);
288
+ if (error)
289
+ return j({ error: error.message, code: 'SET_LOOKUP_FAILED' });
290
+ ids = [...new Set([...ids, ...(data ?? []).map((r) => r.id)])];
291
+ }
292
+ if (!ids.length)
293
+ return j({ error: 'Give campaign_set_id or campaign_ids.', code: 'CAMPAIGNS_REQUIRED' });
294
+ let set;
295
+ try {
296
+ set = await loadSet(projectId, ids);
297
+ }
298
+ catch (error) {
299
+ return j({ error: `Could not load the campaigns: ${error instanceof Error ? error.message : String(error)}`, code: 'SET_LOOKUP_FAILED' });
300
+ }
301
+ const missing = ids.filter((id) => !set.campaigns.some((c) => c.id === id));
302
+ if (missing.length)
303
+ return j({ error: 'Some campaigns were not found in this project.', code: 'NOT_FOUND', missing });
304
+ const notReady = set.campaigns.filter((c) => !['draft', 'paused'].includes(c.status));
305
+ if (notReady.length)
306
+ return j({ error: 'Only draft or paused campaigns can be activated here.', code: 'NOT_ACTIVATABLE', campaigns: notReady.map((c) => ({ id: c.id, name: c.name, status: c.status })) });
307
+ const empty = set.campaigns.filter((c) => !set.steps.some((s) => s.campaign_id === c.id));
308
+ if (empty.length)
309
+ return j({ error: 'Some campaigns have no emails yet.', code: 'NO_EMAILS', campaigns: empty.map((c) => ({ id: c.id, name: c.name })) });
310
+ const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
311
+ const channelContext = currentChannelContext();
312
+ const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
313
+ if (!decision.allowed)
314
+ return txt(decision.reason ?? 'This channel cannot request this action.');
315
+ const names = set.campaigns.map((c) => c.name);
316
+ const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
317
+ campaign_count: set.campaigns.length,
318
+ email_count: set.steps.length,
319
+ campaign_names: names.slice(0, 10).join(', ') + (names.length > 10 ? `, and ${names.length - 10} more` : ''),
320
+ });
321
+ return j({
322
+ ...approval,
323
+ campaigns: previewOf(set.campaigns, set.steps),
324
+ next: 'Show the person the campaigns above and approve_url. After approval, call activate_campaigns with only approval_id.',
325
+ });
326
+ });
327
+ }
@@ -1,2 +1,9 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
3
+ trigger_event_name?: string | null;
4
+ trigger_tag?: string | null;
5
+ trigger_type?: string | null;
6
+ trigger_config?: Record<string, unknown> | null;
7
+ segment_id?: string | null;
8
+ }, projectId: string): Promise<string | null>;
2
9
  export declare function registerCampaignTools(server: McpServer): void;