@sendinel/mcp-server 1.0.6 → 1.0.8

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.
@@ -0,0 +1,116 @@
1
+ export type ComposerBlockType = 'text' | 'heading' | 'button' | 'columns' | 'image' | 'social' | 'divider' | 'spacer' | 'unsubscribe';
2
+ export type ComposerBlock = {
3
+ id?: string;
4
+ type: ComposerBlockType;
5
+ content?: string;
6
+ align?: 'left' | 'center' | 'right';
7
+ level?: 1 | 2 | 3;
8
+ label?: string;
9
+ url?: string;
10
+ bg_color?: string;
11
+ text_color?: string;
12
+ border_radius?: number;
13
+ padding?: string;
14
+ left?: string;
15
+ right?: string;
16
+ split?: '50/50' | '60/40' | '40/60';
17
+ alt?: string;
18
+ width?: number | null;
19
+ brand_asset_id?: string | null;
20
+ links?: Array<{
21
+ platform: string;
22
+ url: string;
23
+ }>;
24
+ height?: number;
25
+ };
26
+ export declare const BLOCK_GENERATION_SCHEMA: {
27
+ readonly type: "array";
28
+ readonly minItems: 2;
29
+ readonly maxItems: 12;
30
+ readonly items: {
31
+ readonly type: "object";
32
+ readonly additionalProperties: false;
33
+ readonly properties: {
34
+ readonly type: {
35
+ readonly enum: readonly ["text", "heading", "button", "columns", "image", "social", "divider", "spacer", "unsubscribe"];
36
+ };
37
+ readonly content: {
38
+ readonly type: "string";
39
+ };
40
+ readonly align: {
41
+ readonly enum: readonly ["left", "center", "right"];
42
+ };
43
+ readonly level: {
44
+ readonly enum: readonly [1, 2, 3];
45
+ };
46
+ readonly label: {
47
+ readonly type: "string";
48
+ };
49
+ readonly url: {
50
+ readonly type: "string";
51
+ };
52
+ readonly bg_color: {
53
+ readonly type: "string";
54
+ };
55
+ readonly text_color: {
56
+ readonly type: "string";
57
+ };
58
+ readonly border_radius: {
59
+ readonly type: "number";
60
+ readonly minimum: 0;
61
+ readonly maximum: 24;
62
+ };
63
+ readonly padding: {
64
+ readonly type: "string";
65
+ };
66
+ readonly left: {
67
+ readonly type: "string";
68
+ };
69
+ readonly right: {
70
+ readonly type: "string";
71
+ };
72
+ readonly split: {
73
+ readonly enum: readonly ["50/50", "60/40", "40/60"];
74
+ };
75
+ readonly alt: {
76
+ readonly type: "string";
77
+ };
78
+ readonly width: {
79
+ readonly type: readonly ["number", "null"];
80
+ readonly minimum: 1;
81
+ readonly maximum: 1200;
82
+ };
83
+ readonly brand_asset_id: {
84
+ readonly type: readonly ["string", "null"];
85
+ };
86
+ readonly links: {
87
+ readonly type: "array";
88
+ readonly maxItems: 8;
89
+ readonly items: {
90
+ readonly type: "object";
91
+ readonly additionalProperties: false;
92
+ readonly properties: {
93
+ readonly platform: {
94
+ readonly type: "string";
95
+ };
96
+ readonly url: {
97
+ readonly type: "string";
98
+ };
99
+ };
100
+ readonly required: readonly ["platform", "url"];
101
+ };
102
+ };
103
+ readonly height: {
104
+ readonly type: "number";
105
+ readonly minimum: 4;
106
+ readonly maximum: 96;
107
+ };
108
+ };
109
+ readonly required: readonly ["type"];
110
+ };
111
+ };
112
+ export declare function normalizeComposerBlocks(raw: unknown): ComposerBlock[];
113
+ export declare function blocksToHtml(blocks: ComposerBlock[], ctx?: {
114
+ preheader?: string | null;
115
+ }): string;
116
+ export declare function blocksToText(blocks: ComposerBlock[]): string;
@@ -0,0 +1,187 @@
1
+ export const BLOCK_GENERATION_SCHEMA = {
2
+ type: 'array',
3
+ minItems: 2,
4
+ maxItems: 12,
5
+ items: {
6
+ type: 'object',
7
+ additionalProperties: false,
8
+ properties: {
9
+ type: { enum: ['text', 'heading', 'button', 'columns', 'image', 'social', 'divider', 'spacer', 'unsubscribe'] },
10
+ content: { type: 'string' },
11
+ align: { enum: ['left', 'center', 'right'] },
12
+ level: { enum: [1, 2, 3] },
13
+ label: { type: 'string' },
14
+ url: { type: 'string' },
15
+ bg_color: { type: 'string' },
16
+ text_color: { type: 'string' },
17
+ border_radius: { type: 'number', minimum: 0, maximum: 24 },
18
+ padding: { type: 'string' },
19
+ left: { type: 'string' },
20
+ right: { type: 'string' },
21
+ split: { enum: ['50/50', '60/40', '40/60'] },
22
+ alt: { type: 'string' },
23
+ width: { type: ['number', 'null'], minimum: 1, maximum: 1200 },
24
+ brand_asset_id: { type: ['string', 'null'] },
25
+ links: {
26
+ type: 'array',
27
+ maxItems: 8,
28
+ items: {
29
+ type: 'object',
30
+ additionalProperties: false,
31
+ properties: {
32
+ platform: { type: 'string' },
33
+ url: { type: 'string' },
34
+ },
35
+ required: ['platform', 'url'],
36
+ },
37
+ },
38
+ height: { type: 'number', minimum: 4, maximum: 96 },
39
+ },
40
+ required: ['type'],
41
+ },
42
+ };
43
+ const BLOCK_TYPES = new Set(['text', 'heading', 'button', 'columns', 'image', 'social', 'divider', 'spacer', 'unsubscribe']);
44
+ const HEADING_SIZES = { 1: 32, 2: 24, 3: 18 };
45
+ const SOCIAL_LABELS = { twitter: 'Twitter / X', x: 'Twitter / X', linkedin: 'LinkedIn', facebook: 'Facebook', instagram: 'Instagram', youtube: 'YouTube', website: 'Website' };
46
+ function cleanText(value, max = 2000) {
47
+ return typeof value === 'string' ? value.replace(/\u2014/g, ' - ').trim().slice(0, max) : '';
48
+ }
49
+ function escapeHtml(value) {
50
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
51
+ }
52
+ function escapeAttr(value) {
53
+ return escapeHtml(value).replace(/`/g, '&#96;');
54
+ }
55
+ function cleanAlign(value) {
56
+ return value === 'center' || value === 'right' ? value : 'left';
57
+ }
58
+ function cleanUrl(value) {
59
+ const url = cleanText(value, 2000);
60
+ if (/^https?:\/\//i.test(url))
61
+ return url;
62
+ if (/^mailto:[^\s<>"]+@[^\s<>"]+$/i.test(url))
63
+ return url;
64
+ if (/^\{\{[A-Za-z0-9_.-]+\}\}$/.test(url))
65
+ return url;
66
+ return '';
67
+ }
68
+ function cleanImageUrl(value) {
69
+ const url = cleanText(value, 2000);
70
+ return /^https:\/\//i.test(url) ? url : '';
71
+ }
72
+ function cleanColor(value, fallback) {
73
+ const color = cleanText(value, 32);
74
+ return /^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/.test(color) ? color : fallback;
75
+ }
76
+ function cleanPadding(value) {
77
+ const padding = cleanText(value, 80);
78
+ return /^([0-9]{1,2}px)(\s+[0-9]{1,2}px){0,3}$/.test(padding) ? padding : '12px 28px';
79
+ }
80
+ function cleanRichText(value, max = 2000) {
81
+ return cleanText(value, max)
82
+ .replace(/<\s*(script|style)[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
83
+ .replace(/<a\b[^>]*\bhref\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi, (tag, _quote, href, label) => cleanUrl(href) ? tag : String(label))
84
+ .replace(/<\s*\/?\s*([a-z0-9]+)([^>]*)>/gi, (tag, rawName, rawAttrs) => {
85
+ const name = String(rawName).toLowerCase();
86
+ if (['strong', 'b', 'em', 'i', 'u'].includes(name))
87
+ return tag.startsWith('</') ? `</${name}>` : `<${name}>`;
88
+ if (name === 'br')
89
+ return '<br>';
90
+ if (name === 'a') {
91
+ if (tag.startsWith('</'))
92
+ return '</a>';
93
+ const href = cleanUrl(String(rawAttrs).match(/\bhref\s*=\s*(["'])(.*?)\1/i)?.[2]);
94
+ return href ? `<a href="${escapeAttr(href)}" target="_blank" style="color:var(--color-editor-link)">` : '';
95
+ }
96
+ return escapeHtml(tag);
97
+ });
98
+ }
99
+ function splitWidths(split) {
100
+ if (split === '60/40')
101
+ return [60, 40];
102
+ if (split === '40/60')
103
+ return [40, 60];
104
+ return [50, 50];
105
+ }
106
+ export function normalizeComposerBlocks(raw) {
107
+ if (!Array.isArray(raw))
108
+ return [];
109
+ return raw.slice(0, 12).flatMap((item, index) => {
110
+ if (!item || typeof item !== 'object' || !BLOCK_TYPES.has(String(item.type)))
111
+ return [];
112
+ const block = item;
113
+ const type = String(block.type);
114
+ const base = { id: cleanText(block.id, 80) || `ai-block-${index + 1}`, type };
115
+ if (type === 'text')
116
+ return [{ ...base, content: cleanRichText(block.content), align: cleanAlign(block.align) }];
117
+ if (type === 'heading')
118
+ return [{ ...base, content: cleanText(block.content, 300), level: ([1, 2, 3].includes(Number(block.level)) ? Number(block.level) : 2), align: cleanAlign(block.align) }];
119
+ if (type === 'button')
120
+ return [{ ...base, label: cleanText(block.label, 120) || 'Learn more', url: cleanUrl(block.url) || '{{cta_url}}', align: cleanAlign(block.align) === 'left' ? 'center' : cleanAlign(block.align), bg_color: cleanColor(block.bg_color, '#00BFFF'), text_color: cleanColor(block.text_color, '#ffffff'), border_radius: Math.max(0, Math.min(24, Number(block.border_radius ?? 6))), padding: cleanPadding(block.padding) }];
121
+ if (type === 'columns')
122
+ return [{ ...base, left: cleanRichText(block.left), right: cleanRichText(block.right), split: block.split === '60/40' || block.split === '40/60' ? block.split : '50/50' }];
123
+ if (type === 'image') {
124
+ const url = cleanImageUrl(block.url);
125
+ return url ? [{ ...base, url, alt: cleanText(block.alt, 300), width: block.width == null ? null : Math.max(1, Math.min(1200, Number(block.width))), brand_asset_id: cleanText(block.brand_asset_id, 80) || null }] : [];
126
+ }
127
+ if (type === 'social') {
128
+ const links = Array.isArray(block.links)
129
+ ? block.links.flatMap((link) => {
130
+ if (!link || typeof link !== 'object')
131
+ return [];
132
+ const platform = cleanText(link.platform, 80).toLowerCase();
133
+ const url = cleanUrl(link.url);
134
+ return platform && url ? [{ platform, url }] : [];
135
+ })
136
+ : [];
137
+ return [{ ...base, align: cleanAlign(block.align), links }];
138
+ }
139
+ if (type === 'spacer')
140
+ return [{ ...base, height: Math.max(4, Math.min(96, Number(block.height ?? 24))) }];
141
+ return [base];
142
+ });
143
+ }
144
+ export function blocksToHtml(blocks, ctx) {
145
+ const body = normalizeComposerBlocks(blocks).map((b) => {
146
+ if (b.type === 'text')
147
+ return String(b.content ?? '').split(/\n\n+/).filter((p) => p.trim()).map((p) => p.trim() === '---' ? '<hr style="border:none;border-top:1px solid #374151;margin:24px 0;" />' : `<p style="margin-bottom:16px;line-height:1.6;text-align:${cleanAlign(b.align)};">${p.trim()}</p>`).join('\n');
148
+ if (b.type === 'heading') {
149
+ const level = [1, 2, 3].includes(Number(b.level)) ? Number(b.level) : 2;
150
+ return `<h${level} style="font-family:Arial,sans-serif;font-size:${HEADING_SIZES[level]}px;font-weight:700;line-height:1.2;margin:0 0 16px;color:#111827;text-align:${cleanAlign(b.align)};">${escapeHtml(b.content || 'Your heading here')}</h${level}>`;
151
+ }
152
+ if (b.type === 'button')
153
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="${cleanAlign(b.align || 'center')}"><a href="${escapeAttr(b.url || '{{cta_url}}')}" target="_blank" style="display:inline-block;padding:${escapeAttr(b.padding || '12px 28px')};background:${escapeAttr(b.bg_color || '#00BFFF')};color:${escapeAttr(b.text_color || '#ffffff')};font-weight:600;text-decoration:none;border-radius:${Math.max(0, Math.min(24, Number(b.border_radius ?? 6)))}px;font-family:Arial,sans-serif;font-size:15px;line-height:1;">${escapeHtml(b.label || 'Get Started')}</a></td></tr></table>`;
154
+ if (b.type === 'columns') {
155
+ const [left, right] = splitWidths(b.split);
156
+ return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="${left}%" valign="top" style="padding-right:12px;">${b.left || ''}</td><td width="${right}%" valign="top" style="padding-left:12px;">${b.right || ''}</td></tr></table>`;
157
+ }
158
+ if (b.type === 'social') {
159
+ const items = (b.links ?? []).map((link) => `<a href="${escapeAttr(link.url)}" target="_blank" style="display:inline-block;margin:0 6px;color:#6b7280;font-size:12px;text-decoration:none;font-family:Arial,sans-serif;">${escapeHtml(SOCIAL_LABELS[link.platform] || link.platform)}</a>`).join('');
160
+ return items ? `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="${cleanAlign(b.align || 'center')}" style="padding:16px 0;">${items}</td></tr></table>` : '';
161
+ }
162
+ if (b.type === 'divider')
163
+ return '<hr style="border:none;border-top:1px solid #374151;margin:24px 0;" />';
164
+ if (b.type === 'spacer')
165
+ return `<div style="height:${Number(b.height || 20)}px;"></div>`;
166
+ if (b.type === 'image' && b.url)
167
+ return `<img src="${escapeAttr(b.url)}" alt="${escapeAttr(b.alt || '')}"${b.brand_asset_id ? ` data-brand-asset-id="${escapeAttr(b.brand_asset_id)}"` : ''} style="max-width:100%;height:auto;display:block;${b.width ? `width:${Number(b.width)}px;` : ''}margin-bottom:16px;" />`;
168
+ if (b.type === 'unsubscribe')
169
+ return '<p style="margin-bottom:0;font-size:12px;color:#6b7280;text-align:center;"><a href="{{unsubscribe_url}}" style="color:#6b7280;">Unsubscribe</a></p>';
170
+ return '';
171
+ }).join('\n');
172
+ const preheader = ctx?.preheader ? `<span style="display:none!important;opacity:0;color:transparent;height:0;width:0;overflow:hidden;">${escapeHtml(ctx.preheader)}</span>` : '';
173
+ return `${preheader}${body}`;
174
+ }
175
+ export function blocksToText(blocks) {
176
+ return normalizeComposerBlocks(blocks).map((block) => {
177
+ if (block.type === 'heading' || block.type === 'text')
178
+ return block.content;
179
+ if (block.type === 'button')
180
+ return [block.label, block.url].filter(Boolean).join(': ');
181
+ if (block.type === 'columns')
182
+ return [block.left, block.right].filter(Boolean).join('\n\n');
183
+ if (block.type === 'image')
184
+ return block.alt ? `[Image: ${block.alt}]` : '';
185
+ return '';
186
+ }).filter(Boolean).join('\n\n').replace(/<[^>]+>/g, ' ').replace(/\s+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
187
+ }
@@ -111,7 +111,7 @@ export function registerAbTestingTools(server) {
111
111
  server.registerTool('check_ab_significance', {
112
112
  title: 'Check A/B Significance',
113
113
  annotations: {
114
- readOnlyHint: true,
114
+ readOnlyHint: false,
115
115
  destructiveHint: false,
116
116
  idempotentHint: true,
117
117
  openWorldHint: false,
@@ -295,7 +295,7 @@ export function registerAdvisorTools(server) {
295
295
  server.registerTool('optimize_subject_lines', {
296
296
  title: 'Optimize Subject Lines',
297
297
  annotations: {
298
- readOnlyHint: true,
298
+ readOnlyHint: false,
299
299
  destructiveHint: false,
300
300
  idempotentHint: true,
301
301
  openWorldHint: true,
@@ -448,7 +448,7 @@ export function registerAnalyticsTools(server) {
448
448
  readOnlyHint: true,
449
449
  destructiveHint: false,
450
450
  idempotentHint: true,
451
- openWorldHint: true,
451
+ openWorldHint: false,
452
452
  },
453
453
  description: 'SPF/DKIM/DMARC status and warmup progress for a site',
454
454
  inputSchema: {
@@ -158,9 +158,9 @@ export function registerAutomationTools(server) {
158
158
  title: 'Enroll Automation',
159
159
  annotations: {
160
160
  readOnlyHint: false,
161
- destructiveHint: false,
161
+ destructiveHint: true,
162
162
  idempotentHint: false,
163
- openWorldHint: false,
163
+ openWorldHint: true,
164
164
  },
165
165
  description: 'Enroll a subscriber into active triggered automation campaigns. Match by campaign_id, trigger_event_name, or trigger_tag. Safe to call repeatedly.',
166
166
  inputSchema: {
@@ -239,9 +239,9 @@ export function registerAutomationTools(server) {
239
239
  title: 'Enroll In Escalation Ladder',
240
240
  annotations: {
241
241
  readOnlyHint: false,
242
- destructiveHint: false,
242
+ destructiveHint: true,
243
243
  idempotentHint: false,
244
- openWorldHint: false,
244
+ openWorldHint: true,
245
245
  },
246
246
  description: 'Enroll a subscriber into an active triggered campaign that has escalation ladder steps. Reuses enroll_automation suppression and unsubscribe checks.',
247
247
  inputSchema: {
@@ -149,7 +149,7 @@ export function registerBrandKitTools(server) {
149
149
  readOnlyHint: false,
150
150
  destructiveHint: false,
151
151
  idempotentHint: false,
152
- openWorldHint: true,
152
+ openWorldHint: false,
153
153
  },
154
154
  description: 'Upload a file to the current project\'s media library directly (base64-encoded bytes). For assets already hosted at a public HTTPS URL, use ingest_brand_assets_by_url instead. Video uploads are not supported via MCP yet.',
155
155
  inputSchema: {
@@ -12,6 +12,7 @@ import { sendEmailForSite } from '../lib/email-provider.js';
12
12
  import { renderTemplateString, resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
13
13
  import { logAudit } from '../audit.js';
14
14
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
15
+ import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
15
16
  const txt = (text) => ({ content: [{ type: 'text', text }] });
16
17
  const j = (data) => txt(JSON.stringify(data, null, 2));
17
18
  function normalizeTriggerValue(value) {
@@ -212,10 +213,10 @@ export function registerCampaignTools(server) {
212
213
  server.registerTool('review_campaign_portfolio', {
213
214
  title: 'Review Campaign Portfolio',
214
215
  annotations: {
215
- readOnlyHint: true,
216
+ readOnlyHint: false,
216
217
  destructiveHint: false,
217
218
  idempotentHint: true,
218
- openWorldHint: false,
219
+ openWorldHint: true,
219
220
  },
220
221
  description: 'Run the campaign portfolio review and optionally raise agent proposals for start/end/modify recommendations.',
221
222
  inputSchema: {
@@ -444,13 +445,14 @@ export function registerCampaignTools(server) {
444
445
  subject: z.string().min(1),
445
446
  body_html: z.string().optional(),
446
447
  body_text: z.string().optional(),
448
+ blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
447
449
  delay_days: z.number().int().min(0).optional().default(0),
448
450
  delay_hours: z.number().int().min(0).max(23).optional().default(0),
449
451
  variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
450
452
  survey_id: z.string().uuid().optional(),
451
453
  brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
452
454
  },
453
- }, async ({ campaign_id, step_order, subject, body_html, body_text, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
455
+ }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids }) => {
454
456
  const renderVars = {};
455
457
  if (survey_id) {
456
458
  const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
@@ -459,11 +461,13 @@ export function registerCampaignTools(server) {
459
461
  renderVars.survey_link = surveyLink;
460
462
  }
461
463
  const renderedSubject = survey_id ? renderTemplateString(subject, renderVars) : subject;
462
- const renderedHtml = body_html !== undefined && survey_id ? renderTemplateString(body_html, renderVars) : body_html;
464
+ const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
465
+ const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
466
+ const renderedHtml = body_html !== undefined && survey_id ? renderTemplateString(body_html, renderVars) : body_html ?? htmlFromBlocks;
463
467
  const renderedText = body_text !== undefined && survey_id ? renderTemplateString(body_text, renderVars) : body_text;
464
468
  const { data, error } = await db()
465
469
  .from('campaign_steps')
466
- .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
470
+ .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' })
467
471
  .select()
468
472
  .single();
469
473
  if (error)
@@ -484,7 +488,7 @@ export function registerCampaignTools(server) {
484
488
  title: 'Update Campaign Status',
485
489
  annotations: {
486
490
  readOnlyHint: false,
487
- destructiveHint: false,
491
+ destructiveHint: true,
488
492
  idempotentHint: true,
489
493
  openWorldHint: true,
490
494
  },
@@ -538,9 +542,9 @@ export function registerCampaignTools(server) {
538
542
  title: 'Enroll Contact',
539
543
  annotations: {
540
544
  readOnlyHint: false,
541
- destructiveHint: false,
545
+ destructiveHint: true,
542
546
  idempotentHint: false,
543
- openWorldHint: false,
547
+ openWorldHint: true,
544
548
  },
545
549
  description: 'Enroll a single contact in a campaign',
546
550
  inputSchema: {
@@ -645,9 +649,9 @@ export function registerCampaignTools(server) {
645
649
  title: 'Enroll Segment',
646
650
  annotations: {
647
651
  readOnlyHint: false,
648
- destructiveHint: false,
652
+ destructiveHint: true,
649
653
  idempotentHint: false,
650
- openWorldHint: false,
654
+ openWorldHint: true,
651
655
  },
652
656
  description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
653
657
  inputSchema: {
@@ -956,6 +960,7 @@ export function registerCampaignTools(server) {
956
960
  subject: s.subject,
957
961
  body_html: s.body_html,
958
962
  body_text: s.body_text,
963
+ blocks: s.blocks ?? null,
959
964
  delay_days: s.delay_days,
960
965
  delay_hours: s.delay_hours,
961
966
  }));
@@ -973,14 +978,15 @@ export function registerCampaignTools(server) {
973
978
  idempotentHint: true,
974
979
  openWorldHint: true,
975
980
  },
976
- description: 'Generate email content (subject, HTML, plain text) from a brief using AI. Optionally provide contact_id to adapt tone based on recipient engagement data.',
981
+ description: 'Generate email content as editable Composer blocks plus rendered HTML/plain text from a brief using AI. Optionally provide contact_id to adapt tone based on recipient engagement data.',
977
982
  inputSchema: {
978
983
  site_id: z.string().uuid(),
979
984
  brief: z.string().min(1).describe('Description of the email to generate'),
980
985
  format: z.enum(['both', 'html', 'text']).optional().describe('Output format, default both'),
981
986
  contact_id: z.string().uuid().optional().describe('Optional contact UUID — when provided, AI adapts tone based on their engagement level and history'),
987
+ content_recommendation: z.unknown().optional().describe('Optional topic evidence from get_campaign_content_recommendations or another recommendation source.'),
982
988
  },
983
- }, async ({ site_id, brief, format = 'both', contact_id }) => {
989
+ }, async ({ site_id, brief, format = 'both', contact_id, content_recommendation }) => {
984
990
  const apiKey = process.env.ANTHROPIC_API_KEY;
985
991
  if (!apiKey)
986
992
  return txt('Error: ANTHROPIC_API_KEY not configured');
@@ -1012,7 +1018,26 @@ export function registerCampaignTools(server) {
1012
1018
  ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name} <${site.from_email}>`
1013
1019
  : 'No brand info available';
1014
1020
  const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
1015
- let systemPrompt = buildEmailSystemPrompt(brandContext, voicePrompt);
1021
+ let systemPrompt = `${buildEmailSystemPrompt(brandContext, voicePrompt)}
1022
+
1023
+ Generate structured Composer blocks first. Return ONLY valid JSON with:
1024
+ {
1025
+ "execution_trace": object,
1026
+ "conclusion": {
1027
+ "subject": string,
1028
+ "preview_text": string,
1029
+ "blocks": ComposerBlock[],
1030
+ "body_text": string,
1031
+ "cta_text": string,
1032
+ "reasoning_log": string,
1033
+ "brand_voice_compliance": "PASS" | string
1034
+ }
1035
+ }
1036
+
1037
+ ComposerBlock[] must conform to this JSON Schema:
1038
+ ${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
1039
+
1040
+ Do not include body_html in your response; Sendinel renders body_html from blocks.`;
1016
1041
  // Inject per-contact context when available
1017
1042
  if (contact) {
1018
1043
  const lastActivity = contact.last_opened_at
@@ -1035,14 +1060,28 @@ Adapt your tone accordingly:
1035
1060
  const anthropic = new Anthropic({ apiKey });
1036
1061
  const genResponse = await anthropic.messages.create({
1037
1062
  model: getAnthropicModelId(),
1038
- max_tokens: 2048,
1063
+ max_tokens: 3000,
1039
1064
  system: systemPrompt,
1040
- messages: [{ role: 'user', content: brief }],
1065
+ messages: [{ role: 'user', content: content_recommendation ? `${brief}\n\nContent recommendation evidence:\n${typeof content_recommendation === 'string' ? content_recommendation : JSON.stringify(content_recommendation, null, 2)}` : brief }],
1041
1066
  });
1042
1067
  const textBlock = genResponse.content.find((b) => b.type === 'text');
1043
1068
  const raw = textBlock && 'text' in textBlock ? textBlock.text : '{}';
1044
1069
  try {
1045
- const result = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
1070
+ const parsed = JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
1071
+ const conclusion = parsed.conclusion ?? parsed;
1072
+ const blocks = normalizeComposerBlocks(conclusion.blocks);
1073
+ if (!blocks.length)
1074
+ return txt(`Error: Generated email did not include Composer blocks. AI returned: ${raw.slice(0, 200)}`);
1075
+ const result = {
1076
+ subject: conclusion.subject,
1077
+ preview_text: conclusion.preview_text,
1078
+ body_html: blocksToHtml(blocks, { preheader: typeof conclusion.preview_text === 'string' ? conclusion.preview_text : null }),
1079
+ body_text: typeof conclusion.body_text === 'string' && conclusion.body_text.trim() ? conclusion.body_text : blocksToText(blocks),
1080
+ blocks,
1081
+ cta_text: conclusion.cta_text,
1082
+ reasoning_log: conclusion.reasoning_log,
1083
+ brand_voice_compliance: conclusion.brand_voice_compliance,
1084
+ };
1046
1085
  if (format === 'html')
1047
1086
  delete result.body_text;
1048
1087
  if (format === 'text')
@@ -1283,13 +1322,32 @@ Adapt your tone accordingly:
1283
1322
  .maybeSingle();
1284
1323
  const brandCtx = site ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name}` : '';
1285
1324
  const voicePrompt = buildVoicePrompt(site?.brand_voice);
1286
- const systemPrompt = buildEmailSystemPrompt(brandCtx, voicePrompt);
1325
+ const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt)}
1326
+
1327
+ Generate structured Composer blocks first. Return ONLY valid JSON with:
1328
+ {
1329
+ "execution_trace": object,
1330
+ "conclusion": {
1331
+ "subject": string,
1332
+ "preview_text": string,
1333
+ "blocks": ComposerBlock[],
1334
+ "body_text": string,
1335
+ "cta_text": string,
1336
+ "reasoning_log": string,
1337
+ "brand_voice_compliance": "PASS" | string
1338
+ }
1339
+ }
1340
+
1341
+ ComposerBlock[] must conform to this JSON Schema:
1342
+ ${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
1343
+
1344
+ Do not include body_html in your response; Sendinel renders body_html from blocks.`;
1287
1345
  const steps = [];
1288
1346
  for (let i = 0; i < emails.length; i++) {
1289
1347
  const email = emails[i];
1290
1348
  const genResp = await anthropic.messages.create({
1291
1349
  model: getAnthropicModelId(),
1292
- max_tokens: 2048,
1350
+ max_tokens: 3000,
1293
1351
  system: systemPrompt,
1294
1352
  messages: [{ role: 'user', content: email.brief }],
1295
1353
  });
@@ -1297,7 +1355,17 @@ Adapt your tone accordingly:
1297
1355
  const rawEmail = gtb && 'text' in gtb ? gtb.text : '{}';
1298
1356
  let emailContent;
1299
1357
  try {
1300
- emailContent = JSON.parse(rawEmail.replace(/```json?\n?|```/g, '').trim());
1358
+ const parsed = JSON.parse(rawEmail.replace(/```json?\n?|```/g, '').trim());
1359
+ const conclusion = parsed.conclusion ?? parsed;
1360
+ const blocks = normalizeComposerBlocks(conclusion.blocks);
1361
+ emailContent = blocks.length
1362
+ ? {
1363
+ subject: conclusion.subject,
1364
+ body_html: blocksToHtml(blocks, { preheader: typeof conclusion.preview_text === 'string' ? conclusion.preview_text : null }),
1365
+ body_text: typeof conclusion.body_text === 'string' && conclusion.body_text.trim() ? conclusion.body_text : blocksToText(blocks),
1366
+ blocks,
1367
+ }
1368
+ : parsed;
1301
1369
  }
1302
1370
  catch {
1303
1371
  emailContent = { subject: `Step ${i + 1}`, body_html: '<p>' + email.brief + '</p>', body_text: email.brief };
@@ -1306,7 +1374,8 @@ Adapt your tone accordingly:
1306
1374
  .from('campaign_steps')
1307
1375
  .insert({
1308
1376
  campaign_id: campaign.id, step_order: i,
1309
- subject: emailContent.subject, body_html: emailContent.body_html, body_text: emailContent.body_text,
1377
+ subject: String(emailContent.subject ?? `Step ${i + 1}`), body_html: emailContent.body_html, body_text: emailContent.body_text,
1378
+ blocks: Array.isArray(emailContent.blocks) ? emailContent.blocks : null,
1310
1379
  delay_days: email.delay_days ?? 0, delay_hours: email.delay_hours ?? 0,
1311
1380
  })
1312
1381
  .select('id, step_order, subject')
@@ -1625,7 +1694,7 @@ Adapt your tone accordingly:
1625
1694
  server.registerTool('check_content_consistency', {
1626
1695
  title: 'Check Content Consistency',
1627
1696
  annotations: {
1628
- readOnlyHint: true,
1697
+ readOnlyHint: false,
1629
1698
  destructiveHint: false,
1630
1699
  idempotentHint: true,
1631
1700
  openWorldHint: true,
@@ -1703,7 +1772,7 @@ Return ONLY valid JSON with this exact shape:
1703
1772
  title: 'Create Campaign With Content',
1704
1773
  annotations: {
1705
1774
  readOnlyHint: false,
1706
- destructiveHint: false,
1775
+ destructiveHint: true,
1707
1776
  idempotentHint: false,
1708
1777
  openWorldHint: true,
1709
1778
  },
@@ -1716,6 +1785,7 @@ Return ONLY valid JSON with this exact shape:
1716
1785
  subject: z.string().min(1).describe('Email subject line'),
1717
1786
  body_html: z.string().min(1).describe('Full HTML email body'),
1718
1787
  body_text: z.string().optional().describe('Plain text fallback'),
1788
+ blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
1719
1789
  delay_days: z.number().int().min(0).optional().default(0),
1720
1790
  delay_hours: z.number().int().min(0).max(23).optional().default(0),
1721
1791
  })).min(1).describe('Email steps with pre-built content'),
@@ -1813,6 +1883,7 @@ Return ONLY valid JSON with this exact shape:
1813
1883
  subject: s.subject,
1814
1884
  body_html: s.body_html,
1815
1885
  body_text: s.body_text ?? null,
1886
+ blocks: s.blocks ? normalizeComposerBlocks(s.blocks) : null,
1816
1887
  delay_days: s.delay_days ?? 0,
1817
1888
  delay_hours: s.delay_hours ?? 0,
1818
1889
  }));
@@ -1906,7 +1977,7 @@ Return ONLY valid JSON with this exact shape:
1906
1977
  return j({ error: campaignErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
1907
1978
  const { data, error } = await db()
1908
1979
  .from('campaign_steps')
1909
- .select('id, step_order, subject, body_html, body_text, delay_days, delay_hours, created_at, updated_at')
1980
+ .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, created_at, updated_at')
1910
1981
  .eq('campaign_id', campaign_id)
1911
1982
  .order('step_order', { ascending: true });
1912
1983
  if (error)
@@ -1927,6 +1998,7 @@ Return ONLY valid JSON with this exact shape:
1927
1998
  subject: z.string().optional(),
1928
1999
  body_html: z.string().optional(),
1929
2000
  body_text: z.string().optional(),
2001
+ blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
1930
2002
  delay_days: z.number().int().min(0).optional(),
1931
2003
  delay_hours: z.number().int().min(0).optional(),
1932
2004
  step_order: z.number().int().min(0).optional(),
@@ -1957,8 +2029,12 @@ Return ONLY valid JSON with this exact shape:
1957
2029
  return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
1958
2030
  renderVars.survey_link = surveyLink;
1959
2031
  }
2032
+ const normalizedBlocks = Array.isArray(stepUpdates.blocks) ? normalizeComposerBlocks(stepUpdates.blocks) : undefined;
1960
2033
  const renderedUpdates = {
1961
2034
  ...stepUpdates,
2035
+ ...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
2036
+ ...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
2037
+ ...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
1962
2038
  ...(survey_id && stepUpdates.subject !== undefined ? { subject: renderTemplateString(stepUpdates.subject, renderVars) } : {}),
1963
2039
  ...(survey_id && stepUpdates.body_html !== undefined ? { body_html: renderTemplateString(stepUpdates.body_html, renderVars) } : {}),
1964
2040
  ...(survey_id && stepUpdates.body_text !== undefined ? { body_text: renderTemplateString(stepUpdates.body_text, renderVars) } : {}),
@@ -4,6 +4,7 @@ import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { buildVoicePrompt } from '../lib/voice.js';
6
6
  import { getAnthropicFastModelId } from '../anthropic-model.js';
7
+ import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
7
8
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
8
9
  function getAnthropicClient() {
9
10
  const apiKey = process.env.ANTHROPIC_API_KEY;
@@ -65,15 +66,30 @@ BRAND VOICE: ${voiceContext}
65
66
 
66
67
  Generate exactly ${num_steps} emails. For each email, provide:
67
68
  - subject
68
- - body_html
69
+ - blocks (ComposerBlock[])
69
70
  - body_text
70
71
  - delay_hours_from_previous (0 for the first email, ${step_delay_hours} for subsequent ones)
71
72
 
73
+ ComposerBlock[] must conform to this JSON Schema:
74
+ ${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
75
+
76
+ Do not include body_html. Sendinel renders body_html from blocks.
72
77
  Respond with a JSON array.`,
73
78
  }],
74
79
  });
75
80
  const raw = msg.content[0].text;
76
- steps = extractJsonArray(raw);
81
+ steps = extractJsonArray(raw).map((step, index) => {
82
+ const blocks = normalizeComposerBlocks(step.blocks);
83
+ return {
84
+ subject: typeof step.subject === 'string' ? step.subject : `Step ${index + 1}`,
85
+ body_html: blocks.length ? blocksToHtml(blocks) : '',
86
+ body_text: typeof step.body_text === 'string' && step.body_text.trim() ? step.body_text : blocksToText(blocks),
87
+ blocks,
88
+ delay_hours_from_previous: Number(step.delay_hours_from_previous ?? (index === 0 ? 0 : step_delay_hours)),
89
+ };
90
+ });
91
+ if (steps.some((step) => !step.blocks?.length))
92
+ throw new Error('AI response did not include editable Composer blocks for every step');
77
93
  }
78
94
  catch (error) {
79
95
  return j({ error: `AI generation failed: ${error instanceof Error ? error.message : String(error)}`, code: 'GENERATION_FAILED' });
@@ -108,6 +124,7 @@ Respond with a JSON array.`,
108
124
  subject: step.subject,
109
125
  body_html: step.body_html,
110
126
  body_text: step.body_text,
127
+ blocks: step.blocks ?? null,
111
128
  delay_days: 0,
112
129
  delay_hours: step.delay_hours_from_previous,
113
130
  template_id: String(template.id),
@@ -158,11 +175,23 @@ Respond with a JSON array.`,
158
175
  content: `Write an email for "${site?.name ?? 'our product'}".
159
176
  BRIEF: ${brief}
160
177
  BRAND VOICE: ${buildVoicePrompt(site?.brand_voice)}
161
- Respond with JSON: { "subject": "...", "body_html": "...", "body_text": "..." }`,
178
+ Generate structured Composer blocks first. ComposerBlock[] must conform to this JSON Schema:
179
+ ${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
180
+ Do not include body_html. Sendinel renders body_html from blocks.
181
+ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
162
182
  }],
163
183
  });
164
184
  const raw = msg.content[0].text;
165
- generated = extractJsonObject(raw);
185
+ const parsed = extractJsonObject(raw);
186
+ const blocks = normalizeComposerBlocks(parsed.blocks);
187
+ if (!blocks.length)
188
+ throw new Error('AI response did not include editable Composer blocks');
189
+ generated = {
190
+ subject: parsed.subject,
191
+ body_html: blocksToHtml(blocks),
192
+ body_text: parsed.body_text || blocksToText(blocks),
193
+ blocks,
194
+ };
166
195
  }
167
196
  catch (error) {
168
197
  return j({ error: `Generation failed: ${error instanceof Error ? error.message : String(error)}` });
@@ -1437,8 +1437,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1437
1437
  server.registerTool('identify_inactive', {
1438
1438
  title: 'Identify Inactive',
1439
1439
  annotations: {
1440
- readOnlyHint: true,
1441
- destructiveHint: false,
1440
+ readOnlyHint: false,
1441
+ destructiveHint: true,
1442
1442
  idempotentHint: true,
1443
1443
  openWorldHint: false,
1444
1444
  },
@@ -206,7 +206,7 @@ export function registerContentTools(server) {
206
206
  title: 'Create Blog Post',
207
207
  annotations: {
208
208
  readOnlyHint: false,
209
- destructiveHint: false,
209
+ destructiveHint: true,
210
210
  idempotentHint: false,
211
211
  openWorldHint: true,
212
212
  },
@@ -268,7 +268,7 @@ export function registerDataTools(server) {
268
268
  readOnlyHint: false,
269
269
  destructiveHint: false,
270
270
  idempotentHint: false,
271
- openWorldHint: true,
271
+ openWorldHint: false,
272
272
  },
273
273
  description: 'Store encrypted credentials for an external platform connection and mark it ready for trigger configuration.',
274
274
  inputSchema: {
@@ -739,7 +739,7 @@ export function registerDataTools(server) {
739
739
  server.registerTool('tenant_export_full', {
740
740
  title: 'Tenant Export Full',
741
741
  annotations: {
742
- readOnlyHint: true,
742
+ readOnlyHint: false,
743
743
  destructiveHint: false,
744
744
  idempotentHint: true,
745
745
  openWorldHint: false,
@@ -9,9 +9,9 @@ export function registerDeliveryOpsTools(server) {
9
9
  readOnlyHint: true,
10
10
  destructiveHint: false,
11
11
  idempotentHint: true,
12
- openWorldHint: true,
12
+ openWorldHint: false,
13
13
  },
14
- description: 'Refresh and return stored DNS verification status for a sending domain.',
14
+ description: 'Return stored DNS verification status for a sending domain. This does not perform a live DNS refresh.',
15
15
  inputSchema: { domain_id: z.string().uuid() },
16
16
  }, async ({ domain_id }) => {
17
17
  const { data, error } = await db().from('domains')
@@ -35,7 +35,7 @@ export function registerDeliveryOpsTools(server) {
35
35
  readOnlyHint: true,
36
36
  destructiveHint: false,
37
37
  idempotentHint: true,
38
- openWorldHint: true,
38
+ openWorldHint: false,
39
39
  },
40
40
  description: 'Return the latest DNSBL snapshot for all monitored zones for a site sending domain.',
41
41
  inputSchema: {
@@ -77,7 +77,7 @@ export function registerDeliveryOpsTools(server) {
77
77
  readOnlyHint: true,
78
78
  destructiveHint: false,
79
79
  idempotentHint: true,
80
- openWorldHint: true,
80
+ openWorldHint: false,
81
81
  },
82
82
  description: 'Draft a DNSBL delisting ticket using tenant send metrics as evidence. Does not auto-submit.',
83
83
  inputSchema: {
@@ -432,9 +432,9 @@ export function registerDeliveryOpsTools(server) {
432
432
  readOnlyHint: false,
433
433
  destructiveHint: false,
434
434
  idempotentHint: false,
435
- openWorldHint: true,
435
+ openWorldHint: false,
436
436
  },
437
- description: 'Register a sending domain for a site and link it to the site record.',
437
+ description: 'Create or update a pending sending-domain record and link it to a site. This does not register the domain with an email provider.',
438
438
  inputSchema: {
439
439
  site_id: z.string().uuid(),
440
440
  domain: z.string().min(1),
@@ -307,7 +307,7 @@ export function registerOrgTools(server) {
307
307
  title: 'Start Provider Switch',
308
308
  annotations: {
309
309
  readOnlyHint: false,
310
- destructiveHint: false,
310
+ destructiveHint: true,
311
311
  idempotentHint: false,
312
312
  openWorldHint: true,
313
313
  },
@@ -327,12 +327,12 @@ export function registerOrgTools(server) {
327
327
  server.registerTool('get_provider_switch_status', {
328
328
  title: 'Get Provider Switch Status',
329
329
  annotations: {
330
- readOnlyHint: true,
331
- destructiveHint: false,
330
+ readOnlyHint: false,
331
+ destructiveHint: true,
332
332
  idempotentHint: true,
333
333
  openWorldHint: true,
334
334
  },
335
- description: 'Poll and return the current status of a provider switch.',
335
+ description: 'Poll and return the current status of a provider switch. Note: while the switch is pending/verifying, this call also drives it forward — it checks domain verification and, if newly verified, flips the site to the new provider as a side effect.',
336
336
  inputSchema: {
337
337
  id: z.string().uuid(),
338
338
  },
@@ -302,7 +302,7 @@ export function registerSmsTools(server) {
302
302
  title: 'Set Contact SMS Consent',
303
303
  annotations: {
304
304
  readOnlyHint: false,
305
- destructiveHint: false,
305
+ destructiveHint: true,
306
306
  idempotentHint: true,
307
307
  openWorldHint: false,
308
308
  },
@@ -258,7 +258,7 @@ export function registerSocialPostTools(server) {
258
258
  title: 'Create Social Post',
259
259
  annotations: {
260
260
  readOnlyHint: false,
261
- destructiveHint: false,
261
+ destructiveHint: true,
262
262
  idempotentHint: false,
263
263
  openWorldHint: true,
264
264
  },
@@ -377,7 +377,7 @@ export function registerSocialPostTools(server) {
377
377
  title: 'Update Social Post',
378
378
  annotations: {
379
379
  readOnlyHint: false,
380
- destructiveHint: false,
380
+ destructiveHint: true,
381
381
  idempotentHint: true,
382
382
  openWorldHint: true,
383
383
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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",