@sendinel/mcp-server 1.0.13 → 1.0.14
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/server.js +3 -0
- package/build/tools/campaigns.js +201 -0
- package/package.json +1 -1
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
|
|
15
|
+
Sendinel currently exposes 34 tool groups and 308 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 302 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` |
|
|
26
|
+
| `campaigns` | 36 | 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 |
|
package/build/server.js
CHANGED
|
@@ -78,6 +78,9 @@ const WRITE_TOOLS = new Set([
|
|
|
78
78
|
'create_subscriber_view', 'update_subscriber_view', 'delete_subscriber_view',
|
|
79
79
|
'enrich_subscriber',
|
|
80
80
|
'create_campaign', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
|
|
81
|
+
// #3348 - branch/goal writes. Same scope as the campaign edits they sit alongside:
|
|
82
|
+
// they change what the send engine does to enrolled contacts, so they are not read-only.
|
|
83
|
+
'add_campaign_branch', 'remove_campaign_branch', 'set_campaign_goal', 'remove_campaign_goal',
|
|
81
84
|
'review_campaign_portfolio',
|
|
82
85
|
'list_campaign_steps', 'update_campaign_step', 'delete_campaign_step',
|
|
83
86
|
'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
|
package/build/tools/campaigns.js
CHANGED
|
@@ -235,6 +235,47 @@ function normalizeConsistencyResult(value) {
|
|
|
235
235
|
flags,
|
|
236
236
|
};
|
|
237
237
|
}
|
|
238
|
+
// #3348 - kept in step with the dashboard routes these tools mirror:
|
|
239
|
+
// app/api/dashboard/campaigns/[id]/steps/[stepId]/branches/route.ts
|
|
240
|
+
// app/api/dashboard/campaigns/goals/route.ts
|
|
241
|
+
// If those lists change, change these. A value MCP accepts that REST rejects (or the
|
|
242
|
+
// reverse) is a divergence between two front doors onto the same engine.
|
|
243
|
+
const CAMPAIGN_BRANCH_CONDITIONS = ['tag_added', 'lifecycle_reached', 'conversion', 'link_clicked', 'custom_event', 'reply', 'click', 'booking'];
|
|
244
|
+
const CAMPAIGN_BRANCH_ACTIONS = ['goto_step', 'goto_campaign', 'exit', 'complete'];
|
|
245
|
+
const CAMPAIGN_GOAL_TYPES = ['conversion', 'tag_added', 'lifecycle_reached', 'link_clicked', 'custom_event'];
|
|
246
|
+
const CAMPAIGN_GOAL_ACTIONS = ['exit', 'complete', 'move_to_campaign'];
|
|
247
|
+
/** Tenant guard: every branch/goal tool confirms the campaign belongs to the caller's project first. */
|
|
248
|
+
async function campaignInProject(campaignId) {
|
|
249
|
+
const { data } = await db()
|
|
250
|
+
.from('campaigns')
|
|
251
|
+
.select('id')
|
|
252
|
+
.eq('id', campaignId)
|
|
253
|
+
.eq('project_id', getProjectId())
|
|
254
|
+
.maybeSingle();
|
|
255
|
+
return Boolean(data);
|
|
256
|
+
}
|
|
257
|
+
/** Mirrors validateTarget() in the branches REST route. */
|
|
258
|
+
async function validateBranchTarget(campaignId, action, targetStepId, targetCampaignId) {
|
|
259
|
+
if (action === 'goto_step') {
|
|
260
|
+
if (!targetStepId)
|
|
261
|
+
return 'target_step_id is required for goto_step';
|
|
262
|
+
const { data } = await db()
|
|
263
|
+
.from('campaign_steps')
|
|
264
|
+
.select('id')
|
|
265
|
+
.eq('id', targetStepId)
|
|
266
|
+
.eq('campaign_id', campaignId)
|
|
267
|
+
.maybeSingle();
|
|
268
|
+
if (!data)
|
|
269
|
+
return 'target_step_id must be a step in the same campaign';
|
|
270
|
+
}
|
|
271
|
+
if (action === 'goto_campaign') {
|
|
272
|
+
if (!targetCampaignId)
|
|
273
|
+
return 'target_campaign_id is required for goto_campaign';
|
|
274
|
+
if (!(await campaignInProject(targetCampaignId)))
|
|
275
|
+
return 'target_campaign_id must be a campaign in this project';
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
238
279
|
export function registerCampaignTools(server) {
|
|
239
280
|
server.registerTool('review_campaign_portfolio', {
|
|
240
281
|
title: 'Review Campaign Portfolio',
|
|
@@ -2528,4 +2569,164 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2528
2569
|
return j({ error: error.message });
|
|
2529
2570
|
return j({ success: true });
|
|
2530
2571
|
});
|
|
2572
|
+
// #3348 - campaign branching and goals were fully built (schema, engine, dashboard API)
|
|
2573
|
+
// but unreachable from MCP, so an agent could compose the emails in a campaign and not
|
|
2574
|
+
// the logic that makes it a campaign rather than a list of templates. The send engine
|
|
2575
|
+
// (cron/enqueue-sends) already reads both tables; these tools only let callers write
|
|
2576
|
+
// what it already honours. Validation mirrors the dashboard routes exactly -- a value
|
|
2577
|
+
// accepted here that the REST route would reject is a divergence, not a convenience.
|
|
2578
|
+
server.registerTool('list_campaign_branches', {
|
|
2579
|
+
title: 'List Campaign Branches',
|
|
2580
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
2581
|
+
description: 'List the conditional branches, if/then rules and logic already set on a campaign, optionally filtered to one step. Shows each condition, what happens when it matches, and the priority order they are evaluated in. Use this to see the existing branching before adding or changing a rule.',
|
|
2582
|
+
inputSchema: {
|
|
2583
|
+
campaign_id: z.string().uuid(),
|
|
2584
|
+
step_id: z.string().uuid().optional().describe('Only branches leaving this step'),
|
|
2585
|
+
},
|
|
2586
|
+
}, async ({ campaign_id, step_id }) => {
|
|
2587
|
+
if (!(await campaignInProject(campaign_id)))
|
|
2588
|
+
return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
|
|
2589
|
+
let query = db()
|
|
2590
|
+
.from('campaign_step_branches')
|
|
2591
|
+
.select('id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority, created_at')
|
|
2592
|
+
.eq('campaign_id', campaign_id)
|
|
2593
|
+
.order('priority', { ascending: true });
|
|
2594
|
+
if (step_id)
|
|
2595
|
+
query = query.eq('from_step_id', step_id);
|
|
2596
|
+
const { data, error } = await query;
|
|
2597
|
+
if (error)
|
|
2598
|
+
return j({ error: error.message });
|
|
2599
|
+
return j({ campaign_id, branches: data ?? [], count: (data ?? []).length });
|
|
2600
|
+
});
|
|
2601
|
+
server.registerTool('add_campaign_branch', {
|
|
2602
|
+
title: 'Add Campaign Branch',
|
|
2603
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2604
|
+
description: 'Add an if/then rule to a campaign step so the sequence branches on what the contact does. Use this for conditional logic like: if they clicked the link send a different follow-up, if they replied skip ahead, if a tag was added move them to another campaign, otherwise continue. When the condition matches, the branch action runs instead of continuing to the next step. Lower priority is evaluated first.',
|
|
2605
|
+
inputSchema: {
|
|
2606
|
+
campaign_id: z.string().uuid(),
|
|
2607
|
+
from_step_id: z.string().uuid().describe('The step this branch leaves from'),
|
|
2608
|
+
condition_type: z.enum(CAMPAIGN_BRANCH_CONDITIONS).describe('What triggers the branch'),
|
|
2609
|
+
condition_value: z.string().optional().describe('Condition argument, e.g. the tag name for tag_added or the URL for link_clicked'),
|
|
2610
|
+
action: z.enum(CAMPAIGN_BRANCH_ACTIONS).describe('goto_step and goto_campaign require their matching target'),
|
|
2611
|
+
target_step_id: z.string().uuid().optional().describe('Required when action is goto_step'),
|
|
2612
|
+
target_campaign_id: z.string().uuid().optional().describe('Required when action is goto_campaign'),
|
|
2613
|
+
priority: z.number().int().min(0).optional().default(0).describe('Lower runs first (default 0)'),
|
|
2614
|
+
},
|
|
2615
|
+
}, async ({ campaign_id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority }) => {
|
|
2616
|
+
if (!(await campaignInProject(campaign_id)))
|
|
2617
|
+
return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
|
|
2618
|
+
// Same requirement the REST route enforces: an action naming a target must have one,
|
|
2619
|
+
// and that target must belong to this project.
|
|
2620
|
+
const targetError = await validateBranchTarget(campaign_id, action, target_step_id ?? null, target_campaign_id ?? null);
|
|
2621
|
+
if (targetError)
|
|
2622
|
+
return j({ error: targetError, code: 'INVALID_TARGET' });
|
|
2623
|
+
const { data, error } = await db()
|
|
2624
|
+
.from('campaign_step_branches')
|
|
2625
|
+
.insert({
|
|
2626
|
+
campaign_id,
|
|
2627
|
+
from_step_id,
|
|
2628
|
+
condition_type,
|
|
2629
|
+
condition_value: condition_value?.trim() || null,
|
|
2630
|
+
action,
|
|
2631
|
+
target_step_id: action === 'goto_step' ? target_step_id : null,
|
|
2632
|
+
target_campaign_id: action === 'goto_campaign' ? target_campaign_id : null,
|
|
2633
|
+
priority: priority ?? 0,
|
|
2634
|
+
})
|
|
2635
|
+
.select('id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority')
|
|
2636
|
+
.single();
|
|
2637
|
+
if (error)
|
|
2638
|
+
return j({ error: error.message });
|
|
2639
|
+
await logAudit({ action: 'add_campaign_branch', resourceType: 'campaign', resourceId: campaign_id, details: { branch_id: data?.id, condition_type, branch_action: action } }).catch(() => { });
|
|
2640
|
+
return j({ success: true, branch: data });
|
|
2641
|
+
});
|
|
2642
|
+
server.registerTool('remove_campaign_branch', {
|
|
2643
|
+
title: 'Remove Campaign Branch',
|
|
2644
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
2645
|
+
description: 'Delete or turn off a conditional branch rule on a campaign step, so that if/then path no longer applies. Contacts already routed by it are unaffected; only future evaluations change.',
|
|
2646
|
+
inputSchema: { campaign_id: z.string().uuid(), branch_id: z.string().uuid() },
|
|
2647
|
+
}, async ({ campaign_id, branch_id }) => {
|
|
2648
|
+
if (!(await campaignInProject(campaign_id)))
|
|
2649
|
+
return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
|
|
2650
|
+
const { error } = await db()
|
|
2651
|
+
.from('campaign_step_branches')
|
|
2652
|
+
.delete()
|
|
2653
|
+
.eq('id', branch_id)
|
|
2654
|
+
.eq('campaign_id', campaign_id);
|
|
2655
|
+
if (error)
|
|
2656
|
+
return j({ error: error.message });
|
|
2657
|
+
await logAudit({ action: 'remove_campaign_branch', resourceType: 'campaign', resourceId: campaign_id, details: { branch_id } }).catch(() => { });
|
|
2658
|
+
return j({ success: true, removed: branch_id });
|
|
2659
|
+
});
|
|
2660
|
+
server.registerTool('list_campaign_goals', {
|
|
2661
|
+
title: 'List Campaign Goals',
|
|
2662
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
2663
|
+
description: 'List the goals, exit conditions and stop rules set on a campaign. A goal is what the campaign is asking the contact to do: once they do it, sending stops for them and the remaining reminders are skipped. Use this to check whether a campaign will stop nagging people who already converted or finished onboarding.',
|
|
2664
|
+
inputSchema: { campaign_id: z.string().uuid() },
|
|
2665
|
+
}, async ({ campaign_id }) => {
|
|
2666
|
+
if (!(await campaignInProject(campaign_id)))
|
|
2667
|
+
return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
|
|
2668
|
+
const { data, error } = await db()
|
|
2669
|
+
.from('campaign_goals')
|
|
2670
|
+
.select('id, goal_type, goal_value, action, target_campaign_id, created_at')
|
|
2671
|
+
.eq('campaign_id', campaign_id)
|
|
2672
|
+
.order('created_at', { ascending: true });
|
|
2673
|
+
if (error)
|
|
2674
|
+
return j({ error: error.message });
|
|
2675
|
+
return j({ campaign_id, goals: data ?? [], count: (data ?? []).length });
|
|
2676
|
+
});
|
|
2677
|
+
server.registerTool('set_campaign_goal', {
|
|
2678
|
+
title: 'Set Campaign Goal',
|
|
2679
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
2680
|
+
description: 'Set the goal or exit condition for a campaign so it will stop sending to a contact once they have done the thing it was asking for. Use this to stop reminders when someone finishes onboarding, completes signup, converts, has already converted, books, or gets tagged as done. Without a goal, every enrolled contact keeps receiving every remaining step and reminder even after they already completed the action, which is the usual cause of people being nagged after converting.',
|
|
2681
|
+
inputSchema: {
|
|
2682
|
+
campaign_id: z.string().uuid(),
|
|
2683
|
+
goal_type: z.enum(CAMPAIGN_GOAL_TYPES),
|
|
2684
|
+
goal_value: z.string().min(1).describe('What counts as met, e.g. the tag name, event name, or URL'),
|
|
2685
|
+
action: z.enum(CAMPAIGN_GOAL_ACTIONS).optional().default('exit').describe('What to do once met (default: exit)'),
|
|
2686
|
+
target_campaign_id: z.string().uuid().optional().describe('Required when action is move_to_campaign'),
|
|
2687
|
+
},
|
|
2688
|
+
}, async ({ campaign_id, goal_type, goal_value, action, target_campaign_id }) => {
|
|
2689
|
+
if (!(await campaignInProject(campaign_id)))
|
|
2690
|
+
return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
|
|
2691
|
+
const resolvedAction = action ?? 'exit';
|
|
2692
|
+
if (resolvedAction === 'move_to_campaign') {
|
|
2693
|
+
if (!target_campaign_id)
|
|
2694
|
+
return j({ error: 'target_campaign_id is required for move_to_campaign', code: 'INVALID_TARGET' });
|
|
2695
|
+
if (!(await campaignInProject(target_campaign_id)))
|
|
2696
|
+
return j({ error: 'target_campaign_id not found in this project', code: 'INVALID_TARGET' });
|
|
2697
|
+
}
|
|
2698
|
+
const { data, error } = await db()
|
|
2699
|
+
.from('campaign_goals')
|
|
2700
|
+
.insert({
|
|
2701
|
+
campaign_id,
|
|
2702
|
+
goal_type,
|
|
2703
|
+
goal_value: goal_value.trim(),
|
|
2704
|
+
action: resolvedAction,
|
|
2705
|
+
target_campaign_id: resolvedAction === 'move_to_campaign' ? target_campaign_id : null,
|
|
2706
|
+
})
|
|
2707
|
+
.select('id, goal_type, goal_value, action, target_campaign_id')
|
|
2708
|
+
.single();
|
|
2709
|
+
if (error)
|
|
2710
|
+
return j({ error: error.message });
|
|
2711
|
+
await logAudit({ action: 'set_campaign_goal', resourceType: 'campaign', resourceId: campaign_id, details: { goal_id: data?.id, goal_type, goal_action: resolvedAction } }).catch(() => { });
|
|
2712
|
+
return j({ success: true, goal: data });
|
|
2713
|
+
});
|
|
2714
|
+
server.registerTool('remove_campaign_goal', {
|
|
2715
|
+
title: 'Remove Campaign Goal',
|
|
2716
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
2717
|
+
description: 'Delete a goal or exit condition from a campaign so it no longer stops early for that reason. Warning: removing the last goal means enrolled contacts will keep receiving every remaining step and reminder even after they convert or finish onboarding.',
|
|
2718
|
+
inputSchema: { campaign_id: z.string().uuid(), goal_id: z.string().uuid() },
|
|
2719
|
+
}, async ({ campaign_id, goal_id }) => {
|
|
2720
|
+
if (!(await campaignInProject(campaign_id)))
|
|
2721
|
+
return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
|
|
2722
|
+
const { error } = await db()
|
|
2723
|
+
.from('campaign_goals')
|
|
2724
|
+
.delete()
|
|
2725
|
+
.eq('id', goal_id)
|
|
2726
|
+
.eq('campaign_id', campaign_id);
|
|
2727
|
+
if (error)
|
|
2728
|
+
return j({ error: error.message });
|
|
2729
|
+
await logAudit({ action: 'remove_campaign_goal', resourceType: 'campaign', resourceId: campaign_id, details: { goal_id } }).catch(() => { });
|
|
2730
|
+
return j({ success: true, removed: goal_id });
|
|
2731
|
+
});
|
|
2531
2732
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
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",
|