@sendinel/mcp-server 1.0.32 → 1.0.34

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/README.md CHANGED
@@ -12,7 +12,7 @@ AI-controlled email operations control plane for campaigns, contacts, segments,
12
12
 
13
13
  ## Tool groups
14
14
 
15
- Sendinel currently exposes 34 tool groups and 316 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
15
+ Sendinel currently exposes 34 tool groups and 318 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
16
16
 
17
17
  | Group | Count | Purpose |
18
18
  |-------|-------|---------|
@@ -23,7 +23,7 @@ Sendinel currently exposes 34 tool groups and 316 tools (gateway mode adds 3 met
23
23
  | `approvals` | 2 | Poll and list risk-tiered action approvals |
24
24
  | `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
25
25
  | `brand_kit` | 5 | Brand Core text import, media library upload/URL ingest, asset-context reads |
26
- | `campaigns` | 40 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates |
26
+ | `campaigns` | 42 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates |
27
27
  | `cloud-import` | 6 | Cloud storage import |
28
28
  | `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
29
29
  | `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
@@ -17,6 +17,7 @@ const GATED_ACTIONS = new Set([
17
17
  'publish_dmarc_record', // writes a DNS record into a customer-owned Cloudflare zone
18
18
  'broadcast_to_segment', // creates + activates a real broadcast
19
19
  'send_broadcast', // one-call broadcast; executes a frozen, approved payload (#3669)
20
+ 'activate_campaigns', // turns on a set of campaigns behind one approval (#3669)
20
21
  'rotate_custom_webhook_secret', // immediately invalidates an inbound signing secret
21
22
  'create_campaign_with_content', // can activate + send in one call
22
23
  'enroll_segment', // bulk enrollment
@@ -52,7 +53,7 @@ export function getApprovalTier(action, params) {
52
53
  }
53
54
  // send_broadcast exists for agents and always needs a person's approval, even in autonomous
54
55
  // mode: it must never approve itself (#3669, AI-worker boundary in CLAUDE.md).
55
- if (action === 'send_broadcast')
56
+ if (action === 'send_broadcast' || action === 'activate_campaigns')
56
57
  return 'gated';
57
58
  // Autonomous mode: user explicitly opted out of approval gates for gated actions
58
59
  // (request-scoped under HTTP — env is only trustworthy for single-project stdio)
@@ -168,6 +169,12 @@ export function buildApprovalSummary(action, params, display = {}) {
168
169
  ...display,
169
170
  }) ?? `Send a broadcast — will send emails to real contacts`;
170
171
  },
172
+ activate_campaigns: (p) => {
173
+ const count = typeof display.campaign_count === 'number' ? display.campaign_count : p.campaign_ids?.length ?? 0;
174
+ const emails = typeof display.email_count === 'number' ? ` (${display.email_count} email${display.email_count === 1 ? '' : 's'})` : '';
175
+ const names = display.campaign_names ? `: ${display.campaign_names}` : '';
176
+ return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires`;
177
+ },
171
178
  update_campaign_status: (p) => (p.status === 'active'
172
179
  ? `Activate campaign "${display.campaign_name ?? p.campaign_id}"${typeof display.enrolled === 'number' ? ` — sends email to ${display.enrolled.toLocaleString('en-US')} enrolled contact${display.enrolled === 1 ? '' : 's'}` : ' — will send emails to real contacts'}${display.trigger ? ` and to new contacts on "${display.trigger}"` : ''}`
173
180
  : `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
package/build/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { runWithToolContext } from './db.js';
2
2
  import { registerAnalyticsTools } from './tools/analytics.js';
3
3
  import { registerCampaignTools } from './tools/campaigns.js';
4
+ import { registerCampaignSetTools } from './tools/campaign-sets.js';
4
5
  import { registerContactTools } from './tools/contacts.js';
5
6
  import { registerDraftTools } from './tools/drafts.js';
6
7
  import { registerGdprTools } from './tools/gdpr.js';
@@ -36,7 +37,8 @@ import { registerConnectorTools } from './tools/connector.js';
36
37
  import { registerCloudImportTools } from './tools/cloud-import.js';
37
38
  const GROUP_REGISTRY = {
38
39
  analytics: registerAnalyticsTools,
39
- campaigns: registerCampaignTools,
40
+ // Campaign sets (#3669) live in their own module but belong to the campaigns group.
41
+ campaigns: (server) => { registerCampaignTools(server); registerCampaignSetTools(server); },
40
42
  contacts: registerContactTools,
41
43
  segments: registerSegmentTools,
42
44
  lists: registerListTools,
@@ -83,7 +85,7 @@ const WRITE_TOOLS = new Set([
83
85
  'add_campaign_branch', 'remove_campaign_branch', 'set_campaign_goal', 'remove_campaign_goal',
84
86
  'review_campaign_portfolio',
85
87
  'list_campaign_steps', 'update_campaign_step', 'delete_campaign_step',
86
- 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'send_broadcast', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
88
+ 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'send_broadcast', 'create_campaign_set', 'activate_campaigns', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
87
89
  'create_social_campaign', 'schedule_social_post',
88
90
  'create_social_post', 'update_social_post', 'delete_social_post',
89
91
  'create_draft', 'approve_draft', 'reject_draft',
@@ -0,0 +1,24 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ type CampaignSetRow = {
3
+ id: string;
4
+ name: string;
5
+ status: string;
6
+ trigger_event_name: string | null;
7
+ trigger_tag: string | null;
8
+ send_class: string | null;
9
+ site_id: string;
10
+ };
11
+ type StepRow = {
12
+ campaign_id: string;
13
+ step_order: number;
14
+ variant_label: string | null;
15
+ subject: string;
16
+ body_html: string | null;
17
+ body_text: string | null;
18
+ delay_days: number | null;
19
+ delay_hours: number | null;
20
+ };
21
+ /** Fingerprint of everything an approver saw: triggers and every email's exact content. */
22
+ export declare function campaignSetContentHash(campaigns: CampaignSetRow[], steps: StepRow[]): string;
23
+ export declare function registerCampaignSetTools(server: McpServer): void;
24
+ export {};
@@ -0,0 +1,336 @@
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
+ // An imported email whose wait wasn't in the source has no delay; it would never send (#3689).
311
+ const waitNotSet = set.steps.filter((s) => s.delay_days == null && s.delay_hours == null);
312
+ if (waitNotSet.length) {
313
+ return j({
314
+ error: 'Some emails have no wait set. Set how long to wait before each one, then try again.',
315
+ code: 'STEP_WAIT_NOT_SET',
316
+ emails: waitNotSet.map((s) => ({ campaign: set.campaigns.find((c) => c.id === s.campaign_id)?.name ?? s.campaign_id, email: s.step_order + 1, subject: s.subject })),
317
+ });
318
+ }
319
+ const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
320
+ const channelContext = currentChannelContext();
321
+ const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
322
+ if (!decision.allowed)
323
+ return txt(decision.reason ?? 'This channel cannot request this action.');
324
+ const names = set.campaigns.map((c) => c.name);
325
+ const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
326
+ campaign_count: set.campaigns.length,
327
+ email_count: set.steps.length,
328
+ campaign_names: names.slice(0, 10).join(', ') + (names.length > 10 ? `, and ${names.length - 10} more` : ''),
329
+ });
330
+ return j({
331
+ ...approval,
332
+ campaigns: previewOf(set.campaigns, set.steps),
333
+ next: 'Show the person the campaigns above and approve_url. After approval, call activate_campaigns with only approval_id.',
334
+ });
335
+ });
336
+ }
@@ -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;
@@ -69,7 +69,7 @@ function applyCampaignSendAtField(updates, sendAt, campaignType) {
69
69
  updates.send_at = parsed.scheduledAt;
70
70
  return null;
71
71
  }
72
- async function applyCampaignTargetingFields(updates, input, projectId) {
72
+ export async function applyCampaignTargetingFields(updates, input, projectId) {
73
73
  if (input.trigger_event_name !== undefined)
74
74
  updates.trigger_event_name = input.trigger_event_name?.trim() || null;
75
75
  if (input.trigger_tag !== undefined)
@@ -595,6 +595,22 @@ export function registerCampaignTools(server) {
595
595
  }, async ({ campaign_id, status, approval_id }) => {
596
596
  // Human approval required for activation
597
597
  if (status === 'active') {
598
+ // An imported email whose wait wasn't in the source has no delay; it would never send (#3689).
599
+ const { data: unsetSteps, error: stepsErr } = await db()
600
+ .from('campaign_steps')
601
+ .select('step_order, subject')
602
+ .eq('campaign_id', campaign_id)
603
+ .is('delay_days', null)
604
+ .is('delay_hours', null);
605
+ if (stepsErr)
606
+ return j({ error: `Could not check the campaign's emails: ${stepsErr.message}`, code: 'STEP_LOOKUP_FAILED' });
607
+ if (unsetSteps?.length) {
608
+ return j({
609
+ error: 'Some emails have no wait set. Set how long to wait before each one, then try again.',
610
+ code: 'STEP_WAIT_NOT_SET',
611
+ emails: unsetSteps.map((s) => ({ email: s.step_order + 1, subject: s.subject })),
612
+ });
613
+ }
598
614
  const approvalParams = { campaign_id, status };
599
615
  if (!approval_id) {
600
616
  const channelContext = currentChannelContext();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
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",