@sendinel/mcp-server 1.0.176 → 1.0.177

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { withCreatedItems } from '../lib/mcp-created-items.js';
2
- import { literalIlikePattern } from '@sendinel/domain';
2
+ import { literalIlikePattern, refuseActiveText, findUnresolvedTemplateTags } from '@sendinel/domain';
3
3
  import { withApproval } from '../lib/action-approvals.js';
4
4
  import { snapshotTemplateVersion, versionHasBodySnapshot } from '@sendinel/domain';
5
5
  import { z } from 'zod';
@@ -8,9 +8,20 @@ import { db, requestContext } from '../db.js';
8
8
  import { getProjectId } from '../project.js';
9
9
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
10
10
  import { deriveTemplateVariables } from '../lib/voice.js';
11
- import { dbToolError } from '../lib/db-error.js';
11
+ import { dbToolError, toolError } from '../lib/db-error.js';
12
+ import { normalizeTagInput } from '../lib/tags.js';
13
+ // campaign_templates.name is TEXT NOT NULL; enforce the tool's name limit here.
14
+ const templateName = z.string().trim().min(1).max(200);
12
15
  const txt = (text) => ({ content: [{ type: 'text', text }] });
13
16
  const j = (data) => txt(JSON.stringify(data, null, 2));
17
+ function templateContentError(content) {
18
+ return refuseActiveText('body_html', content.body_html) ?? refuseActiveText('subject_hint', content.subject_hint);
19
+ }
20
+ function templateWarnings(content) {
21
+ const tags = new Set([content.body_html, content.subject_hint]
22
+ .flatMap(value => typeof value === 'string' ? findUnresolvedTemplateTags(value) : []));
23
+ return [...tags].map(tag => `Unresolved template tag: ${tag}`);
24
+ }
14
25
  function stripHtml(value) {
15
26
  return value
16
27
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
@@ -39,17 +50,15 @@ function translateMergeTags(body) {
39
50
  variables.add('unsubscribe_url');
40
51
  return '{{unsubscribe_url}}';
41
52
  })
