@sendinel/mcp-server 1.0.48 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,9 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /**
3
+ * Plain-language answer to "what will this campaign actually fire on?", from a saved campaign
4
+ * row. Includes a warning when the campaign cannot fire yet (#3855).
5
+ */
6
+ export declare function buildCampaignFiresOn(campaign: Record<string, unknown>): string;
2
7
  export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
3
8
  trigger_event_name?: string | null;
4
9
  trigger_tag?: string | null;
@@ -51,6 +51,45 @@ function buildCampaignTriggerConfig(value) {
51
51
  return null;
52
52
  return { events, tags, ...(Object.keys(where).length ? { where } : {}) };
53
53
  }
54
+ /**
55
+ * Plain-language answer to "what will this campaign actually fire on?", from a saved campaign
56
+ * row. Includes a warning when the campaign cannot fire yet (#3855).
57
+ */
58
+ export function buildCampaignFiresOn(campaign) {
59
+ const config = (campaign.trigger_config && typeof campaign.trigger_config === 'object' ? campaign.trigger_config : {});
60
+ const events = normalizeTriggerList([...normalizeTriggerList(config.events), campaign.trigger_event_name]);
61
+ const tags = normalizeTriggerList([...normalizeTriggerList(config.tags), campaign.trigger_tag]);
62
+ const parts = [];
63
+ if (campaign.trigger_type === 'date_field') {
64
+ const field = typeof config.field === 'string' && config.field ? config.field : null;
65
+ const days = Number(config.days_before ?? 0) || 0;
66
+ parts.push(field
67
+ ? `${days} day(s) before contact field "${field}" (${config.recurring === false ? 'once' : 'recurring'})`
68
+ : 'a contact date field, but no field is set (it will never fire)');
69
+ }
70
+ else if (campaign.trigger_type === 'contact_event') {
71
+ const name = typeof config.event_type === 'string' ? config.event_type : null;
72
+ parts.push(name
73
+ ? `${Number(config.days_after ?? 0) || 0} day(s) after event "${name}"`
74
+ : 'a contact event, but no event is set (it will never fire)');
75
+ }
76
+ if (events.length)
77
+ parts.push(`event${events.length > 1 ? 's' : ''} ${events.map((e) => `"${e}"`).join(', ')} sent to /v1/events`);
78
+ if (tags.length)
79
+ parts.push(`tag${tags.length > 1 ? 's' : ''} ${tags.map((t) => `"${t}"`).join(', ')} added`);
80
+ if (campaign.segment_id)
81
+ parts.push('contacts entering its segment');
82
+ let summary = parts.length ? `Fires on: ${parts.join('; ')}.` : 'Fires on: nothing (no trigger set).';
83
+ const warnings = [];
84
+ if ((events.length || tags.length) && campaign.type !== 'triggered') {
85
+ warnings.push(`event and tag triggers only start campaigns of type "triggered"; this one is "${String(campaign.type)}"`);
86
+ }
87
+ if (campaign.status !== 'active')
88
+ warnings.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
89
+ if (warnings.length)
90
+ summary += ` Warning: ${warnings.join('; ')}.`;
91
+ return summary;
92
+ }
54
93
  function applyCampaignSendAtField(updates, sendAt, campaignType) {
55
94
  if (sendAt === undefined)
56
95
  return null;
@@ -71,6 +110,13 @@ export async function applyCampaignTargetingFields(updates, input, projectId) {
71
110
  updates.trigger_event_name = input.trigger_event_name?.trim() || null;
72
111
  if (input.trigger_tag !== undefined)
73
112
  updates.trigger_tag = input.trigger_tag?.trim().toLowerCase() || null;
113
+ if (input.trigger_type === null || input.trigger_type === 'manual') {
114
+ // campaigns.trigger_type is NOT NULL DEFAULT 'manual', so "clear" means back to 'manual'.
115
+ // Also drops a stale date_field config unless the caller sent a new trigger_config (#3855).
116
+ updates.trigger_type = 'manual';
117
+ if (input.trigger_config === undefined)
118
+ updates.trigger_config = null;
119
+ }
74
120
  if (input.trigger_type === 'date_field') {
75
121
  const config = input.trigger_config ?? {};
76
122
  const field = typeof config.field === 'string' ? config.field.trim() : '';
@@ -3143,7 +3189,7 @@ Return ONLY valid JSON with this exact shape:
3143
3189
  idempotentHint: true,
3144
3190
  openWorldHint: false,
3145
3191
  },
3146
- description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). 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.',
3192
+ 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 trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on. 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.',
3147
3193
  inputSchema: {
3148
3194
  campaign_id: z.string().uuid(),
3149
3195
  name: z.string().min(1).max(200).optional(),
@@ -3153,33 +3199,50 @@ Return ONLY valid JSON with this exact shape:
3153
3199
  topic: z.string().nullable().optional(),
3154
3200
  trigger_event_name: z.string().nullable().optional(),
3155
3201
  trigger_tag: z.string().nullable().optional(),
3156
- trigger_type: z.enum(['date_field']).nullable().optional(),
3202
+ trigger_type: z.enum(['date_field', 'manual']).nullable().optional().describe("'date_field' fires relative to a contact date field (needs trigger_config.field). null or 'manual' clears a date_field trigger."),
3157
3203
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
3204
+ type: z.enum(['sequence', 'triggered']).optional().describe("Switch a DRAFT campaign between 'sequence' and 'triggered'. Event and tag triggers only start campaigns of type 'triggered'."),
3158
3205
  sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
3159
3206
  send_in_contact_timezone: z.boolean().optional(),
3160
3207
  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)."),
3161
3208
  },
