@sendinel/mcp-server 1.0.0

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.
Files changed (90) hide show
  1. package/README.md +113 -0
  2. package/build/anthropic-model.d.ts +5 -0
  3. package/build/anthropic-model.js +6 -0
  4. package/build/audit.d.ts +21 -0
  5. package/build/audit.js +38 -0
  6. package/build/auth.d.ts +10 -0
  7. package/build/auth.js +57 -0
  8. package/build/byod-client.d.ts +30 -0
  9. package/build/byod-client.js +95 -0
  10. package/build/crypto.d.ts +2 -0
  11. package/build/crypto.js +27 -0
  12. package/build/db.d.ts +38 -0
  13. package/build/db.js +70 -0
  14. package/build/index.d.ts +1 -0
  15. package/build/index.js +97 -0
  16. package/build/lib/action-approvals.d.ts +30 -0
  17. package/build/lib/action-approvals.js +154 -0
  18. package/build/lib/schedule.d.ts +36 -0
  19. package/build/lib/schedule.js +63 -0
  20. package/build/lib/social-engine/archetypes.d.ts +27 -0
  21. package/build/lib/social-engine/archetypes.js +160 -0
  22. package/build/lib/social-engine/index.d.ts +5 -0
  23. package/build/lib/social-engine/index.js +6 -0
  24. package/build/lib/social-engine/parse.d.ts +7 -0
  25. package/build/lib/social-engine/parse.js +83 -0
  26. package/build/lib/social-engine/platform-constraints.d.ts +27 -0
  27. package/build/lib/social-engine/platform-constraints.js +70 -0
  28. package/build/lib/social-engine/prompt-builder.d.ts +13 -0
  29. package/build/lib/social-engine/prompt-builder.js +80 -0
  30. package/build/lib/social-engine/types.d.ts +60 -0
  31. package/build/lib/social-engine/types.js +19 -0
  32. package/build/lib/url-validation.d.ts +3 -0
  33. package/build/lib/url-validation.js +51 -0
  34. package/build/lib/voice.d.ts +32 -0
  35. package/build/lib/voice.js +140 -0
  36. package/build/lib/webhook-events.d.ts +15 -0
  37. package/build/lib/webhook-events.js +120 -0
  38. package/build/plan-limits.d.ts +8 -0
  39. package/build/plan-limits.js +9 -0
  40. package/build/project.d.ts +1 -0
  41. package/build/project.js +9 -0
  42. package/build/server.d.ts +18 -0
  43. package/build/server.js +235 -0
  44. package/build/tools/ab-testing.d.ts +2 -0
  45. package/build/tools/ab-testing.js +204 -0
  46. package/build/tools/advisor.d.ts +23 -0
  47. package/build/tools/advisor.js +762 -0
  48. package/build/tools/analytics.d.ts +33 -0
  49. package/build/tools/analytics.js +1105 -0
  50. package/build/tools/approvals.d.ts +2 -0
  51. package/build/tools/approvals.js +32 -0
  52. package/build/tools/automations.d.ts +2 -0
  53. package/build/tools/automations.js +344 -0
  54. package/build/tools/campaigns.d.ts +2 -0
  55. package/build/tools/campaigns.js +1335 -0
  56. package/build/tools/compound.d.ts +2 -0
  57. package/build/tools/compound.js +312 -0
  58. package/build/tools/contacts.d.ts +2 -0
  59. package/build/tools/contacts.js +1483 -0
  60. package/build/tools/content.d.ts +2 -0
  61. package/build/tools/content.js +68 -0
  62. package/build/tools/data-proposals.d.ts +2 -0
  63. package/build/tools/data-proposals.js +155 -0
  64. package/build/tools/data.d.ts +2 -0
  65. package/build/tools/data.js +707 -0
  66. package/build/tools/delivery-ops.d.ts +2 -0
  67. package/build/tools/delivery-ops.js +387 -0
  68. package/build/tools/drafts.d.ts +2 -0
  69. package/build/tools/drafts.js +204 -0
  70. package/build/tools/forms.d.ts +2 -0
  71. package/build/tools/forms.js +46 -0
  72. package/build/tools/gdpr.d.ts +2 -0
  73. package/build/tools/gdpr.js +61 -0
  74. package/build/tools/org.d.ts +2 -0
  75. package/build/tools/org.js +71 -0
  76. package/build/tools/segments.d.ts +2 -0
  77. package/build/tools/segments.js +384 -0
  78. package/build/tools/sites.d.ts +2 -0
  79. package/build/tools/sites.js +182 -0
  80. package/build/tools/sms.d.ts +2 -0
  81. package/build/tools/sms.js +489 -0
  82. package/build/tools/social-posts.d.ts +2 -0
  83. package/build/tools/social-posts.js +380 -0
  84. package/build/tools/templates.d.ts +2 -0
  85. package/build/tools/templates.js +282 -0
  86. package/build/tools/warmup.d.ts +2 -0
  87. package/build/tools/warmup.js +57 -0
  88. package/build/tools/webhooks.d.ts +2 -0
  89. package/build/tools/webhooks.js +127 -0
  90. package/package.json +63 -0