42
- .replace(/\*\|FNAME\|\*/g, () => {
43
- variables.add('first_name');
44
- return '{{first_name}}';
45
- })
46
- .replace(/\*\|LNAME\|\*/g, () => {
47
- variables.add('last_name');
48
- return '{{last_name}}';
49
- })
50
- .replace(/\*\|EMAIL\|\*/g, () => {
51
- variables.add('email');
52
- return '{{email}}';
53
+ .replace(/\*\|([^|]*?)\|\*/g, (literal, tag) => {
54
+ const mapped = { FNAME: 'first_name', LNAME: 'last_name', EMAIL: 'email', UNSUB: 'unsubscribe_url' };
55
+ const key = mapped[tag.toUpperCase()];
56
+ if (!key) {
57
+ unmapped.add(literal);
58
+ return literal;
59
+ }
60
+ variables.add(key);
61
+ return `{{${key}}}`;
53
62
  })
54
63
  .replace(/{{\s*customer\.([a-zA-Z0-9_.-]+)(?:\s*\|[^}]*)?\s*}}/g, (_match, token) => {
55
64
  const name = `custom.${String(token).replace(/^custom\./, '')}`;
@@ -57,12 +66,8 @@ function translateMergeTags(body) {
57
66
  unmapped.add(`customer.${token}`);
58
67
  return `{{${name}}}`;
59
68
  });
60
- const tokenPattern = /{{\s*([a-zA-Z0-9_.-]+)\s*}}/g;
61
- let match;
62
- while ((match = tokenPattern.exec(translated)) !== null) {
63
- if (match[1])
64
- variables.add(match[1]);
65
- }
69
+ for (const variable of deriveTemplateVariables({ body_html: translated }))
70
+ variables.add(variable);
66
71
  return { body: translated, variables: Array.from(variables), unmapped: Array.from(unmapped) };
67
72
  }
68
73
  export function registerTemplateTools(server) {
@@ -77,17 +82,19 @@ export function registerTemplateTools(server) {
77
82
  description: 'List email templates for the current project, including reusable system templates.',
78
83
  inputSchema: {
79
84
  limit: z.number().int().min(1).max(100).default(50).optional(),
85
+ offset: z.number().int().min(0).default(0).optional(),
80
86
  category: z.string().optional(),
81
87
  search: z.string().optional(),
82
88
  },
83
- }, async ({ limit = 50, category, search }) => {
89
+ }, async ({ limit = 50, offset = 0, category, search }) => {
84
90
  const projectId = getProjectId();
85
91
  let query = db().from('campaign_templates')
86
92
  .select('id, name, category, description, subject_hint, brief_template, body_html, variables, tags, project_id, created_at, updated_at, is_transactional')
87
93
  .eq('is_archived', false)
88
94
  .or(`project_id.is.null,project_id.eq.${projectId}`)
89
95
  .order('created_at', { ascending: false })
90
- .limit(limit);
96
+ .order('id', { ascending: false })
97
+ .range(offset, offset + limit);
91
98
  if (category)
92
99
  query = query.eq('category', category);
93
100
  if (search)
@@ -95,7 +102,9 @@ export function registerTemplateTools(server) {
95
102
  const { data, error } = await query;
96
103
  if (error)
97
104
  return j(dbToolError(error));
98
- return j({ templates: data ?? [], count: data?.length ?? 0, truncated: (data?.length ?? 0) === limit });
105
+ const templates = (data ?? []).slice(0, limit);
106
+ const truncated = (data?.length ?? 0) > limit;
107
+ return j({ templates, count: templates.length, truncated, next_offset: truncated ? offset + limit : null });
99
108
  });
100
109
  server.registerTool('translate_template', {
101
110
  title: 'Translate Template',
@@ -125,11 +134,12 @@ export function registerTemplateTools(server) {
125
134
  if (!source)
126
135
  return j({ error: 'html or text is required' });
127
136
  const translatedBody = translateMergeTags(source);
137
+ const translatedText = text !== undefined ? translateMergeTags(text) : null;
128
138
  const translatedSubject = translateMergeTags(String(subject ?? name ?? 'Imported template'));
129
139
  const bodyHtml = html ? translatedBody.body : null;
130
- const bodyText = text ? translatedBody.body : stripHtml(translatedBody.body);
131
- const variables = Array.from(new Set([...translatedBody.variables, ...translatedSubject.variables]));
132
- const unmapped = Array.from(new Set([...translatedBody.unmapped, ...translatedSubject.unmapped]));
140
+ const bodyText = translatedText?.body ?? stripHtml(translatedBody.body);
141
+ const variables = Array.from(new Set([...translatedBody.variables, ...(translatedText?.variables ?? []), ...translatedSubject.variables]));
142
+ const unmapped = Array.from(new Set([...translatedBody.unmapped, ...(translatedText?.unmapped ?? []), ...translatedSubject.unmapped]));
133
143
  const translated = {
134
144
  provider,
135
145
  source_template_id: source_template_id ?? null,
@@ -213,12 +223,14 @@ export function registerTemplateTools(server) {
213
223
  }, async ({ id }) => {
214
224
  const projectId = getProjectId();
215
225
  const { data, error } = await db().from('campaign_templates')
216
- .select('id, name, category, description, subject_hint, brief_template, body_html, variables, tags, project_id, created_at, updated_at, is_transactional')
226
+ .select('id, name, category, description, subject_hint, brief_template, body_html, variables, tags, project_id, created_at, updated_at, is_transactional, is_archived')
217
227
  .eq('id', id)
218
228
  .or(`project_id.is.null,project_id.eq.${projectId}`)
219
229
  .maybeSingle();
220
230
  if (error)
221
231
  return j(dbToolError(error));
232
+ if (!data)
233
+ return toolError({ error: 'Template not found', code: 'not_found' });
222
234
  return j({ template: data });
223
235
  });
224
236
  server.registerTool('create_template', {
@@ -231,7 +243,7 @@ export function registerTemplateTools(server) {
231
243
  },
232
244
  description: 'Create a new email template. Use create_template_from_brief to AI-generate the content.',
233
245
  inputSchema: {
234
- name: z.string().min(1),
246
+ name: templateName,
235
247
  brief: z.string().min(1),
236
248
  subject_hint: z.string().optional(),
237
249
  body_html: z.string().optional(),
@@ -242,18 +254,25 @@ export function registerTemplateTools(server) {
242
254
  brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this template.'),
243
255
  },
244
256
  }, async ({ name, brief, subject_hint, body_html, category, description, tags, is_transactional, brand_asset_ids }) => {
257
+ const content = { body_html, subject_hint };
258
+ const contentError = templateContentError(content);
259
+ if (contentError)
260
+ return toolError({ error: contentError, code: 'validation_failed' });
261
+ const parsedName = templateName.safeParse(name);
262
+ if (!parsedName.success)
263
+ return toolError({ error: 'Name must contain 1 to 200 characters after trimming', code: 'validation_failed' });
245
264
  const projectId = getProjectId();
246
265
  const { data, error } = await withCreatedItems('campaign_templates', db().from('campaign_templates')
247
266
  .insert({
248
267
  project_id: projectId,
249
- name,
268
+ name: parsedName.data,
250
269
  brief_template: brief,
251
270
  subject_hint: subject_hint ?? '',
252
271
  body_html: body_html ?? null,
253
272
  variables: deriveTemplateVariables({ body_html: body_html ?? null, subject_hint: subject_hint ?? '' }),
254
273
  category: category ?? 'general',
255
274
  description: description ?? '',
256
- tags: tags ?? [],
275
+ tags: normalizeTagInput(tags ?? []).tags,
257
276
  is_transactional: is_transactional ?? false,
258
277
  })
259
278
  .select('id, name, category, description, subject_hint, brief_template, body_html, body_source, body_text, format, variables, tags, created_at')
@@ -284,7 +303,7 @@ export function registerTemplateTools(server) {
284
303
  tags: data.tags,
285
304
  createdBy: requestContext()?.actorId ?? null,
286
305
  }).catch((err) => console.warn('Failed to snapshot template version for MCP create_template:', err));
287
- return j({ template: data, ok: true });
306
+ return j({ template: data, ok: true, warnings: templateWarnings(content) });
288
307
  });
289
308
  server.registerTool('update_template', {
290
309
  title: 'Update Template',
@@ -297,7 +316,7 @@ export function registerTemplateTools(server) {
297
316
  description: 'Update an existing email template.',
298
317
  inputSchema: {
299
318
  id: z.string().uuid(),
300
- name: z.string().optional(),
319
+ name: templateName.optional(),
301
320
  brief: z.string().optional(),
302
321
  subject_hint: z.string().optional(),
303
322
  body_html: z.string().optional(),
@@ -313,13 +332,26 @@ export function registerTemplateTools(server) {
313
332
  .select('id, project_id, body_html, subject_hint')
314
333
  .eq('id', id)
315
334
  .maybeSingle();
316
- if (existingErr || !existing)
317
- return j({ error: (existingErr ? dbToolError(existingErr).error : undefined) ?? 'Template not found', code: 'NOT_FOUND' });
335
+ if (existingErr)
336
+ return toolError(dbToolError(existingErr));
337
+ if (!existing)
338
+ return toolError({ error: 'Template not found', code: 'not_found' });
318
339
  if (!existing.project_id || existing.project_id !== projectId)
319
- return j({ error: 'Cannot edit system templates', code: 'FORBIDDEN' });
340
+ return toolError({ error: 'Cannot edit system or other-project templates', code: 'forbidden' });
341
+ const content = {
342
+ body_html: updates.body_html !== undefined ? updates.body_html : existing.body_html,
343
+ subject_hint: updates.subject_hint !== undefined ? updates.subject_hint : existing.subject_hint,
344
+ };
345
+ const contentError = templateContentError(content);
346
+ if (contentError)
347
+ return toolError({ error: contentError, code: 'validation_failed' });
320
348
  const row = { updated_at: new Date().toISOString() };
321
- if (updates.name !== undefined)
322
- row.name = updates.name;
349
+ if (updates.name !== undefined) {
350
+ const parsedName = templateName.safeParse(updates.name);
351
+ if (!parsedName.success)
352
+ return toolError({ error: 'Name must contain 1 to 200 characters after trimming', code: 'validation_failed' });
353
+ row.name = parsedName.data;
354
+ }
323
355
  if (updates.brief !== undefined)
324
356
  row.brief_template = updates.brief;
325
357
  if (updates.subject_hint !== undefined)
@@ -331,13 +363,10 @@ export function registerTemplateTools(server) {
331
363
  if (updates.description !== undefined)
332
364
  row.description = updates.description;
333
365
  if (updates.tags !== undefined)
334
- row.tags = updates.tags;
366
+ row.tags = normalizeTagInput(updates.tags).tags;
335
367
  if (updates.is_transactional !== undefined)
336
368
  row.is_transactional = updates.is_transactional;
337
- row.variables = deriveTemplateVariables({
338
- body_html: updates.body_html !== undefined ? updates.body_html : existing.body_html,
339
- subject_hint: updates.subject_hint !== undefined ? updates.subject_hint : existing.subject_hint,
340
- });
369
+ row.variables = deriveTemplateVariables(content);
341
370
  const { data, error } = await db().from('campaign_templates')
342
371
  .update(row)
343
372
  .eq('id', id)
@@ -370,7 +399,7 @@ export function registerTemplateTools(server) {
370
399
  tags: data.tags,
371
400
  createdBy: requestContext()?.actorId ?? null,
372
401
  }).catch((err) => console.warn('Failed to snapshot template version for MCP update_template:', err));
373
- return j({ template: data, ok: true });
402
+ return j({ template: data, ok: true, warnings: templateWarnings(content) });
374
403
  });
375
404
  server.registerTool('delete_template', {
376
405
  title: 'Delete Template',
@@ -388,17 +417,19 @@ export function registerTemplateTools(server) {
388
417
  .select('id, project_id')
389
418
  .eq('id', id)
390
419
  .maybeSingle();
391
- if (existingErr || !existing)
392
- return j({ error: (existingErr ? dbToolError(existingErr).error : undefined) ?? 'Template not found', code: 'NOT_FOUND' });
420
+ if (existingErr)
421
+ return toolError(dbToolError(existingErr));
422
+ if (!existing)
423
+ return toolError({ error: 'Template not found', code: 'not_found' });
393
424
  if (!existing.project_id || existing.project_id !== projectId)
394
- return j({ error: 'Cannot delete system templates', code: 'FORBIDDEN' });
425
+ return toolError({ error: 'Cannot archive system or other-project templates', code: 'forbidden' });
395
426
  const { error } = await db().from('campaign_templates')
396
427
  .update({ is_archived: true, updated_at: new Date().toISOString() })
397
428
  .eq('id', id)
398
429
  .eq('project_id', projectId);
399
430
  if (error)
400
431
  return j(dbToolError(error));
401
- return j({ ok: true, deleted: id });
432
+ return j({ ok: true, deleted: id, archived: true });
402
433
  }));
403
434
  server.registerTool('restore_template_version', {
404
435
  title: 'Restore Template Version',
@@ -419,17 +450,21 @@ export function registerTemplateTools(server) {
419
450
  .select('id, project_id, body_html, body_text, subject_hint')
420
451
  .eq('id', id)
421
452
  .maybeSingle();
422
- if (existingErr || !existing)
423
- return j({ error: (existingErr ? dbToolError(existingErr).error : undefined) ?? 'Template not found', code: 'NOT_FOUND' });
453
+ if (existingErr)
454
+ return toolError(dbToolError(existingErr));
455
+ if (!existing)
456
+ return toolError({ error: 'Template not found', code: 'not_found' });
424
457
  if (!existing.project_id || existing.project_id !== projectId)
425
- return j({ error: 'Cannot restore a system template', code: 'FORBIDDEN' });
458
+ return toolError({ error: 'Cannot restore system or other-project templates', code: 'forbidden' });
426
459
  const { data: version, error: versionErr } = await db().from('template_versions')
427
460
  .select('id, version, name, category, description, subject_hint, brief_template, tags, body_html, body_source, body_text, format')
428
461
  .eq('id', version_id)
429
462
  .eq('template_id', id)
430
463
  .maybeSingle();
431
- if (versionErr || !version)
432
- return j({ error: (versionErr ? dbToolError(versionErr).error : undefined) ?? 'Version not found', code: 'NOT_FOUND' });
464
+ if (versionErr)
465
+ return toolError(dbToolError(versionErr));
466
+ if (!version)
467
+ return toolError({ error: 'Version not found', code: 'not_found' });
433
468
  const bodyRestored = versionHasBodySnapshot(version);
434
469
  const row = {
435
470
  updated_at: new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.176",
3
+ "version": "1.0.177",
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",