3162
- }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone, send_class }) => {
3209
+ }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class }) => {
3163
3210
  const projectId = getProjectId();
3164
3211
  let campaignType;
3212
+ const loadExisting = () => db()
3213
+ .from('campaigns')
3214
+ .select('id, type, status')
3215
+ .eq('id', campaign_id)
3216
+ .eq('project_id', projectId)
3217
+ .maybeSingle();
3165
3218
  if (send_at !== undefined && send_at !== null) {
3166
- const { data: existing, error: fetchError } = await db()
3167
- .from('campaigns')
3168
- .select('id, type')
3169
- .eq('id', campaign_id)
3170
- .eq('project_id', projectId)
3171
- .maybeSingle();
3219
+ const { data: existing, error: fetchError } = await loadExisting();
3172
3220
  if (fetchError)
3173
3221
  return j({ error: fetchError.message });
3174
3222
  if (!existing)
3175
3223
  return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3176
3224
  campaignType = existing.type;
3177
3225
  }
3226
+ if (type !== undefined) {
3227
+ const { data: existing, error: fetchError } = await loadExisting();
3228
+ if (fetchError)
3229
+ return j({ error: fetchError.message });
3230
+ if (!existing)
3231
+ return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3232
+ if (type !== existing.type) {
3233
+ if (existing.status !== 'draft')
3234
+ return j({ error: `type can only be changed while the campaign is a draft (this one is ${existing.status})`, code: 'INVALID_FIELD' });
3235
+ if (existing.type !== 'sequence' && existing.type !== 'triggered')
3236
+ return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'INVALID_FIELD' });
3237
+ }
3238
+ }
3178
3239
  const updates = { updated_at: new Date().toISOString() };
3179
3240
  if (name !== undefined)
3180
3241
  updates.name = name;
3181
3242
  if (site_id !== undefined)
3182
3243
  updates.site_id = site_id;
3244
+ if (type !== undefined)
3245
+ updates.type = type;
3183
3246
  const scheduleError = applyCampaignSendAtField(updates, send_at, campaignType);
3184
3247
  if (scheduleError)
3185
3248
  return j({ error: scheduleError, code: 'INVALID_FIELD' });
@@ -3203,7 +3266,7 @@ Return ONLY valid JSON with this exact shape:
3203
3266
  .single();
3204
3267
  if (error)
3205
3268
  return j({ error: error.message });
3206
- return j(data);
3269
+ return j({ ...data, fires_on: buildCampaignFiresOn(data) });
3207
3270
  });
3208
3271
  server.registerTool('get_campaign', {
3209
3272
  title: 'Get Campaign',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.48",
3
+ "version": "1.0.49",
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",