@sendinel/mcp-server 1.0.102 → 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.
- package/README.md +2 -2
- package/build/lib/action-approvals.js +6 -1
- package/build/lib/approval-exempt.js +0 -1
- package/build/lib/campaign-trigger-approval.d.ts +10 -0
- package/build/lib/campaign-trigger-approval.js +66 -0
- package/build/lib/how-to-recipes.js +13 -0
- package/build/server.js +1 -1
- package/build/tools/campaigns.js +119 -32
- package/build/tools/tool-search.js +1 -0
- package/package.json +1 -1
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
|
|
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` |
|
|
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 |
|
|
@@ -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,8 @@ 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
|
|
32
|
+
'set_campaign_trigger', // changes who starts an active or paused campaign
|
|
31
33
|
'update_campaign_status', // activation sends real emails (see special case below)
|
|
32
34
|
'add_list_members', // bulk contact-list membership write
|
|
33
35
|
'remove_list_members', // bulk contact-list membership write
|
|
@@ -229,6 +231,8 @@ function launchSummary(p, d) {
|
|
|
229
231
|
}
|
|
230
232
|
/** Build a human-readable summary of the action for the approval screen */
|
|
231
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);
|
|
232
236
|
const summaries = {
|
|
233
237
|
launch_campaign: (p) => launchSummary(p, display) ?? `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
234
238
|
install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
|
|
@@ -254,6 +258,7 @@ export function buildApprovalSummary(action, params, display = {}) {
|
|
|
254
258
|
const warnings = display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : '';
|
|
255
259
|
return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires${warnings}`;
|
|
256
260
|
},
|
|
261
|
+
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
262
|
update_campaign_status: (p) => (p.status === 'active'
|
|
258
263
|
? `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
264
|
: `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
|
|
@@ -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
|
+
}
|
|
@@ -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',
|
package/build/tools/campaigns.js
CHANGED
|
@@ -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';
|
|
@@ -641,7 +642,7 @@ export function registerCampaignTools(server) {
|
|
|
641
642
|
idempotentHint: true,
|
|
642
643
|
openWorldHint: false,
|
|
643
644
|
},
|
|
644
|
-
description: 'List campaigns, optionally
|
|
645
|
+
description: 'List campaigns with saved trigger fields, fires_on summaries and trigger_diagnostics; optionally filter by site or status.',
|
|
645
646
|
inputSchema: {
|
|
646
647
|
site_id: z.string().uuid().optional(),
|
|
647
648
|
period: z.string().min(1).optional().describe("Return one enrollment period in by_period; totals remain all-time."),
|
|
@@ -651,7 +652,7 @@ export function registerCampaignTools(server) {
|
|
|
651
652
|
}, async ({ site_id, status, limit, period }) => {
|
|
652
653
|
let q = db()
|
|
653
654
|
.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')
|
|
655
|
+
.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
656
|
.eq('project_id', getProjectId())
|
|
656
657
|
.order('created_at', { ascending: false })
|
|
657
658
|
.limit(limit ?? 25);
|
|
@@ -671,6 +672,8 @@ export function registerCampaignTools(server) {
|
|
|
671
672
|
const { period_key: _periodKey, ...legacy } = row;
|
|
672
673
|
return {
|
|
673
674
|
...legacy,
|
|
675
|
+
fires_on: buildCampaignFiresOn(row),
|
|
676
|
+
trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
|
|
674
677
|
...campaignPeriodFields(periods, period),
|
|
675
678
|
...(periods?.totals ? { total_sent: periods.totals.sent } : {}),
|
|
676
679
|
unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
|
|
@@ -2958,11 +2961,15 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2958
2961
|
idempotentHint: false,
|
|
2959
2962
|
openWorldHint: true,
|
|
2960
2963
|
},
|
|
2961
|
-
description: 'Create a campaign with pre-built email content in one call.
|
|
2964
|
+
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
2965
|
inputSchema: {
|
|
2963
2966
|
site_id: z.string().uuid().describe('Site to send from'),
|
|
2964
2967
|
name: z.string().min(1).max(200).describe('Campaign name'),
|
|
2965
|
-
type: z.enum(['sequence', 'broadcast']).describe('Campaign type'),
|
|
2968
|
+
type: z.enum(['sequence', 'broadcast', 'triggered']).describe('Campaign type'),
|
|
2969
|
+
trigger_event_name: z.string().nullable().optional(),
|
|
2970
|
+
trigger_tag: z.string().nullable().optional(),
|
|
2971
|
+
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
2972
|
+
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
2973
|
steps: z.array(z.object({
|
|
2967
2974
|
subject: z.string().min(1).describe('Email subject line'),
|
|
2968
2975
|
body_html: z.string().min(1).describe('Full HTML email body'),
|
|
@@ -2971,16 +2978,25 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2971
2978
|
delay_days: z.number().int().min(0).optional().default(0),
|
|
2972
2979
|
delay_hours: z.number().int().min(0).max(23).optional().default(0),
|
|
2973
2980
|
})).min(1).describe('Email steps with pre-built content'),
|
|
2974
|
-
segment_id: z.string().uuid().optional().describe('Segment to auto-enroll.
|
|
2981
|
+
segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. Optional for triggered campaigns; future trigger matches enroll contacts.'),
|
|
2975
2982
|
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.'),
|
|
2983
|
+
activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id or a configured triggered campaign.'),
|
|
2977
2984
|
confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
|
|
2978
2985
|
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
2986
|
},
|
|
2980
|
-
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id }) => {
|
|
2987
|
+
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config }) => {
|
|
2988
|
+
const targeting = {};
|
|
2989
|
+
const triggerInput = { trigger_event_name, trigger_tag, trigger_type, trigger_config };
|
|
2990
|
+
const targetingError = await applyCampaignTargetingFields(targeting, triggerInput, getProjectId());
|
|
2991
|
+
if (targetingError)
|
|
2992
|
+
return j({ error: targetingError, code: 'INVALID_FIELD' });
|
|
2993
|
+
const triggerConfig = normalizeTriggerConfig(targeting.trigger_config);
|
|
2994
|
+
const hasEventOrTag = Boolean(targeting.trigger_event_name || targeting.trigger_tag || triggerConfig.events.length || triggerConfig.tags.length);
|
|
2995
|
+
const campaignType = type === 'sequence' && hasEventOrTag ? 'triggered' : type;
|
|
2996
|
+
const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
|
|
2981
2997
|
// Human-in-the-loop gate for activation
|
|
2982
2998
|
if (activate && confirmed) {
|
|
2983
|
-
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate };
|
|
2999
|
+
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, ...triggerInput };
|
|
2984
3000
|
const channelContext = currentChannelContext();
|
|
2985
3001
|
const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
|
|
2986
3002
|
if (!decision.allowed)
|
|
@@ -3026,7 +3042,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3026
3042
|
if (!confirmed) {
|
|
3027
3043
|
return j({
|
|
3028
3044
|
confirmation_required: true,
|
|
3029
|
-
campaign: { name, type, site: site.name },
|
|
3045
|
+
campaign: { name, type: campaignType, site: site.name, ...targeting },
|
|
3030
3046
|
steps: steps.map((s, i) => ({
|
|
3031
3047
|
step: i + 1,
|
|
3032
3048
|
subject: s.subject,
|
|
@@ -3037,8 +3053,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3037
3053
|
segment_id: segment_id ?? null,
|
|
3038
3054
|
sends_per_hour: sends_per_hour ?? null,
|
|
3039
3055
|
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.`,
|
|
3056
|
+
will_activate: activate && (!!segment_id || canActivateOnTrigger),
|
|
3057
|
+
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
3058
|
});
|
|
3043
3059
|
}
|
|
3044
3060
|
// Execute mode (second call)
|
|
@@ -3049,7 +3065,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3049
3065
|
project_id: getProjectId(),
|
|
3050
3066
|
site_id,
|
|
3051
3067
|
name,
|
|
3052
|
-
type,
|
|
3068
|
+
type: campaignType,
|
|
3069
|
+
...targeting,
|
|
3053
3070
|
status: 'draft',
|
|
3054
3071
|
segment_id: segment_id ?? null,
|
|
3055
3072
|
sends_per_hour: sends_per_hour ?? null,
|
|
@@ -3113,7 +3130,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3113
3130
|
}
|
|
3114
3131
|
}
|
|
3115
3132
|
// 4. Activate if requested
|
|
3116
|
-
|
|
3133
|
+
const willActivate = Boolean(activate && ((segment_id && enrolled > 0) || canActivateOnTrigger));
|
|
3134
|
+
if (willActivate) {
|
|
3117
3135
|
await db()
|
|
3118
3136
|
.from('campaigns')
|
|
3119
3137
|
.update({ status: 'active' })
|
|
@@ -3122,19 +3140,23 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3122
3140
|
fireWebhookEvent('campaign.activated', {
|
|
3123
3141
|
campaign_id: campaign.id,
|
|
3124
3142
|
campaign_name: name,
|
|
3125
|
-
type,
|
|
3143
|
+
type: campaignType,
|
|
3126
3144
|
contacts_enrolled: enrolled,
|
|
3127
3145
|
steps: steps.length,
|
|
3128
3146
|
}).catch(() => { }); // fire-and-forget
|
|
3129
3147
|
}
|
|
3148
|
+
await logAudit({ action: 'campaign.created', resourceType: 'campaign', resourceId: campaign.id, details: { type: campaignType, ...targeting, activated: willActivate } });
|
|
3130
3149
|
return j({
|
|
3131
3150
|
campaign_id: campaign.id,
|
|
3132
3151
|
name,
|
|
3133
|
-
type,
|
|
3152
|
+
type: campaignType,
|
|
3153
|
+
...targeting,
|
|
3154
|
+
fires_on: buildCampaignFiresOn({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3155
|
+
trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3134
3156
|
steps_created: steps.length,
|
|
3135
3157
|
contacts_enrolled: enrolled,
|
|
3136
|
-
status:
|
|
3137
|
-
message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${
|
|
3158
|
+
status: willActivate ? 'active' : 'draft',
|
|
3159
|
+
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
3160
|
});
|
|
3139
3161
|
});
|
|
3140
3162
|
server.registerTool('list_campaign_steps', {
|
|
@@ -3319,6 +3341,56 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3319
3341
|
return j({ error: error.message });
|
|
3320
3342
|
return j({ success: true, deleted: campaign.name });
|
|
3321
3343
|
}));
|
|
3344
|
+
server.registerTool('set_campaign_trigger', {
|
|
3345
|
+
title: 'Set Campaign Trigger',
|
|
3346
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
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.',
|
|
3348
|
+
inputSchema: {
|
|
3349
|
+
campaign_id: z.string().uuid().optional(),
|
|
3350
|
+
trigger_event_name: z.string().nullable().optional(),
|
|
3351
|
+
trigger_tag: z.string().nullable().optional(),
|
|
3352
|
+
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
3353
|
+
trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists and optional where filter, or date_field/contact_event configuration.'),
|
|
3354
|
+
clear: z.literal(true).optional().describe('Clear all trigger fields back to manual. Cannot be combined with trigger fields.'),
|
|
3355
|
+
approval_id: z.string().uuid().optional(),
|
|
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' });
|
|
3364
|
+
const projectId = getProjectId();
|
|
3365
|
+
const { data: existing, error: loadError } = await db().from('campaigns')
|
|
3366
|
+
.select(TRIGGER_EDIT_SELECT)
|
|
3367
|
+
.eq('id', args.campaign_id).eq('project_id', projectId).maybeSingle();
|
|
3368
|
+
if (loadError)
|
|
3369
|
+
return j({ error: loadError.message });
|
|
3370
|
+
if (!existing)
|
|
3371
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3372
|
+
if (!['draft', 'active', 'paused'].includes(existing.status)) {
|
|
3373
|
+
return j({ error: `Cannot change a ${existing.status} campaign trigger`, code: 'INVALID_STATUS' });
|
|
3374
|
+
}
|
|
3375
|
+
const hasFields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config']
|
|
3376
|
+
.some((key) => args[key] !== undefined);
|
|
3377
|
+
if ((!args.clear && !hasFields) || (args.clear && hasFields)) {
|
|
3378
|
+
return j({ error: 'Provide trigger fields or clear=true, exclusively', code: 'INVALID_FIELD' });
|
|
3379
|
+
}
|
|
3380
|
+
const updates = {};
|
|
3381
|
+
const targetingError = await applyCampaignTargetingFields(updates, args.clear
|
|
3382
|
+
? { trigger_type: 'manual', trigger_event_name: null, trigger_tag: null, trigger_config: null }
|
|
3383
|
+
: args, projectId, existing);
|
|
3384
|
+
if (targetingError)
|
|
3385
|
+
return j({ error: targetingError, code: 'INVALID_FIELD' });
|
|
3386
|
+
const normalized = normalizeTriggerConfig(updates.trigger_config);
|
|
3387
|
+
if (existing.status === 'draft' && existing.type === 'sequence'
|
|
3388
|
+
&& (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
|
|
3389
|
+
updates.type = 'triggered';
|
|
3390
|
+
}
|
|
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) });
|
|
3393
|
+
});
|
|
3322
3394
|
server.registerTool('update_campaign', {
|
|
3323
3395
|
title: 'Update Campaign',
|
|
3324
3396
|
annotations: {
|
|
@@ -3327,9 +3399,10 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3327
3399
|
idempotentHint: true,
|
|
3328
3400
|
openWorldHint: false,
|
|
3329
3401
|
},
|
|
3330
|
-
description: 'Update a campaign name, site, segment, send time, topic, trigger
|
|
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.',
|
|
3331
3403
|
inputSchema: {
|
|
3332
|
-
campaign_id: z.string().uuid(),
|
|
3404
|
+
campaign_id: z.string().uuid().optional(),
|
|
3405
|
+
approval_id: z.string().uuid().optional(),
|
|
3333
3406
|
name: z.string().min(1).max(200).optional(),
|
|
3334
3407
|
site_id: z.string().uuid().optional(),
|
|
3335
3408
|
segment_id: z.string().uuid().nullable().optional(),
|
|
@@ -3344,12 +3417,19 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3344
3417
|
send_in_contact_timezone: z.boolean().optional(),
|
|
3345
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)."),
|
|
3346
3419
|
},
|
|
3347
|
-
}, async ({
|
|
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' });
|
|
3348
3428
|
const projectId = getProjectId();
|
|
3349
3429
|
let campaignType;
|
|
3350
3430
|
const loadExisting = () => db()
|
|
3351
3431
|
.from('campaigns')
|
|
3352
|
-
.select(
|
|
3432
|
+
.select(TRIGGER_EDIT_SELECT)
|
|
3353
3433
|
.eq('id', campaign_id)
|
|
3354
3434
|
.eq('project_id', projectId)
|
|
3355
3435
|
.maybeSingle();
|
|
@@ -3393,7 +3473,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3393
3473
|
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3394
3474
|
existingCampaignForTargeting = existing;
|
|
3395
3475
|
}
|
|
3396
|
-
const updates = {
|
|
3476
|
+
const updates = {};
|
|
3397
3477
|
if (name !== undefined)
|
|
3398
3478
|
updates.name = name;
|
|
3399
3479
|
if (site_id !== undefined)
|
|
@@ -3424,15 +3504,20 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3424
3504
|
updates.type = 'triggered';
|
|
3425
3505
|
}
|
|
3426
3506
|
}
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
.
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
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
|
+
}
|
|
3436
3521
|
const diagnostics = buildCampaignTriggerDiagnostics(data);
|
|
3437
3522
|
const warning = diagnostics.find((d) => d.includes('never fire')) || (diagnostics.length ? diagnostics[0] : null);
|
|
3438
3523
|
return j({
|
|
@@ -3451,7 +3536,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3451
3536
|
idempotentHint: true,
|
|
3452
3537
|
openWorldHint: false,
|
|
3453
3538
|
},
|
|
3454
|
-
description: 'Get full details
|
|
3539
|
+
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
3540
|
inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
|
|
3456
3541
|
}, async ({ campaign_id, period }) => {
|
|
3457
3542
|
const projectId = getProjectId();
|
|
@@ -3522,6 +3607,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3522
3607
|
const unresolvedCount = dlqUnresolvedCount ?? 0;
|
|
3523
3608
|
return j({
|
|
3524
3609
|
...campaign,
|
|
3610
|
+
fires_on: buildCampaignFiresOn(campaign),
|
|
3611
|
+
trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
|
|
3525
3612
|
total_sent: periods?.totals?.sent ?? sentCount ?? 0,
|
|
3526
3613
|
...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
|
|
3527
3614
|
...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.
|
|
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",
|