@@ -0,0 +1,380 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { z } from 'zod';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { getAnthropicModelId } from '../anthropic-model.js';
6
+ import { buildVoicePrompt } from '../lib/voice.js';
7
+ import { buildSocialSystemPrompt, buildSocialUserPrompt, parseSocialResponse, selectArchetype, } from '../lib/social-engine/index.js';
8
+ import { fireWebhookEvent } from '../lib/webhook-events.js';
9
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
10
+ const j = (data) => txt(JSON.stringify(data, null, 2));
11
+ const PLATFORMS = [
12
+ 'instagram', 'linkedin', 'twitter', 'x', 'facebook', 'tiktok',
13
+ 'youtube', 'pinterest', 'threads', 'reddit', 'bluesky', 'google_business',
14
+ ];
15
+ const STATUSES = ['draft', 'scheduled', 'queued', 'publishing', 'published', 'failed', 'archived'];
16
+ function cleanText(value) {
17
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
18
+ }
19
+ function stripHtml(value) {
20
+ return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
21
+ }
22
+ // Brand-context line for the social engine prompt — mirrors the dashboard's
23
+ // buildBrandContext output (plain "Brand: X. Mission: Y." string, no label).
24
+ // Project brand_settings is loosely typed; read canonical fields defensively.
25
+ function buildSocialBrandContext(siteName, projectSettings) {
26
+ const parts = [];
27
+ if (siteName)
28
+ parts.push(`Brand: ${siteName}`);
29
+ const str = (key) => (typeof projectSettings?.[key] === 'string' && projectSettings[key].trim() ? projectSettings[key].trim() : null);
30
+ const mission = str('mission');
31
+ const audience = str('audience');
32
+ const differentiators = str('differentiators');
33
+ if (mission)
34
+ parts.push(`Mission: ${mission}`);
35
+ if (audience)
36
+ parts.push(`Audience: ${audience}`);
37
+ if (differentiators)
38
+ parts.push(`Differentiators: ${differentiators}`);
39
+ return parts.join('. ');
40
+ }
41
+ function isoOrNull(value) {
42
+ if (!value)
43
+ return null;
44
+ const date = new Date(String(value));
45
+ if (Number.isNaN(date.getTime()))
46
+ throw new Error('Expected a valid ISO 8601 datetime.');
47
+ return date.toISOString();
48
+ }
49
+ function statusFromSchedule(scheduledAt, queueRequested) {
50
+ if (queueRequested)
51
+ return 'queued';
52
+ if (scheduledAt)
53
+ return 'scheduled';
54
+ return 'draft';
55
+ }
56
+ function trackedUrl(input) {
57
+ const url = new URL(input.destinationUrl);
58
+ const source = input.platform === 'x' ? 'twitter' : input.platform;
59
+ const campaign = input.utmCampaign || input.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || 'social-post';
60
+ url.searchParams.set('utm_source', source);
61
+ url.searchParams.set('utm_medium', 'social');
62
+ url.searchParams.set('utm_campaign', campaign);
63
+ return { tracked_url: url.toString(), utm_source: source, utm_medium: 'social', utm_campaign: campaign };
64
+ }
65
+ async function projectOrgId(projectId) {
66
+ const { data } = await db().from('projects').select('org_id').eq('id', projectId).maybeSingle();
67
+ return data?.org_id ?? null;
68
+ }
69
+ async function requireProjectSite(siteId) {
70
+ if (!siteId)
71
+ return;
72
+ const { data, error } = await db().from('sites').select('id').eq('project_id', getProjectId()).eq('id', siteId).maybeSingle();
73
+ if (error)
74
+ throw new Error(error.message);
75
+ if (!data)
76
+ throw new Error('Site not found for this project');
77
+ }
78
+ async function requireProjectCampaign(campaignId) {
79
+ if (!campaignId)
80
+ return;
81
+ const { data, error } = await db().from('campaigns').select('id').eq('project_id', getProjectId()).eq('id', campaignId).maybeSingle();
82
+ if (error)
83
+ throw new Error(error.message);
84
+ if (!data)
85
+ throw new Error('Email campaign not found for this project');
86
+ }
87
+ export function registerSocialPostTools(server) {
88
+ server.registerTool('list_social_posts', {
89
+ description: 'List first-class social posts for the current project.',
90
+ inputSchema: {
91
+ status: z.enum(STATUSES).optional(),
92
+ platform: z.enum(PLATFORMS).optional(),
93
+ limit: z.number().int().min(1).max(100).optional().default(25),
94
+ },
95
+ }, async ({ status, platform, limit }) => {
96
+ let query = db()
97
+ .from('social_posts')
98
+ .select('*')
99
+ .eq('project_id', getProjectId())
100
+ .neq('status', 'archived')
101
+ .order('scheduled_at', { ascending: true, nullsFirst: false })
102
+ .order('created_at', { ascending: false })
103
+ .limit(limit ?? 25);
104
+ if (status)
105
+ query = query.eq('status', status);
106
+ if (platform)
107
+ query = query.contains('platforms', [platform]);
108
+ const { data, error } = await query;
109
+ if (error)
110
+ return txt(`Error: ${error.message}`);
111
+ return j({ posts: data ?? [] });
112
+ });
113
+ server.registerTool('get_social_post', {
114
+ description: 'Get one social post by ID.',
115
+ inputSchema: { post_id: z.string().uuid() },
116
+ }, async ({ post_id }) => {
117
+ const { data, error } = await db()
118
+ .from('social_posts')
119
+ .select('*')
120
+ .eq('project_id', getProjectId())
121
+ .eq('id', post_id)
122
+ .maybeSingle();
123
+ if (error)
124
+ return txt(`Error: ${error.message}`);
125
+ if (!data)
126
+ return j({ error: 'Social post not found', code: 'NOT_FOUND' });
127
+ return j(data);
128
+ });
129
+ server.registerTool('adapt_social_post', {
130
+ description: 'Adapt an existing email campaign into channel-specific social captions using the creator-archetype engine (deterministic archetype selection, multi-layer brand/voice prompts, A/B/C variants per platform, gen-time constraint enforcement) — without creating a post. Returns platform_captions (variant A per platform, back-compat) plus a full generation record with variants, media plans, and constraint warnings.',
131
+ inputSchema: {
132
+ email_campaign_id: z.string().uuid(),
133
+ platforms: z.array(z.enum(PLATFORMS)).min(1),
134
+ },
135
+ }, async ({ email_campaign_id, platforms }) => {
136
+ const projectId = getProjectId();
137
+ const [{ data: campaign, error: campaignError }, { data: step }, { data: project }] = await Promise.all([
138
+ db().from('campaigns').select('id, name, description, site_id').eq('project_id', projectId).eq('id', email_campaign_id).maybeSingle(),
139
+ db().from('campaign_steps').select('subject, body_html, body_text').eq('campaign_id', email_campaign_id).order('step_order', { ascending: true }).limit(1).maybeSingle(),
140
+ db().from('projects').select('org_id').eq('id', projectId).maybeSingle(),
141
+ ]);
142
+ if (campaignError)
143
+ return txt(`Error: ${campaignError.message}`);
144
+ if (!campaign)
145
+ return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
146
+ const [{ data: site }, { data: projectSettingsRow }] = await Promise.all([
147
+ campaign.site_id
148
+ ? db().from('sites').select('name, brand_voice').eq('project_id', projectId).eq('id', campaign.site_id).maybeSingle()
149
+ : Promise.resolve({ data: null }),
150
+ db().from('projects').select('brand_settings').eq('id', projectId).maybeSingle(),
151
+ ]);
152
+ const seed = stripHtml(step?.body_text ?? step?.body_html ?? campaign.description ?? campaign.name);
153
+ const subject = step?.subject ?? campaign.name;
154
+ const projectSettings = (projectSettingsRow?.brand_settings ?? null);
155
+ const voiceRules = buildVoicePrompt(site?.brand_voice ?? null, projectSettings);
156
+ const brandContext = buildSocialBrandContext(site?.name ?? null, projectSettings);
157
+ // Dedupe while preserving order (x and twitter are distinct platforms).
158
+ const requested = Array.from(new Set(platforms));
159
+ const brief = {
160
+ goal: `announce: ${campaign.name}`,
161
+ source_content: seed,
162
+ format: 'text',
163
+ platforms: requested,
164
+ };
165
+ const archetype = selectArchetype(brief);
166
+ const apiKey = process.env.ANTHROPIC_API_KEY;
167
+ if (!apiKey) {
168
+ return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'NO_API_KEY', title: campaign.name, content: seed, platform_captions: {} });
169
+ }
170
+ const anthropic = new Anthropic({ apiKey });
171
+ const model = getAnthropicModelId();
172
+ // One model call per platform — each a multi-layer engine prompt returning
173
+ // A/B/C variants as a structured IR. Independent calls so a partial failure
174
+ // surfaces per platform instead of failing the whole adaptation.
175
+ const settled = await Promise.allSettled(requested.map(async (platform) => {
176
+ const promptInput = { brief, platform, archetype, brandContext, voiceRules };
177
+ const response = await anthropic.messages.create({
178
+ model,
179
+ max_tokens: 1600,
180
+ system: [{ type: 'text', text: buildSocialSystemPrompt(promptInput), cache_control: { type: 'ephemeral' } }],
181
+ messages: [{ role: 'user', content: buildSocialUserPrompt(promptInput) }],
182
+ });
183
+ const textBlock = response.content.find((b) => b.type === 'text');
184
+ const raw = textBlock && 'text' in textBlock ? textBlock.text : '{}';
185
+ return parseSocialResponse(raw, platform, archetype.id);
186
+ }));
187
+ const posts = {};
188
+ const captions = {};
189
+ const failures = [];
190
+ settled.forEach((result, index) => {
191
+ if (result.status === 'fulfilled') {
192
+ posts[requested[index]] = result.value;
193
+ captions[requested[index]] = result.value.variants[0].caption;
194
+ }
195
+ else {
196
+ failures.push(`${requested[index]}: ${result.reason instanceof Error ? result.reason.message : 'generation failed'}`);
197
+ }
198
+ });
199
+ const generation = {
200
+ brief,
201
+ archetype_id: archetype.id,
202
+ posts,
203
+ generated_at: new Date().toISOString(),
204
+ };
205
+ return j({
206
+ title: campaign.name,
207
+ content: seed,
208
+ platform_captions: captions,
209
+ generation,
210
+ archetype: { id: archetype.id, name: archetype.name, trigger: archetype.neurodesign_trigger },
211
+ failures,
212
+ source: {
213
+ email_campaign_id: campaign.id,
214
+ subject,
215
+ brand_voice: site?.brand_voice ?? null,
216
+ brand_rules: voiceRules,
217
+ },
218
+ });
219
+ });
220
+ server.registerTool('create_social_post', {
221
+ description: 'Create a first-class social post, optionally linked to an email campaign and UTM-tracked social campaign.',
222
+ inputSchema: {
223
+ title: z.string().min(1),
224
+ content: z.string().min(1),
225
+ platforms: z.array(z.enum(PLATFORMS)).min(1),
226
+ site_id: z.string().uuid().optional(),
227
+ email_campaign_id: z.string().uuid().optional(),
228
+ platform_captions: z.record(z.string(), z.string()).optional(),
229
+ media_urls: z.array(z.string().url()).optional().default([]),
230
+ destination_url: z.string().url().optional(),
231
+ utm_campaign: z.string().optional(),
232
+ scheduled_at: z.string().datetime().optional(),
233
+ queue_requested: z.boolean().optional().default(false),
234
+ timezone: z.string().optional().default('Pacific/Honolulu'),
235
+ },
236
+ }, async ({ title, content, platforms, site_id, email_campaign_id, platform_captions, media_urls, destination_url, utm_campaign, scheduled_at, queue_requested, timezone }) => {
237
+ const projectId = getProjectId();
238
+ const orgId = await projectOrgId(projectId);
239
+ if (!orgId)
240
+ return txt('Project organization not found.');
241
+ try {
242
+ await requireProjectSite(site_id);
243
+ await requireProjectCampaign(email_campaign_id);
244
+ }
245
+ catch (err) {
246
+ return txt(`Error: ${err instanceof Error ? err.message : 'Invalid linked resource'}`);
247
+ }
248
+ let scheduledAt;
249
+ try {
250
+ scheduledAt = isoOrNull(scheduled_at);
251
+ if (scheduledAt && queue_requested)
252
+ return txt('Choose either a scheduled time or queue delivery, not both.');
253
+ }
254
+ catch (err) {
255
+ return txt(`Error: ${err instanceof Error ? err.message : 'Invalid schedule'}`);
256
+ }
257
+ let socialCampaignId = null;
258
+ let tracked = null;
259
+ if (destination_url) {
260
+ tracked = trackedUrl({ destinationUrl: destination_url, title, platform: platforms.includes('x') ? 'twitter' : platforms[0], utmCampaign: cleanText(utm_campaign) });
261
+ const { data: socialCampaign, error: campaignError } = await db()
262
+ .from('social_campaigns')
263
+ .insert({
264
+ project_id: projectId,
265
+ site_id: site_id ?? null,
266
+ name: title,
267
+ platform: tracked.utm_source,
268
+ destination_url,
269
+ utm_source: tracked.utm_source,
270
+ utm_medium: tracked.utm_medium,
271
+ utm_campaign: tracked.utm_campaign,
272
+ tracked_url: tracked.tracked_url,
273
+ notes: content,
274
+ scheduled_at: scheduledAt,
275
+ status: scheduledAt ? 'active' : 'draft',
276
+ })
277
+ .select('id')
278
+ .single();
279
+ if (campaignError)
280
+ return txt(`Error: ${campaignError.message}`);
281
+ socialCampaignId = socialCampaign.id;
282
+ }
283
+ const status = statusFromSchedule(scheduledAt, Boolean(queue_requested));
284
+ const { data, error } = await db()
285
+ .from('social_posts')
286
+ .insert({
287
+ org_id: orgId,
288
+ project_id: projectId,
289
+ site_id: site_id ?? null,
290
+ email_campaign_id: email_campaign_id ?? null,
291
+ social_campaign_id: socialCampaignId,
292
+ title,
293
+ content,
294
+ platform_captions: platform_captions ?? {},
295
+ media_urls: media_urls ?? [],
296
+ platforms,
297
+ destination_url: destination_url ?? null,
298
+ tracked_url: tracked?.tracked_url ?? null,
299
+ scheduled_at: scheduledAt,
300
+ timezone: timezone ?? 'Pacific/Honolulu',
301
+ queue_requested: Boolean(queue_requested),
302
+ status,
303
+ })
304
+ .select()
305
+ .single();
306
+ if (error)
307
+ return txt(`Error: ${error.message}`);
308
+ fireWebhookEvent('social.post.created', { social_post_id: data.id, title: data.title, status: data.status, platforms: data.platforms }, projectId).catch(() => { });
309
+ if (status === 'scheduled' || status === 'queued') {
310
+ fireWebhookEvent('social.post.scheduled', { social_post_id: data.id, title: data.title, status: data.status, scheduled_at: data.scheduled_at, platforms: data.platforms }, projectId).catch(() => { });
311
+ }
312
+ return j(data);
313
+ });
314
+ server.registerTool('update_social_post', {
315
+ description: 'Update a social post by ID. Supports copy, captions, schedule, status, and metrics fields.',
316
+ inputSchema: {
317
+ post_id: z.string().uuid(),
318
+ title: z.string().min(1).optional(),
319
+ content: z.string().min(1).optional(),
320
+ platforms: z.array(z.enum(PLATFORMS)).min(1).optional(),
321
+ platform_captions: z.record(z.string(), z.string()).optional(),
322
+ media_urls: z.array(z.string().url()).optional(),
323
+ destination_url: z.string().url().nullable().optional(),
324
+ scheduled_at: z.string().datetime().nullable().optional(),
325
+ queue_requested: z.boolean().optional(),
326
+ status: z.enum(STATUSES).optional(),
327
+ reach: z.number().int().min(0).optional(),
328
+ impressions: z.number().int().min(0).optional(),
329
+ engagements: z.number().int().min(0).optional(),
330
+ link_clicks: z.number().int().min(0).optional(),
331
+ conversions: z.number().int().min(0).optional(),
332
+ publish_error: z.string().nullable().optional(),
333
+ },
334
+ }, async ({ post_id, ...input }) => {
335
+ const updates = { updated_at: new Date().toISOString() };
336
+ for (const key of ['title', 'content', 'platforms', 'platform_captions', 'media_urls', 'destination_url', 'status', 'reach', 'impressions', 'engagements', 'link_clicks', 'conversions', 'publish_error']) {
337
+ if (input[key] !== undefined)
338
+ updates[key] = input[key];
339
+ }
340
+ if (input.scheduled_at !== undefined)
341
+ updates.scheduled_at = input.scheduled_at ? isoOrNull(input.scheduled_at) : null;
342
+ if (input.queue_requested !== undefined)
343
+ updates.queue_requested = input.queue_requested;
344
+ if (updates.scheduled_at && updates.queue_requested)
345
+ return txt('Choose either a scheduled time or queue delivery, not both.');
346
+ if (!Object.keys(updates).some((key) => key !== 'updated_at'))
347
+ return txt('No fields to update.');
348
+ const { data, error } = await db()
349
+ .from('social_posts')
350
+ .update(updates)
351
+ .eq('project_id', getProjectId())
352
+ .eq('id', post_id)
353
+ .select()
354
+ .single();
355
+ if (error)
356
+ return txt(`Error: ${error.message}`);
357
+ fireWebhookEvent('social.post.updated', { social_post_id: data.id, title: data.title, status: data.status, fields: Object.keys(updates) }, getProjectId()).catch(() => { });
358
+ if (data.status === 'published')
359
+ fireWebhookEvent('social.post.published', { social_post_id: data.id, title: data.title, platforms: data.platforms, published_at: data.published_at }, getProjectId()).catch(() => { });
360
+ if (data.status === 'failed')
361
+ fireWebhookEvent('social.post.failed', { social_post_id: data.id, title: data.title, publish_error: data.publish_error }, getProjectId()).catch(() => { });
362
+ return j(data);
363
+ });
364
+ server.registerTool('delete_social_post', {
365
+ description: 'Archive a social post so it disappears from active planner and list views.',
366
+ inputSchema: { post_id: z.string().uuid() },
367
+ }, async ({ post_id }) => {
368
+ const { data, error } = await db()
369
+ .from('social_posts')
370
+ .update({ status: 'archived', updated_at: new Date().toISOString() })
371
+ .eq('project_id', getProjectId())
372
+ .eq('id', post_id)
373
+ .select()
374
+ .single();
375
+ if (error)
376
+ return txt(`Error: ${error.message}`);
377
+ fireWebhookEvent('social.post.archived', { social_post_id: data.id, title: data.title }, getProjectId()).catch(() => { });
378
+ return j(data);
379
+ });
380
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerTemplateTools(server: McpServer): void;
@@ -0,0 +1,282 @@
1
+ import { z } from 'zod';
2
+ import { db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
5
+ const j = (data) => txt(JSON.stringify(data, null, 2));
6
+ function stripHtml(value) {
7
+ return value
8
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
9
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
10
+ .replace(/<[^>]+>/g, ' ')
11
+ .replace(/\s+/g, ' ')
12
+ .trim();
13
+ }
14
+ function translateMergeTags(body) {
15
+ const variables = new Set();
16
+ const unmapped = new Set();
17
+ const translated = body
18
+ .replace(/{{\s*customer\.first_name(?:\s*\|[^}]*)?\s*}}/gi, () => {
19
+ variables.add('first_name');
20
+ return '{{first_name}}';
21
+ })
22
+ .replace(/{{\s*customer\.last_name(?:\s*\|[^}]*)?\s*}}/gi, () => {
23
+ variables.add('last_name');
24
+ return '{{last_name}}';
25
+ })
26
+ .replace(/{{\s*customer\.email(?:\s*\|[^}]*)?\s*}}/gi, () => {
27
+ variables.add('email');
28
+ return '{{email}}';
29
+ })
30
+ .replace(/{{\s*unsubscribe_url\s*}}/gi, () => {
31
+ variables.add('unsubscribe_url');
32
+ return '{{unsubscribe_url}}';
33
+ })
34
+ .replace(/\*\|FNAME\|\*/g, () => {
35
+ variables.add('first_name');
36
+ return '{{first_name}}';
37
+ })
38
+ .replace(/\*\|LNAME\|\*/g, () => {
39
+ variables.add('last_name');
40
+ return '{{last_name}}';
41
+ })
42
+ .replace(/\*\|EMAIL\|\*/g, () => {
43
+ variables.add('email');
44
+ return '{{email}}';
45
+ })
46
+ .replace(/{{\s*customer\.([a-zA-Z0-9_.-]+)(?:\s*\|[^}]*)?\s*}}/g, (_match, token) => {
47
+ const name = `custom.${String(token).replace(/^custom\./, '')}`;
48
+ variables.add(name);
49
+ unmapped.add(`customer.${token}`);
50
+ return `{{${name}}}`;
51
+ });
52
+ const tokenPattern = /{{\s*([a-zA-Z0-9_.-]+)\s*}}/g;
53
+ let match;
54
+ while ((match = tokenPattern.exec(translated)) !== null) {
55
+ if (match[1])
56
+ variables.add(match[1]);
57
+ }
58
+ return { body: translated, variables: Array.from(variables), unmapped: Array.from(unmapped) };
59
+ }
60
+ export function registerTemplateTools(server) {
61
+ server.registerTool('list_templates', {
62
+ description: 'List email templates for the current project, including reusable system templates.',
63
+ inputSchema: {
64
+ limit: z.number().int().min(1).max(100).default(50).optional(),
65
+ category: z.string().optional(),
66
+ search: z.string().optional(),
67
+ },
68
+ }, async ({ limit = 50, category, search }) => {
69
+ const projectId = getProjectId();
70
+ let query = db().from('campaign_templates')
71
+ .select('id, name, category, description, subject_hint, brief_template, body_html, tags, project_id, created_at, updated_at')
72
+ .eq('is_archived', false)
73
+ .or(`project_id.is.null,project_id.eq.${projectId}`)
74
+ .order('created_at', { ascending: false })
75
+ .limit(limit);
76
+ if (category)
77
+ query = query.eq('category', category);
78
+ if (search)
79
+ query = query.ilike('name', `%${search}%`);
80
+ const { data, error } = await query;
81
+ if (error)
82
+ return j({ error: error.message });
83
+ return j({ templates: data ?? [], count: data?.length ?? 0 });
84
+ });
85
+ server.registerTool('translate_template', {
86
+ description: 'Translate rendered source-platform HTML into a Sendinel campaign template. Optionally save it.',
87
+ inputSchema: {
88
+ provider: z.enum(['customerio', 'mailchimp', 'klaviyo', 'activecampaign', 'kit', 'resend']).default('customerio').optional(),
89
+ source_template_id: z.string().optional(),
90
+ name: z.string().optional(),
91
+ subject: z.string().optional(),
92
+ html: z.string().optional(),
93
+ text: z.string().optional(),
94
+ save: z.boolean().default(false).optional(),
95
+ },
96
+ }, async ({ provider = 'customerio', source_template_id, name, subject, html, text, save = false }) => {
97
+ const projectId = getProjectId();
98
+ const { data: project, error: projectError } = await db().from('projects').select('org_id').eq('id', projectId).single();
99
+ if (projectError || !project?.org_id)
100
+ return j({ error: projectError?.message ?? 'Project not found' });
101
+ const source = String(html ?? text ?? '').trim();
102
+ if (!source)
103
+ return j({ error: 'html or text is required' });
104
+ const translatedBody = translateMergeTags(source);
105
+ const translatedSubject = translateMergeTags(String(subject ?? name ?? 'Imported template'));
106
+ const bodyHtml = html ? translatedBody.body : null;
107
+ const bodyText = text ? translatedBody.body : stripHtml(translatedBody.body);
108
+ const variables = Array.from(new Set([...translatedBody.variables, ...translatedSubject.variables]));
109
+ const unmapped = Array.from(new Set([...translatedBody.unmapped, ...translatedSubject.unmapped]));
110
+ const translated = {
111
+ provider,
112
+ source_template_id: source_template_id ?? null,
113
+ name: name ?? translatedSubject.body,
114
+ subject: translatedSubject.body,
115
+ body_html: bodyHtml,
116
+ body_text: bodyText,
117
+ variables,
118
+ unmapped_merge_tags: unmapped,
119
+ confidence: unmapped.length ? 'medium' : 'high',
120
+ translator: 'render_then_extract:mcp:v1',
121
+ };
122
+ const { data: row, error } = await db().from('template_translations').insert({
123
+ project_id: projectId,
124
+ org_id: project.org_id,
125
+ user_id: null,
126
+ source_provider: provider,
127
+ source_template_id: source_template_id ?? null,
128
+ source_name: translated.name,
129
+ source_subject: subject ?? null,
130
+ source_html: html ?? source,
131
+ source_text: text ?? null,
132
+ translated_subject: translated.subject,
133
+ translated_html: bodyHtml,
134
+ translated_text: bodyText,
135
+ variables,
136
+ unmapped_merge_tags: unmapped,
137
+ confidence: translated.confidence,
138
+ status: save ? 'saved' : 'completed',
139
+ extracted_json: translated,
140
+ }).select('id').single();
141
+ if (error)
142
+ return j({ error: error.message });
143
+ let savedTemplateId = null;
144
+ if (save) {
145
+ const { data: saved, error: saveError } = await db().from('campaign_templates').insert({
146
+ project_id: projectId,
147
+ name: translated.name,
148
+ category: 'migration',
149
+ description: `Imported from ${provider}. Translation confidence: ${translated.confidence}.`,
150
+ subject_hint: translated.subject,
151
+ brief_template: translated.body_text.slice(0, 240) || `Use imported ${provider} template.`,
152
+ body_html: bodyHtml,
153
+ body_text: bodyText,
154
+ body_source: bodyHtml ?? bodyText,
155
+ format: bodyHtml ? 'html' : 'text',
156
+ variables,
157
+ tags: ['migration', provider],
158
+ imported_from: provider,
159
+ external_id: source_template_id ?? null,
160
+ }).select('id').single();
161
+ if (saveError)
162
+ return j({ error: saveError.message });
163
+ savedTemplateId = saved?.id ?? null;
164
+ if (savedTemplateId) {
165
+ await db().from('template_translations').update({ saved_template_id: savedTemplateId, status: 'saved' }).eq('id', row.id);
166
+ }
167
+ }
168
+ return j({ ok: true, translation_id: row.id, saved_template_id: savedTemplateId, translated });
169
+ });
170
+ server.registerTool('get_template', {
171
+ description: 'Get a single email template by ID.',
172
+ inputSchema: { id: z.string().uuid() },
173
+ }, async ({ id }) => {
174
+ const projectId = getProjectId();
175
+ const { data, error } = await db().from('campaign_templates')
176
+ .select('id, name, category, description, subject_hint, brief_template, body_html, tags, project_id, created_at, updated_at')
177
+ .eq('id', id)
178
+ .or(`project_id.is.null,project_id.eq.${projectId}`)
179
+ .maybeSingle();
180
+ if (error)
181
+ return j({ error: error.message });
182
+ return j({ template: data });
183
+ });
184
+ server.registerTool('create_template', {
185
+ description: 'Create a new email template. Use create_template_from_brief to AI-generate the content.',
186
+ inputSchema: {
187
+ name: z.string().min(1),
188
+ brief: z.string().min(1),
189
+ subject_hint: z.string().optional(),
190
+ body_html: z.string().optional(),
191
+ category: z.string().optional(),
192
+ description: z.string().optional(),
193
+ tags: z.array(z.string()).optional(),
194
+ },
195
+ }, async ({ name, brief, subject_hint, body_html, category, description, tags }) => {
196
+ const projectId = getProjectId();
197
+ const { data, error } = await db().from('campaign_templates')
198
+ .insert({
199
+ project_id: projectId,
200
+ name,
201
+ brief_template: brief,
202
+ subject_hint: subject_hint ?? '',
203
+ body_html: body_html ?? null,
204
+ category: category ?? 'general',
205
+ description: description ?? '',
206
+ tags: tags ?? [],
207
+ })
208
+ .select('id, name, category, subject_hint, created_at')
209
+ .single();
210
+ if (error)
211
+ return j({ error: error.message });
212
+ return j({ template: data, ok: true });
213
+ });
214
+ server.registerTool('update_template', {
215
+ description: 'Update an existing email template.',
216
+ inputSchema: {
217
+ id: z.string().uuid(),
218
+ name: z.string().optional(),
219
+ brief: z.string().optional(),
220
+ subject_hint: z.string().optional(),
221
+ body_html: z.string().optional(),
222
+ category: z.string().optional(),
223
+ description: z.string().optional(),
224
+ tags: z.array(z.string()).optional(),
225
+ },
226
+ }, async ({ id, ...updates }) => {
227
+ const projectId = getProjectId();
228
+ const { data: existing, error: existingErr } = await db().from('campaign_templates')
229
+ .select('id, project_id')
230
+ .eq('id', id)
231
+ .maybeSingle();
232
+ if (existingErr || !existing)
233
+ return j({ error: existingErr?.message ?? 'Template not found', code: 'NOT_FOUND' });
234
+ if (!existing.project_id || existing.project_id !== projectId)
235
+ return j({ error: 'Cannot edit system templates', code: 'FORBIDDEN' });
236
+ const row = { updated_at: new Date().toISOString() };
237
+ if (updates.name !== undefined)
238
+ row.name = updates.name;
239
+ if (updates.brief !== undefined)
240
+ row.brief_template = updates.brief;
241
+ if (updates.subject_hint !== undefined)
242
+ row.subject_hint = updates.subject_hint;
243
+ if (updates.body_html !== undefined)
244
+ row.body_html = updates.body_html;
245
+ if (updates.category !== undefined)
246
+ row.category = updates.category;
247
+ if (updates.description !== undefined)
248
+ row.description = updates.description;
249
+ if (updates.tags !== undefined)
250
+ row.tags = updates.tags;
251
+ const { data, error } = await db().from('campaign_templates')
252
+ .update(row)
253
+ .eq('id', id)
254
+ .eq('project_id', projectId)
255
+ .select('id, name, category, subject_hint, updated_at')
256
+ .single();
257
+ if (error)
258
+ return j({ error: error.message });
259
+ return j({ template: data, ok: true });
260
+ });
261
+ server.registerTool('delete_template', {
262
+ description: 'Archive an email template. System templates cannot be deleted.',
263
+ inputSchema: { id: z.string().uuid() },
264
+ }, async ({ id }) => {
265
+ const projectId = getProjectId();
266
+ const { data: existing, error: existingErr } = await db().from('campaign_templates')
267
+ .select('id, project_id')
268
+ .eq('id', id)
269
+ .maybeSingle();
270
+ if (existingErr || !existing)
271
+ return j({ error: existingErr?.message ?? 'Template not found', code: 'NOT_FOUND' });
272
+ if (!existing.project_id || existing.project_id !== projectId)
273
+ return j({ error: 'Cannot delete system templates', code: 'FORBIDDEN' });
274
+ const { error } = await db().from('campaign_templates')
275
+ .update({ is_archived: true, updated_at: new Date().toISOString() })
276
+ .eq('id', id)
277
+ .eq('project_id', projectId);
278
+ if (error)
279
+ return j({ error: error.message });
280
+ return j({ ok: true, deleted: id });
281
+ });
282
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerWarmupTools(server: McpServer): void;