@sendinel/mcp-server 1.0.141 → 1.0.142

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.
@@ -13,7 +13,7 @@ export function registerAbTestingTools(server) {
13
13
  idempotentHint: false,
14
14
  openWorldHint: false,
15
15
  },
16
- description: 'Create an A/B test for a campaign step. Requires at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
16
+ description: 'Create an A/B test for a campaign step. Requires a non-blank subject and body on every variant, and at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
17
17
  inputSchema: {
18
18
  campaign_id: z.string().uuid(),
19
19
  step_order: z.number().int().min(0).optional().default(0).describe('Which step to test (default: 0, the first email)'),
@@ -38,13 +38,20 @@ export function registerAbTestingTools(server) {
38
38
  // Check variant steps exist
39
39
  const { data: steps } = await db()
40
40
  .from('campaign_steps')
41
- .select('id, variant_label, subject')
41
+ .select('id, variant_label, subject, body_html, body_text')
42
42
  .eq('campaign_id', campaign_id)
43
43
  .eq('step_order', step_order ?? 0);
44
44
  const variantLabels = (steps ?? []).map((s) => s.variant_label);
45
45
  if (variantLabels.length < 2) {
46
46
  return txt(`Error: Need at least 2 variant steps for step_order ${step_order ?? 0}. Found: [${variantLabels.join(', ')}]. Add more variants via add_campaign_step with variant_label parameter.`);
47
47
  }
48
+ const emptyVariants = (steps ?? []).filter((step) => !step.subject?.trim() || !(step.body_html?.trim() || step.body_text?.trim()));
49
+ if (emptyVariants.length) {
50
+ return j({
51
+ error: `Variant(s) [${emptyVariants.map((step) => step.variant_label).join(', ')}] for step_order ${step_order ?? 0} need a non-blank subject and body. Add content with add_campaign_step before creating the test.`,
52
+ code: 'validation_failed',
53
+ });
54
+ }
48
55
  // Check no existing test for this step
49
56
  const { data: existing } = await db()
50
57
  .from('ab_tests')
@@ -137,6 +137,9 @@ export function buildCampaignTriggerDiagnostics(campaign, seenEvents) {
137
137
  if ((campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event') && (events.length || tags.length) && campaign.type === 'triggered') {
138
138
  diagnostics.push(`trigger_type is "${String(campaign.trigger_type)}" but event/tag triggers are also set; event intake will start it immediately, outside the ${String(campaign.trigger_type)} schedule`);
139
139
  }
140
+ if (campaign.type === 'triggered' && campaignTriggerKind(campaign) === 'manual' && !campaign.segment_id) {
141
+ diagnostics.push('campaign type is "triggered" but no trigger is set (it will never fire)');
142
+ }
140
143
  if (campaign.status !== 'active') {
141
144
  diagnostics.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
142
145
  }
@@ -855,7 +858,7 @@ export function registerCampaignTools(server) {
855
858
  .single();
856
859
  if (error)
857
860
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
858
- return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION }, null, 2) }] };
861
+ return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) }, null, 2) }] };
859
862
  });
860
863
  const propertyConditions = z.array(z.union([
861
864
  z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
@@ -869,7 +872,7 @@ export function registerCampaignTools(server) {
869
872
  idempotentHint: false,
870
873
  openWorldHint: false,
871
874
  },
872
- description: 'Add an email step to a campaign. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
875
+ description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
873
876
  inputSchema: {
874
877
  campaign_id: z.string().uuid(),
875
878
  step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
@@ -892,6 +895,16 @@ export function registerCampaignTools(server) {
892
895
  const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
893
896
  if (!properties.ok)
894
897
  return j({ error: properties.error, code: "INVALID_INPUT" });
898
+ const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
899
+ const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
900
+ if (!subject.trim())
901
+ return j({ error: 'A step needs a non-blank subject.', code: 'validation_failed' });
902
+ const hasBody = body_html?.trim() || body_text?.trim()
903
+ || htmlFromBlocks?.replace(/<[^>]*>/g, '').trim()
904
+ || normalizedBlocks?.some((block) => block.type === 'image' && block.url);
905
+ if (!hasBody) {
906
+ return j({ error: 'A step needs body_html or body_text (or blocks that render to content).', code: 'validation_failed' });
907
+ }
895
908
  const { data: campaign, error: campaignError } = await db()
896
909
  .schema('email')
897
910
  .from('campaigns')
@@ -911,8 +924,6 @@ export function registerCampaignTools(server) {
911
924
  renderVars.survey_link = surveyLink;
912
925
  }
913
926
  const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
914
- const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
915
- const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
916
927
  const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
917
928
  const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
918
929
  const { data, error } = await db()
@@ -975,12 +986,15 @@ export function registerCampaignTools(server) {
975
986
  // Placeholder text and links to the sending domain never go out (#3853).
976
987
  const { data: campRow, error: campErr } = await db()
977
988
  .from('campaigns')
978
- .select('id, name, site_id, trigger_event_name')
989
+ .select('id, name, site_id, type, trigger_type, trigger_event_name, trigger_tag, trigger_config, segment_id')
979
990
  .eq('id', campaign_id)
980
991
  .eq('project_id', getProjectId())
981
992
  .maybeSingle();
982
993
  if (campErr)
983
994
  return j({ error: `Could not check the campaign: ${campErr.message}`, code: 'CAMPAIGN_LOOKUP_FAILED' });
995
+ if (campRow?.type === 'triggered' && campaignTriggerKind(campRow) === 'manual' && !campRow.segment_id) {
996
+ return j({ error: 'This triggered campaign has no trigger. Set an event, tag, date, contact event, or segment before activating it.', code: 'validation_failed' });
997
+ }
984
998
  let contentWarnings = [];
985
999
  if (campRow) {
986
1000
  const { data: contentSteps, error: contentErr } = await db()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.141",
3
+ "version": "1.0.142",
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",