@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.
- package/build/lib/composer-blocks.d.ts +116 -0
- package/build/lib/composer-blocks.js +187 -0
- package/build/tools/ab-testing.js +1 -1
- package/build/tools/advisor.js +1 -1
- package/build/tools/analytics.js +1 -1
- package/build/tools/automations.js +4 -4
- package/build/tools/brand-kit.js +1 -1
- package/build/tools/campaigns.js +99 -23
- package/build/tools/compound.js +33 -4
- package/build/tools/contacts.js +2 -2
- package/build/tools/content.js +1 -1
- package/build/tools/data.js +2 -2
- package/build/tools/delivery-ops.js +6 -6
- package/build/tools/org.js +4 -4
- package/build/tools/sms.js +1 -1
- package/build/tools/social-posts.js +2 -2
- package/package.json +1 -1
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
51
|
+
}
|
|
52
|
+
function escapeAttr(value) {
|
|
53
|
+
return escapeHtml(value).replace(/`/g, '`');
|
|
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:
|
|
114
|
+
readOnlyHint: false,
|
|
115
115
|
destructiveHint: false,
|
|
116
116
|
idempotentHint: true,
|
|
117
117
|
openWorldHint: false,
|
package/build/tools/advisor.js
CHANGED
|
@@ -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:
|
|
298
|
+
readOnlyHint: false,
|
|
299
299
|
destructiveHint: false,
|
|
300
300
|
idempotentHint: true,
|
|
301
301
|
openWorldHint: true,
|
package/build/tools/analytics.js
CHANGED
|
@@ -448,7 +448,7 @@ export function registerAnalyticsTools(server) {
|
|
|
448
448
|
readOnlyHint: true,
|
|
449
449
|
destructiveHint: false,
|
|
450
450
|
idempotentHint: true,
|
|
451
|
-
openWorldHint:
|
|
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:
|
|
161
|
+
destructiveHint: true,
|
|
162
162
|
idempotentHint: false,
|
|
163
|
-
openWorldHint:
|
|
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:
|
|
242
|
+
destructiveHint: true,
|
|
243
243
|
idempotentHint: false,
|
|
244
|
-
openWorldHint:
|
|
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: {
|
package/build/tools/brand-kit.js
CHANGED
|
@@ -149,7 +149,7 @@ export function registerBrandKitTools(server) {
|
|
|
149
149
|
readOnlyHint: false,
|
|
150
150
|
destructiveHint: false,
|
|
151
151
|
idempotentHint: false,
|
|
152
|
-
openWorldHint:
|
|
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: {
|
package/build/tools/campaigns.js
CHANGED
|
@@ -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:
|
|
216
|
+
readOnlyHint: false,
|
|
216
217
|
destructiveHint: false,
|
|
217
218
|
idempotentHint: true,
|
|
218
|
-
openWorldHint:
|
|
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
|
|
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:
|
|
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:
|
|
545
|
+
destructiveHint: true,
|
|
542
546
|
idempotentHint: false,
|
|
543
|
-
openWorldHint:
|
|
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:
|
|
652
|
+
destructiveHint: true,
|
|
649
653
|
idempotentHint: false,
|
|
650
|
-
openWorldHint:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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) } : {}),
|
package/build/tools/compound.js
CHANGED
|
@@ -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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}` });
|
package/build/tools/contacts.js
CHANGED
|
@@ -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:
|
|
1441
|
-
destructiveHint:
|
|
1440
|
+
readOnlyHint: false,
|
|
1441
|
+
destructiveHint: true,
|
|
1442
1442
|
idempotentHint: true,
|
|
1443
1443
|
openWorldHint: false,
|
|
1444
1444
|
},
|
package/build/tools/content.js
CHANGED
package/build/tools/data.js
CHANGED
|
@@ -268,7 +268,7 @@ export function registerDataTools(server) {
|
|
|
268
268
|
readOnlyHint: false,
|
|
269
269
|
destructiveHint: false,
|
|
270
270
|
idempotentHint: false,
|
|
271
|
-
openWorldHint:
|
|
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:
|
|
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:
|
|
12
|
+
openWorldHint: false,
|
|
13
13
|
},
|
|
14
|
-
description: '
|
|
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:
|
|
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:
|
|
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:
|
|
435
|
+
openWorldHint: false,
|
|
436
436
|
},
|
|
437
|
-
description: '
|
|
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),
|
package/build/tools/org.js
CHANGED
|
@@ -307,7 +307,7 @@ export function registerOrgTools(server) {
|
|
|
307
307
|
title: 'Start Provider Switch',
|
|
308
308
|
annotations: {
|
|
309
309
|
readOnlyHint: false,
|
|
310
|
-
destructiveHint:
|
|
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:
|
|
331
|
-
destructiveHint:
|
|
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
|
},
|
package/build/tools/sms.js
CHANGED
|
@@ -258,7 +258,7 @@ export function registerSocialPostTools(server) {
|
|
|
258
258
|
title: 'Create Social Post',
|
|
259
259
|
annotations: {
|
|
260
260
|
readOnlyHint: false,
|
|
261
|
-
destructiveHint:
|
|
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:
|
|
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.
|
|
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",
|