@sendinel/mcp-server 1.0.102 → 1.0.103

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
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
18
18
  [event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
19
19
  inputs, idempotency, subscription requirements, and conditional step examples.
20
20
 
21
- Sendinel currently exposes 35 tool groups and 340 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
21
+ Sendinel currently exposes 35 tool groups and 341 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
22
22
 
23
23
  | Group | Count | Purpose |
24
24
  |-------|-------|---------|
@@ -30,7 +30,7 @@ Sendinel currently exposes 35 tool groups and 340 tools (gateway mode exposes 4
30
30
  | `approvals` | 2 | Poll and list risk-tiered action approvals |
31
31
  | `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
32
32
  | `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
33
- | `campaigns` | 43 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
33
+ | `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
34
34
  | `cloud-import` | 6 | Cloud storage import |
35
35
  | `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
36
36
  | `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
@@ -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
+ 'set_campaign_trigger', // changes who starts an active or paused campaign
31
32
  'update_campaign_status', // activation sends real emails (see special case below)
32
33
  'add_list_members', // bulk contact-list membership write
33
34
  'remove_list_members', // bulk contact-list membership write
@@ -254,6 +255,7 @@ export function buildApprovalSummary(action, params, display = {}) {
254
255
  const warnings = display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : '';
255
256
  return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires${warnings}`;
256
257
  },
258
+ set_campaign_trigger: (p) => `Change what starts campaign \"${display.campaign_name ?? p.campaign_id}\" from ${display.from_trigger ?? 'its current trigger'} to ${display.to_trigger ?? 'the requested trigger'}; ${typeof display.recipient_count === 'number' ? `up to ${display.recipient_count.toLocaleString('en-US')} people` : 'an unknown number of people'} may start receiving it`,
257
259
  update_campaign_status: (p) => (p.status === 'active'
258
260
  ? `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}"` : ''}${display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : ''}`
259
261
  : `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
@@ -6,6 +6,19 @@ const projectRule = 'On hosted /mcp, pass project_id on each call. It is require
6
6
  const previewRule = 'preview_email only substitutes sample merge variables; it is not the real send renderer. send_test_email with dry_run:true uses the real renderer without sending, but still follows the approval flow in standard mode; keep the same args plus approval_id when re-calling it. Stdio requires SENDINEL_API_KEY.';
7
7
  const sender = { n: 1, tool: 'get_sites', args_example: {}, note: 'Choose the intended sender from the returned array; do not assume the first sender is correct. Replace example content and addresses with the user’s own choices.' };
8
8
  export const HOW_TO_RECIPES = [
9
+ {
10
+ id: 'set-campaign-trigger', title: 'Set what a campaign fires on',
11
+ when_to_use: 'Bind an existing campaign to an event such as invoice_payment_failed for dunning, or change its tag/date/contact-event trigger.',
12
+ synonyms: TOOL_ALIASES.set_campaign_trigger,
13
+ requires: { scopes: ['write'], tester_ok: false },
14
+ steps: [
15
+ { n: 1, tool: 'list_campaigns', args_example: { limit: 25 }, note: 'Select the intended campaign and inspect its status and saved trigger.' },
16
+ { n: 2, tool: 'set_campaign_trigger', args_example: { campaign_id: from(1, '0.id'), trigger_event_name: 'invoice_payment_failed', trigger_config: { events: ['invoice_payment_failed'], where: { plan: 'paid' } } }, approval, note: 'Use the actual event and intended filter. Drafts save directly; active/paused campaigns return approve_url. Wait for approval, then repeat the SAME args plus approval_id.' },
17
+ { n: 3, tool: 'get_campaign', args_example: { campaign_id: from(1, '0.id') }, note: 'Verify saved trigger fields, where, fires_on and trigger_diagnostics.' },
18
+ ],
19
+ gotchas: [projectRule, 'Archived/completed campaigns refuse changes. clear:true clears trigger fields and cannot be combined with trigger arguments. Drafts still need separate activation approval; setting a trigger does not emit an event.'],
20
+ done_when: 'The intended trigger and filter round-trip through get_campaign; any activation or live event test remains a separate authorized action.',
21
+ },
9
22
  {
10
23
  id: 'send-one-off-email', title: 'Send a one-off email to my list',
11
24
  when_to_use: 'Send an announcement or email blast to an audience with human approval.',
package/build/server.js CHANGED
@@ -85,7 +85,7 @@ const WRITE_TOOLS = new Set([
85
85
  'add_subscriber', 'identify_subscriber', 'emit_contact_event', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
86
86
  'create_subscriber_view', 'update_subscriber_view', 'delete_subscriber_view',
87
87
  'enrich_subscriber',
88
- 'create_campaign', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
88
+ 'create_campaign', 'set_campaign_trigger', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
89
89
  // #3348 - branch/goal writes. Same scope as the campaign edits they sit alongside:
90
90
  // they change what the send engine does to enrolled contacts, so they are not read-only.
91
91
  'add_campaign_branch', 'remove_campaign_branch', 'set_campaign_goal', 'remove_campaign_goal',
@@ -641,7 +641,7 @@ export function registerCampaignTools(server) {
641
641
  idempotentHint: true,
642
642
  openWorldHint: false,
643
643
  },
644
- description: 'List campaigns, optionally filtered by site and/or status',
644
+ description: 'List campaigns with saved trigger fields, fires_on summaries and trigger_diagnostics; optionally filter by site or status.',
645
645
  inputSchema: {
646
646
  site_id: z.string().uuid().optional(),
647
647
  period: z.string().min(1).optional().describe("Return one enrollment period in by_period; totals remain all-time."),
@@ -651,7 +651,7 @@ export function registerCampaignTools(server) {
651
651
  }, async ({ site_id, status, limit, period }) => {
652
652
  let q = db()
653
653
  .from('campaigns')
654
- .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key')
654
+ .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config')
655
655
  .eq('project_id', getProjectId())
656
656
  .order('created_at', { ascending: false })
657
657
  .limit(limit ?? 25);
@@ -671,6 +671,8 @@ export function registerCampaignTools(server) {
671
671
  const { period_key: _periodKey, ...legacy } = row;
672
672
  return {
673
673
  ...legacy,
674
+ fires_on: buildCampaignFiresOn(row),
675
+ trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
674
676
  ...campaignPeriodFields(periods, period),
675
677
  ...(periods?.totals ? { total_sent: periods.totals.sent } : {}),
676
678
  unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
@@ -2958,11 +2960,15 @@ Return ONLY valid JSON with this exact shape:
2958
2960
  idempotentHint: false,
2959
2961
  openWorldHint: true,
2960
2962
  },
2961
- description: 'Create a campaign with pre-built email content in one call. Creates the campaign, adds email step(s), optionally enrolls a segment, and activates. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
2963
+ description: 'Create a sequence, broadcast or triggered campaign with pre-built email content in one call. Set trigger_event_name for dunning (invoice_payment_failed, trial_ending), trigger_tag, or date_field/contact_event trigger_config; events/tags and where filters are supported. Triggered campaigns need no segment. Returns fires_on and trigger_diagnostics. Adds email steps, optionally enrolls a segment, and activates behind standard approval. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
2962
2964
  inputSchema: {
2963
2965
  site_id: z.string().uuid().describe('Site to send from'),
2964
2966
  name: z.string().min(1).max(200).describe('Campaign name'),
2965
- type: z.enum(['sequence', 'broadcast']).describe('Campaign type'),
2967
+ type: z.enum(['sequence', 'broadcast', 'triggered']).describe('Campaign type'),
2968
+ trigger_event_name: z.string().nullable().optional(),
2969
+ trigger_tag: z.string().nullable().optional(),
2970
+ trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
2971
+ trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists with optional where filter, or date_field/contact_event configuration.'),
2966
2972
  steps: z.array(z.object({
2967
2973
  subject: z.string().min(1).describe('Email subject line'),
2968
2974
  body_html: z.string().min(1).describe('Full HTML email body'),
@@ -2971,16 +2977,25 @@ Return ONLY valid JSON with this exact shape:
2971
2977
  delay_days: z.number().int().min(0).optional().default(0),
2972
2978
  delay_hours: z.number().int().min(0).max(23).optional().default(0),
2973
2979
  })).min(1).describe('Email steps with pre-built content'),
2974
- segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. If omitted, campaign is created in draft without enrollments.'),
2980
+ segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. Optional for triggered campaigns; future trigger matches enroll contacts.'),
2975
2981
  sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
2976
- activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id.'),
2982
+ activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id or a configured triggered campaign.'),
2977
2983
  confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
2978
2984
  approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
2979
2985
  },
2980
- }, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id }) => {
2986
+ }, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config }) => {
2987
+ const targeting = {};
2988
+ const triggerInput = { trigger_event_name, trigger_tag, trigger_type, trigger_config };
2989
+ const targetingError = await applyCampaignTargetingFields(targeting, triggerInput, getProjectId());
2990
+ if (targetingError)
2991
+ return j({ error: targetingError, code: 'INVALID_FIELD' });
2992
+ const triggerConfig = normalizeTriggerConfig(targeting.trigger_config);
2993
+ const hasEventOrTag = Boolean(targeting.trigger_event_name || targeting.trigger_tag || triggerConfig.events.length || triggerConfig.tags.length);
2994
+ const campaignType = type === 'sequence' && hasEventOrTag ? 'triggered' : type;
2995
+ const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
2981
2996
  // Human-in-the-loop gate for activation
2982
2997
  if (activate && confirmed) {
2983
- const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate };
2998
+ const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, ...triggerInput };
2984
2999
  const channelContext = currentChannelContext();
2985
3000
  const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
2986
3001
  if (!decision.allowed)
@@ -3026,7 +3041,7 @@ Return ONLY valid JSON with this exact shape:
3026
3041
  if (!confirmed) {
3027
3042
  return j({
3028
3043
  confirmation_required: true,
3029
- campaign: { name, type, site: site.name },
3044
+ campaign: { name, type: campaignType, site: site.name, ...targeting },
3030
3045
  steps: steps.map((s, i) => ({
3031
3046
  step: i + 1,
3032
3047
  subject: s.subject,
@@ -3037,8 +3052,8 @@ Return ONLY valid JSON with this exact shape:
3037
3052
  segment_id: segment_id ?? null,
3038
3053
  sends_per_hour: sends_per_hour ?? null,
3039
3054
  estimated_contacts: contactCount,
3040
- will_activate: activate && !!segment_id,
3041
- message: `Campaign "${name}" with ${steps.length} step(s) on ${site.name}. ${segment_id ? `Will enroll ~${contactCount} contacts.` : 'No segment — created as draft.'} ${activate && segment_id ? 'Will activate immediately.' : ''} Call again with confirmed=true to proceed.`,
3055
+ will_activate: activate && (!!segment_id || canActivateOnTrigger),
3056
+ message: `Campaign "${name}" with ${steps.length} step(s) on ${site.name}. ${segment_id ? `Will enroll ~${contactCount} contacts.` : canActivateOnTrigger ? 'Future trigger matches will enroll contacts.' : 'No segment — created as draft.'} ${activate && (segment_id || canActivateOnTrigger) ? 'Will activate immediately.' : ''} Call again with confirmed=true to proceed.`,
3042
3057
  });
3043
3058
  }
3044
3059
  // Execute mode (second call)
@@ -3049,7 +3064,8 @@ Return ONLY valid JSON with this exact shape:
3049
3064
  project_id: getProjectId(),
3050
3065
  site_id,
3051
3066
  name,
3052
- type,
3067
+ type: campaignType,
3068
+ ...targeting,
3053
3069
  status: 'draft',
3054
3070
  segment_id: segment_id ?? null,
3055
3071
  sends_per_hour: sends_per_hour ?? null,
@@ -3113,7 +3129,8 @@ Return ONLY valid JSON with this exact shape:
3113
3129
  }
3114
3130
  }
3115
3131
  // 4. Activate if requested
3116
- if (activate && segment_id && enrolled > 0) {
3132
+ const willActivate = Boolean(activate && ((segment_id && enrolled > 0) || canActivateOnTrigger));
3133
+ if (willActivate) {
3117
3134
  await db()
3118
3135
  .from('campaigns')
3119
3136
  .update({ status: 'active' })
@@ -3122,19 +3139,23 @@ Return ONLY valid JSON with this exact shape:
3122
3139
  fireWebhookEvent('campaign.activated', {
3123
3140
  campaign_id: campaign.id,
3124
3141
  campaign_name: name,
3125
- type,
3142
+ type: campaignType,
3126
3143
  contacts_enrolled: enrolled,
3127
3144
  steps: steps.length,
3128
3145
  }).catch(() => { }); // fire-and-forget
3129
3146
  }
3147
+ await logAudit({ action: 'campaign.created', resourceType: 'campaign', resourceId: campaign.id, details: { type: campaignType, ...targeting, activated: willActivate } });
3130
3148
  return j({
3131
3149
  campaign_id: campaign.id,
3132
3150
  name,
3133
- type,
3151
+ type: campaignType,
3152
+ ...targeting,
3153
+ fires_on: buildCampaignFiresOn({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3154
+ trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3134
3155
  steps_created: steps.length,
3135
3156
  contacts_enrolled: enrolled,
3136
- status: activate && enrolled > 0 ? 'active' : 'draft',
3137
- message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${activate && enrolled > 0 ? 'Campaign is now active — emails will begin sending on the next cron cycle.' : 'Campaign saved as draft.'}`,
3157
+ status: willActivate ? 'active' : 'draft',
3158
+ message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${willActivate ? 'Campaign is now active — emails will begin sending on the next cron cycle.' : 'Campaign saved as draft.'}`,
3138
3159
  });
3139
3160
  });
3140
3161
  server.registerTool('list_campaign_steps', {
@@ -3319,6 +3340,78 @@ Return ONLY valid JSON with this exact shape:
3319
3340
  return j({ error: error.message });
3320
3341
  return j({ success: true, deleted: campaign.name });
3321
3342
  }));
3343
+ server.registerTool('set_campaign_trigger', {
3344
+ title: 'Set Campaign Trigger',
3345
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3346
+ 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
+ inputSchema: {
3348
+ campaign_id: z.string().uuid(),
3349
+ trigger_event_name: z.string().nullable().optional(),
3350
+ trigger_tag: z.string().nullable().optional(),
3351
+ trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
3352
+ trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists and optional where filter, or date_field/contact_event configuration.'),
3353
+ clear: z.literal(true).optional().describe('Clear all trigger fields back to manual. Cannot be combined with trigger fields.'),
3354
+ approval_id: z.string().uuid().optional(),
3355
+ },
3356
+ }, async ({ approval_id, ...args }) => {
3357
+ const projectId = getProjectId();
3358
+ 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')
3360
+ .eq('id', args.campaign_id).eq('project_id', projectId).maybeSingle();
3361
+ if (loadError)
3362
+ return j({ error: loadError.message });
3363
+ if (!existing)
3364
+ return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3365
+ if (!['draft', 'active', 'paused'].includes(existing.status)) {
3366
+ return j({ error: `Cannot change a ${existing.status} campaign trigger`, code: 'INVALID_STATUS' });
3367
+ }
3368
+ const hasFields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config']
3369
+ .some((key) => args[key] !== undefined);
3370
+ if ((!args.clear && !hasFields) || (args.clear && hasFields)) {
3371
+ return j({ error: 'Provide trigger fields or clear=true, exclusively', code: 'INVALID_FIELD' });
3372
+ }
3373
+ const updates = {};
3374
+ const targetingError = await applyCampaignTargetingFields(updates, args.clear
3375
+ ? { trigger_type: 'manual', trigger_event_name: null, trigger_tag: null, trigger_config: null }
3376
+ : args, projectId, existing);
3377
+ if (targetingError)
3378
+ return j({ error: targetingError, code: 'INVALID_FIELD' });
3379
+ const normalized = normalizeTriggerConfig(updates.trigger_config);
3380
+ if (existing.status === 'draft' && existing.type === 'sequence'
3381
+ && (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
3382
+ updates.type = 'triggered';
3383
+ }
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 });
3414
+ });
3322
3415
  server.registerTool('update_campaign', {
3323
3416
  title: 'Update Campaign',
3324
3417
  annotations: {
@@ -3327,7 +3420,7 @@ Return ONLY valid JSON with this exact shape:
3327
3420
  idempotentHint: true,
3328
3421
  openWorldHint: false,
3329
3422
  },
3330
- 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.',
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.',
3331
3424
  inputSchema: {
3332
3425
  campaign_id: z.string().uuid(),
3333
3426
  name: z.string().min(1).max(200).optional(),
@@ -3451,7 +3544,7 @@ Return ONLY valid JSON with this exact shape:
3451
3544
  idempotentHint: true,
3452
3545
  openWorldHint: false,
3453
3546
  },
3454
- description: 'Get full details of a single campaign including all steps, current enrollment counts, and settings.',
3547
+ description: 'Get full campaign details including all steps, enrollment counts and saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it.',
3455
3548
  inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
3456
3549
  }, async ({ campaign_id, period }) => {
3457
3550
  const projectId = getProjectId();
@@ -3522,6 +3615,8 @@ Return ONLY valid JSON with this exact shape:
3522
3615
  const unresolvedCount = dlqUnresolvedCount ?? 0;
3523
3616
  return j({
3524
3617
  ...campaign,
3618
+ fires_on: buildCampaignFiresOn(campaign),
3619
+ trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
3525
3620
  total_sent: periods?.totals?.sent ?? sentCount ?? 0,
3526
3621
  ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
3527
3622
  ...campaignPeriodFields(periods, period),
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
12
12
  * Only tools that exist in the registry belong here.
13
13
  */
14
14
  export const TOOL_ALIASES = {
15
+ set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'trial ending'],
15
16
  send_broadcast: [
16
17
  'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
17
18
  'email my list', 'send to my list', 'email to my list', 'email my subscribers',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.102",
3
+ "version": "1.0.103",
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",