@sendinel/mcp-server 1.0.103 → 1.0.104

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,4 +1,4 @@
1
- import { decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
1
+ import { triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
2
2
  import { db, requestContext } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  import { fireWebhookEvent } from './webhook-events.js';
@@ -28,6 +28,7 @@ const GATED_ACTIONS = new Set([
28
28
  'import_subscribers', // bulk data ingestion
29
29
  'subscribe_webhook', // outbound data channel
30
30
  'send_transactional', // sends a real email
31
+ 'update_campaign', // live trigger edits only; the domain rule gates callers
31
32
  'set_campaign_trigger', // changes who starts an active or paused campaign
32
33
  'update_campaign_status', // activation sends real emails (see special case below)
33
34
  'add_list_members', // bulk contact-list membership write
@@ -230,6 +231,8 @@ function launchSummary(p, d) {
230
231
  }
231
232
  /** Build a human-readable summary of the action for the approval screen */
232
233
  export function buildApprovalSummary(action, params, display = {}) {
234
+ if ((action === 'update_campaign' || action === 'set_campaign_trigger') && params.previous_campaign && params.updates)
235
+ return triggerChangeSummary(String(params.previous_campaign.name ?? params.campaign_id), params.previous_campaign, params.updates);
233
236
  const summaries = {
234
237
  launch_campaign: (p) => launchSummary(p, display) ?? `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
235
238
  install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
@@ -116,7 +116,6 @@ export const APPROVAL_EXEMPT = {
116
116
  trigger_automation: 'generates a draft into the approval queue (or returns it with dry_run); nothing is sent until a person approves the draft',
117
117
  unsubscribe_subscriber: "only stops mail to a contact; reduces sending",
118
118
  update_brand_voice: "edits brand voice text on the caller own project; internal",
119
- update_campaign: "edits campaign settings; status changes go through update_campaign_status (gated). FOLLOW-UP: same live-edit caveat as update_campaign_step",
120
119
  update_campaign_step: "edits email copy; sends only through an already-gated activation. FOLLOW-UP: editing a live campaign changes what goes out (#4005 summary)",
121
120
  update_custom_field: "renames or edits a field definition; values are untouched",
122
121
  update_hygiene_config: "changes hygiene settings; suppression runs are gated (identify_inactive) or only reduce sending",
@@ -0,0 +1,10 @@
1
+ type Row = Record<string, any>;
2
+ export declare const TRIGGER_EDIT_SELECT = "id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at";
3
+ export declare const campaignEditError: (error: string, code: string) => {
4
+ error: string;
5
+ code: string;
6
+ };
7
+ export declare function finishCampaignEdit(action: string, input: Row, existing: Row, updates: Row, request: (params: Row) => Promise<Row>): Promise<any>;
8
+ /** The second call may carry only approval_id. Never rebuild a frozen edit from fresh arguments. */
9
+ export declare function resumeCampaignEdit(action: string, approvalId: string, input: Row): Promise<any>;
10
+ export {};
@@ -0,0 +1,66 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { logAudit } from '../audit.js';
6
+ import { assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
7
+ export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at';
8
+ export const campaignEditError = (error, code) => ({ error, code });
9
+ /** All writes include the revision used for the approval decision, including ungated drafts. */
10
+ async function writeEdit(campaignId, previous, updates) {
11
+ let query = db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
12
+ .eq('id', campaignId).eq('project_id', getProjectId()).eq('status', previous.status);
13
+ if (previous.updated_at)
14
+ query = query.eq('updated_at', previous.updated_at);
15
+ const { data, error } = await query.select().single();
16
+ if (error || !data)
17
+ return campaignEditError(error?.message ?? 'Campaign changed; read it and request a new approval', 'CAMPAIGN_CHANGED');
18
+ await logAudit({ action: 'campaign.updated', resourceType: 'campaign', resourceId: campaignId, details: { previous, updates } });
19
+ return data;
20
+ }
21
+ export async function finishCampaignEdit(action, input, existing, updates, request) {
22
+ if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
23
+ return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
24
+ }
25
+ if (!triggerChangeNeedsApproval(existing, updates))
26
+ return writeEdit(input.campaign_id, existing, updates);
27
+ const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
28
+ const context = currentChannelContext();
29
+ const decision = assertChannelCanExecute(action, params, context);
30
+ if (!decision.allowed)
31
+ return campaignEditError(decision.reason ?? 'Channel cannot request approval', 'FORBIDDEN');
32
+ // This trigger policy requires a human decision even if the integration uses autonomous mode.
33
+ return request(params);
34
+ }
35
+ /** The second call may carry only approval_id. Never rebuild a frozen edit from fresh arguments. */
36
+ export async function resumeCampaignEdit(action, approvalId, input) {
37
+ const { data: approval, error } = await db().from('action_approvals').select('*')
38
+ .eq('id', approvalId).eq('project_id', getProjectId()).maybeSingle();
39
+ if (error || !approval || approval.action !== action)
40
+ return campaignEditError('Approval not found for this action', 'APPROVAL_MISMATCH');
41
+ if (approval.used_at || approval.status === 'used')
42
+ return campaignEditError('Approval consumed', 'APPROVAL_CONSUMED');
43
+ const params = approval.params;
44
+ if (!params?.previous_campaign || !params.updates || !params.campaign_id)
45
+ return campaignEditError('Approval has no frozen campaign edit', 'APPROVAL_MISMATCH');
46
+ for (const [key, value] of Object.entries(input)) {
47
+ if (value !== undefined && !isDeepStrictEqual(value, params.input?.[key]))
48
+ return campaignEditError('params_mismatch: request differs from the frozen edit', 'APPROVAL_MISMATCH');
49
+ }
50
+ const { data: existing, error: loadError } = await db().from('campaigns').select(TRIGGER_EDIT_SELECT)
51
+ .eq('id', params.campaign_id).eq('project_id', getProjectId()).maybeSingle();
52
+ if (loadError || !existing)
53
+ return campaignEditError('Campaign not found', 'NOT_FOUND');
54
+ if (campaignTriggerRevision(existing) !== campaignTriggerRevision(params.previous_campaign)) {
55
+ return campaignEditError('Campaign changed after approval was requested; request a new approval', 'CAMPAIGN_CHANGED');
56
+ }
57
+ if (!Number.isFinite(Date.parse(approval.expires_at)) || Date.parse(approval.expires_at) <= Date.now())
58
+ return campaignEditError('Approval expired', 'APPROVAL_EXPIRED');
59
+ const decision = assertChannelCanExecute(action, params, currentChannelContext());
60
+ if (!decision.allowed)
61
+ return campaignEditError(decision.reason ?? 'Channel cannot use approval', 'FORBIDDEN');
62
+ const check = await checkApproval(approvalId, { consume: true, action, params });
63
+ if (!check.approved)
64
+ return campaignEditError(`Approval ${approvalId} is ${check.status}`, 'APPROVAL_REQUIRED');
65
+ return writeEdit(params.campaign_id, existing, params.updates);
66
+ }
@@ -9,6 +9,7 @@ import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLO
9
9
  // bundling problem this subpath was introduced to fix. See the comment in
10
10
  // packages/domain/src/index.ts.
11
11
  import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/domain/reply-actions';
12
+ import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
12
13
  import { adminDb, db, requestContext } from '../db.js';
13
14
  import { getProjectId } from '../project.js';
14
15
  import { checkPlanLimit } from '../plan-limits.js';
@@ -3345,7 +3346,7 @@ Return ONLY valid JSON with this exact shape:
3345
3346
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3346
3347
  description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Use for dunning on invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
3347
3348
  inputSchema: {
3348
- campaign_id: z.string().uuid(),
3349
+ campaign_id: z.string().uuid().optional(),
3349
3350
  trigger_event_name: z.string().nullable().optional(),
3350
3351
  trigger_tag: z.string().nullable().optional(),
3351
3352
  trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
@@ -3354,9 +3355,15 @@ Return ONLY valid JSON with this exact shape:
3354
3355
  approval_id: z.string().uuid().optional(),
3355
3356
  },
3356
3357
  }, async ({ approval_id, ...args }) => {
3358
+ if (approval_id) {
3359
+ const result = await resumeCampaignEdit('set_campaign_trigger', approval_id, args);
3360
+ return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3361
+ }
3362
+ if (!args.campaign_id)
3363
+ return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
3357
3364
  const projectId = getProjectId();
3358
3365
  const { data: existing, error: loadError } = await db().from('campaigns')
3359
- .select('id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at')
3366
+ .select(TRIGGER_EDIT_SELECT)
3360
3367
  .eq('id', args.campaign_id).eq('project_id', projectId).maybeSingle();
3361
3368
  if (loadError)
3362
3369
  return j({ error: loadError.message });
@@ -3381,36 +3388,8 @@ Return ONLY valid JSON with this exact shape:
3381
3388
  && (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
3382
3389
  updates.type = 'triggered';
3383
3390
  }
3384
- const execute = async () => {
3385
- // A draft activated during this call must never bypass approval. The timestamp also
3386
- // guards against overwriting edits made while an approved call is executing.
3387
- let query = db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
3388
- .eq('id', args.campaign_id).eq('project_id', projectId).eq('status', existing.status);
3389
- if (existing.updated_at)
3390
- query = query.eq('updated_at', existing.updated_at);
3391
- const { data, error } = await query.select().maybeSingle();
3392
- if (error)
3393
- return j({ error: error.message });
3394
- if (!data)
3395
- return j({ error: 'Campaign changed; read it and retry', code: 'CAMPAIGN_CHANGED' });
3396
- await logAudit({ action: 'campaign.trigger_updated', resourceType: 'campaign', resourceId: args.campaign_id, details: { previous: existing, updates } });
3397
- return j({ ...data, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) });
3398
- };
3399
- if (existing.status === 'draft')
3400
- return execute();
3401
- // The saved state is internal, never caller-supplied. Approval is for this exact before/after.
3402
- return withApproval('set_campaign_trigger', async (_params) => execute(), {
3403
- display: async () => {
3404
- const { count, error } = await db().from('subscribers').select('id', { count: 'exact', head: true })
3405
- .eq('project_id', projectId).eq('global_unsubscribed', false);
3406
- return {
3407
- campaign_name: existing.name,
3408
- from_trigger: `${buildCampaignFiresOn(existing)} Config: ${JSON.stringify(existing.trigger_config)}`,
3409
- to_trigger: `${buildCampaignFiresOn({ ...existing, ...updates })} Config: ${JSON.stringify(updates.trigger_config === undefined ? existing.trigger_config : updates.trigger_config)}`,
3410
- recipient_count: error ? null : count,
3411
- };
3412
- },
3413
- })({ ...args, previous_trigger: existing, approval_id });
3391
+ const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
3392
+ return j(result.error || result.requires_approval ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3414
3393
  });
3415
3394
  server.registerTool('update_campaign', {
3416
3395
  title: 'Update Campaign',
@@ -3420,9 +3399,10 @@ Return ONLY valid JSON with this exact shape:
3420
3399
  idempotentHint: true,
3421
3400
  openWorldHint: false,
3422
3401
  },
3423
- description: 'Update a campaign name, site, segment, send time, topic, trigger event name/tag/config (including where filters for dunning), 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.',
3402
+ description: 'Update a campaign name, site, segment, send time, topic, trigger event name/tag/config (including where filters for dunning), 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. Changed triggers on active/paused campaigns return an approval request; retry with only approval_id to apply the frozen edit.',
3424
3403
  inputSchema: {
3425
- campaign_id: z.string().uuid(),
3404
+ campaign_id: z.string().uuid().optional(),
3405
+ approval_id: z.string().uuid().optional(),
3426
3406
  name: z.string().min(1).max(200).optional(),
3427
3407
  site_id: z.string().uuid().optional(),
3428
3408
  segment_id: z.string().uuid().nullable().optional(),
@@ -3437,12 +3417,19 @@ Return ONLY valid JSON with this exact shape:
3437
3417
  send_in_contact_timezone: z.boolean().optional(),
3438
3418
  send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
3439
3419
  },
3440
- }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class }) => {
3420
+ }, async ({ approval_id, ...input }) => {
3421
+ if (approval_id) {
3422
+ const result = await resumeCampaignEdit('update_campaign', approval_id, input);
3423
+ return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3424
+ }
3425
+ const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class } = input;
3426
+ if (!campaign_id)
3427
+ return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
3441
3428
  const projectId = getProjectId();
3442
3429
  let campaignType;
3443
3430
  const loadExisting = () => db()
3444
3431
  .from('campaigns')
3445
- .select('id, type, status, trigger_type, trigger_config, trigger_event_name, trigger_tag')
3432
+ .select(TRIGGER_EDIT_SELECT)
3446
3433
  .eq('id', campaign_id)
3447
3434
  .eq('project_id', projectId)
3448
3435
  .maybeSingle();
@@ -3486,7 +3473,7 @@ Return ONLY valid JSON with this exact shape:
3486
3473
  return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3487
3474
  existingCampaignForTargeting = existing;
3488
3475
  }
3489
- const updates = { updated_at: new Date().toISOString() };
3476
+ const updates = {};
3490
3477
  if (name !== undefined)
3491
3478
  updates.name = name;
3492
3479
  if (site_id !== undefined)
@@ -3517,15 +3504,20 @@ Return ONLY valid JSON with this exact shape:
3517
3504
  updates.type = 'triggered';
3518
3505
  }
3519
3506
  }
3520
- const { data, error } = await db()
3521
- .from('campaigns')
3522
- .update(updates)
3523
- .eq('id', campaign_id)
3524
- .eq('project_id', projectId)
3525
- .select()
3526
- .single();
3527
- if (error)
3528
- return j({ error: error.message });
3507
+ let data;
3508
+ if (existingCampaignForTargeting) {
3509
+ data = await finishCampaignEdit('update_campaign', input, existingCampaignForTargeting, updates, params => requestApproval('update_campaign', params));
3510
+ if (data.error || data.requires_approval)
3511
+ return j(data);
3512
+ }
3513
+ else {
3514
+ const result = await db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
3515
+ .eq('id', campaign_id).eq('project_id', projectId).select().single();
3516
+ if (result.error)
3517
+ return j({ error: result.error.message });
3518
+ data = result.data;
3519
+ await logAudit({ action: 'campaign.updated', resourceType: 'campaign', resourceId: campaign_id, details: { updates } });
3520
+ }
3529
3521
  const diagnostics = buildCampaignTriggerDiagnostics(data);
3530
3522
  const warning = diagnostics.find((d) => d.includes('never fire')) || (diagnostics.length ? diagnostics[0] : null);
3531
3523
  return j({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.103",
3
+ "version": "1.0.104",
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",