@sendinel/mcp-server 1.0.95 → 1.0.97
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/build/tools/campaigns.js +27 -8
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
4
|
import { assertSendAccepted, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
|
|
@@ -296,7 +297,6 @@ function normalizeConsistencyResult(value) {
|
|
|
296
297
|
// app/api/dashboard/campaigns/goals/route.ts
|
|
297
298
|
// If those lists change, change these. A value MCP accepts that REST rejects (or the
|
|
298
299
|
// reverse) is a divergence between two front doors onto the same engine.
|
|
299
|
-
const CAMPAIGN_BRANCH_CONDITIONS = ['tag_added', 'lifecycle_reached', 'conversion', 'link_clicked', 'custom_event', 'reply', 'click', 'booking'];
|
|
300
300
|
const CAMPAIGN_BRANCH_ACTIONS = ['goto_step', 'goto_campaign', 'exit', 'complete'];
|
|
301
301
|
const CAMPAIGN_GOAL_TYPES = ['conversion', 'tag_added', 'lifecycle_reached', 'link_clicked', 'custom_event'];
|
|
302
302
|
const CAMPAIGN_GOAL_ACTIONS = ['exit', 'complete', 'move_to_campaign'];
|
|
@@ -764,6 +764,10 @@ export function registerCampaignTools(server) {
|
|
|
764
764
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
765
765
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
766
766
|
});
|
|
767
|
+
const propertyConditions = z.array(z.union([
|
|
768
|
+
z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
|
|
769
|
+
z.object({ key: z.string(), op: z.literal('exists') }).strict(),
|
|
770
|
+
])).min(1).max(10).refine(isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR).nullable().optional();
|
|
767
771
|
server.registerTool('add_campaign_step', {
|
|
768
772
|
title: 'Add Campaign Step',
|
|
769
773
|
annotations: {
|
|
@@ -785,10 +789,15 @@ export function registerCampaignTools(server) {
|
|
|
785
789
|
variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
|
|
786
790
|
survey_id: z.string().uuid().optional(),
|
|
787
791
|
brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
|
|
792
|
+
skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
|
|
793
|
+
send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
|
|
788
794
|
skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
|
|
789
795
|
send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
|
|
790
796
|
},
|
|
791
|
-
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event }) => {
|
|
797
|
+
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
|
|
798
|
+
const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
|
|
799
|
+
if (!properties.ok)
|
|
800
|
+
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
792
801
|
const renderVars = {};
|
|
793
802
|
if (survey_id) {
|
|
794
803
|
const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
|
|
@@ -803,7 +812,7 @@ export function registerCampaignTools(server) {
|
|
|
803
812
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
804
813
|
const { data, error } = await db()
|
|
805
814
|
.from('campaign_steps')
|
|
806
|
-
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null })
|
|
815
|
+
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
|
|
807
816
|
.select()
|
|
808
817
|
.single();
|
|
809
818
|
if (error)
|
|
@@ -818,6 +827,7 @@ export function registerCampaignTools(server) {
|
|
|
818
827
|
}).catch((err) => {
|
|
819
828
|
console.warn('Failed to record brand asset usage for MCP campaign step:', err);
|
|
820
829
|
});
|
|
830
|
+
await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
|
|
821
831
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
822
832
|
});
|
|
823
833
|
server.registerTool('update_campaign_status', {
|
|
@@ -3101,7 +3111,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3101
3111
|
return j({ error: campaignErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
|
|
3102
3112
|
const { data, error } = await db()
|
|
3103
3113
|
.from('campaign_steps')
|
|
3104
|
-
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, created_at, updated_at')
|
|
3114
|
+
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, created_at, updated_at')
|
|
3105
3115
|
.eq('campaign_id', campaign_id)
|
|
3106
3116
|
.order('step_order', { ascending: true });
|
|
3107
3117
|
if (error)
|
|
@@ -3127,6 +3137,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3127
3137
|
delay_hours: z.number().int().min(0).optional(),
|
|
3128
3138
|
step_order: z.number().int().min(0).optional(),
|
|
3129
3139
|
survey_id: z.string().uuid().optional(),
|
|
3140
|
+
skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
|
|
3141
|
+
send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
|
|
3130
3142
|
skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
|
|
3131
3143
|
send_only_if_event: z.string().trim().min(1).nullable().optional().describe('Event required for this step to send; null clears the rule.'),
|
|
3132
3144
|
link_actions: z.array(z.discriminatedUnion('action', [
|
|
@@ -3143,6 +3155,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3143
3155
|
}, async ({ step_id, ...updates }) => {
|
|
3144
3156
|
const projectId = getProjectId();
|
|
3145
3157
|
const { survey_id, ...stepUpdates } = updates;
|
|
3158
|
+
const properties = validateStepPropertyRules(stepUpdates);
|
|
3159
|
+
if (!properties.ok)
|
|
3160
|
+
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
3146
3161
|
const { data: step, error: stepErr } = await db()
|
|
3147
3162
|
.from('campaign_steps')
|
|
3148
3163
|
.select('id, campaign_id')
|
|
@@ -3179,10 +3194,11 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3179
3194
|
.from('campaign_steps')
|
|
3180
3195
|
.update({ ...renderedUpdates, updated_at: new Date().toISOString() })
|
|
3181
3196
|
.eq('id', step_id)
|
|
3182
|
-
.select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, updated_at')
|
|
3197
|
+
.select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, updated_at')
|
|
3183
3198
|
.single();
|
|
3184
3199
|
if (error)
|
|
3185
3200
|
return j({ error: error.message });
|
|
3201
|
+
await logAudit({ action: 'campaign.step_updated', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
|
|
3186
3202
|
return j({ step: data, ok: true });
|
|
3187
3203
|
});
|
|
3188
3204
|
server.registerTool('delete_campaign_step', {
|
|
@@ -3537,18 +3553,21 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3537
3553
|
server.registerTool('add_campaign_branch', {
|
|
3538
3554
|
title: 'Add Campaign Branch',
|
|
3539
3555
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
3540
|
-
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.',
|
|
3556
|
+
description: 'Add an if/then rule to a campaign step so the sequence branches on what the contact does or their current custom properties. 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.',
|
|
3541
3557
|
inputSchema: {
|
|
3542
3558
|
campaign_id: z.string().uuid(),
|
|
3543
3559
|
from_step_id: z.string().uuid().describe('The step this branch leaves from'),
|
|
3544
3560
|
condition_type: z.enum(CAMPAIGN_BRANCH_CONDITIONS).describe('What triggers the branch'),
|
|
3545
|
-
condition_value: z.string().optional().describe('
|
|
3561
|
+
condition_value: z.union([z.string(), z.record(z.string(), z.unknown())]).optional().describe('Tag, URL or event name for existing conditions. For contact_property: {key, op: eq|neq, value: string} or {key, op: exists}; key matches [A-Za-z0-9_.-]{1,64}. Missing/null properties never match.'),
|
|
3546
3562
|
action: z.enum(CAMPAIGN_BRANCH_ACTIONS).describe('goto_step and goto_campaign require their matching target'),
|
|
3547
3563
|
target_step_id: z.string().uuid().optional().describe('Required when action is goto_step'),
|
|
3548
3564
|
target_campaign_id: z.string().uuid().optional().describe('Required when action is goto_campaign'),
|
|
3549
3565
|
priority: z.number().int().min(0).optional().default(0).describe('Lower runs first (default 0)'),
|
|
3550
3566
|
},
|
|
3551
3567
|
}, async ({ campaign_id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority }) => {
|
|
3568
|
+
const conditionValue = campaignBranchConditionValue(condition_type, condition_value);
|
|
3569
|
+
if (!conditionValue.ok)
|
|
3570
|
+
return { ...j({ error: conditionValue.error, code: 'INVALID_CONDITION_VALUE' }), isError: true };
|
|
3552
3571
|
if (!(await campaignInProject(campaign_id)))
|
|
3553
3572
|
return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
|
|
3554
3573
|
// Same requirement the REST route enforces: an action naming a target must have one,
|
|
@@ -3562,7 +3581,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3562
3581
|
campaign_id,
|
|
3563
3582
|
from_step_id,
|
|
3564
3583
|
condition_type,
|
|
3565
|
-
condition_value:
|
|
3584
|
+
condition_value: conditionValue.value,
|
|
3566
3585
|
action,
|
|
3567
3586
|
target_step_id: action === 'goto_step' ? target_step_id : null,
|
|
3568
3587
|
target_campaign_id: action === 'goto_campaign' ? target_campaign_id : null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.97",
|
|
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",
|