@sendinel/mcp-server 1.0.3 → 1.0.5

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 (60) hide show
  1. package/build/audit.d.ts +1 -0
  2. package/build/audit.js +9 -3
  3. package/build/auth.d.ts +10 -0
  4. package/build/auth.js +82 -9
  5. package/build/db.d.ts +12 -0
  6. package/build/db.js +37 -2
  7. package/build/index.js +38 -12
  8. package/build/lib/action-approvals.d.ts +28 -1
  9. package/build/lib/action-approvals.js +89 -2
  10. package/build/lib/brand-asset-persist.d.ts +35 -0
  11. package/build/lib/brand-asset-persist.js +161 -0
  12. package/build/lib/brand-asset-usage.d.ts +13 -0
  13. package/build/lib/brand-asset-usage.js +46 -0
  14. package/build/lib/email-provider.d.ts +19 -0
  15. package/build/lib/email-provider.js +281 -0
  16. package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
  17. package/build/lib/ssrf-pinned-fetch.js +101 -0
  18. package/build/lib/survey-link-resolver.d.ts +6 -0
  19. package/build/lib/survey-link-resolver.js +23 -0
  20. package/build/lib/svg-sanitize.d.ts +2 -0
  21. package/build/lib/svg-sanitize.js +110 -0
  22. package/build/lib/webhook-events.d.ts +1 -1
  23. package/build/scoped-token.d.ts +11 -0
  24. package/build/scoped-token.js +59 -0
  25. package/build/server.js +30 -9
  26. package/build/tools/ab-testing.js +28 -0
  27. package/build/tools/advisor.js +52 -2
  28. package/build/tools/ai-work.d.ts +2 -0
  29. package/build/tools/ai-work.js +460 -0
  30. package/build/tools/analytics.d.ts +1 -0
  31. package/build/tools/analytics.js +203 -0
  32. package/build/tools/approvals.js +15 -1
  33. package/build/tools/automations.js +288 -65
  34. package/build/tools/brand-kit.d.ts +2 -0
  35. package/build/tools/brand-kit.js +277 -0
  36. package/build/tools/campaigns.js +900 -69
  37. package/build/tools/compound.js +28 -0
  38. package/build/tools/contacts.js +338 -17
  39. package/build/tools/content.js +266 -1
  40. package/build/tools/data-proposals.js +21 -0
  41. package/build/tools/data.js +121 -5
  42. package/build/tools/delivery-ops.js +84 -0
  43. package/build/tools/drafts.js +64 -1
  44. package/build/tools/forms.js +42 -9
  45. package/build/tools/gdpr.js +22 -3
  46. package/build/tools/newsletters.d.ts +2 -0
  47. package/build/tools/newsletters.js +583 -0
  48. package/build/tools/org.js +297 -3
  49. package/build/tools/segments.js +45 -3
  50. package/build/tools/short-links.d.ts +2 -0
  51. package/build/tools/short-links.js +157 -0
  52. package/build/tools/sites.js +35 -0
  53. package/build/tools/sms.js +49 -0
  54. package/build/tools/social-posts.js +81 -5
  55. package/build/tools/surveys.d.ts +2 -0
  56. package/build/tools/surveys.js +222 -0
  57. package/build/tools/templates.js +78 -2
  58. package/build/tools/warmup.js +28 -0
  59. package/build/tools/webhooks.js +29 -0
  60. package/package.json +10 -4
@@ -218,6 +218,13 @@ async function logBlocked(projectId, orgId, siteId, contactId, phone, reasonCode
218
218
  }
219
219
  export function registerSmsTools(server) {
220
220
  server.registerTool('list_sms_log', {
221
+ title: 'List SMS Log',
222
+ annotations: {
223
+ readOnlyHint: true,
224
+ destructiveHint: false,
225
+ idempotentHint: true,
226
+ openWorldHint: false,
227
+ },
221
228
  description: 'List recent SMS sends with delivery status and cost.',
222
229
  inputSchema: {
223
230
  status: z.enum(['queued', 'sending', 'sent', 'delivered', 'failed', 'undelivered']).optional(),
@@ -238,6 +245,13 @@ export function registerSmsTools(server) {
238
245
  return j({ rows: data ?? [] });
239
246
  });
240
247
  server.registerTool('list_sms_inbound', {
248
+ title: 'List SMS Inbound',
249
+ annotations: {
250
+ readOnlyHint: true,
251
+ destructiveHint: false,
252
+ idempotentHint: true,
253
+ openWorldHint: false,
254
+ },
241
255
  description: 'List recent inbound SMS replies and their interpreted intent (stop/help/reply).',
242
256
  inputSchema: {
243
257
  interpreted_as: z.enum(['stop', 'help', 'start', 'reply', 'unknown']).optional(),
@@ -258,6 +272,13 @@ export function registerSmsTools(server) {
258
272
  return j({ rows: data ?? [] });
259
273
  });
260
274
  server.registerTool('get_sms_provider_status', {
275
+ title: 'Get SMS Provider Status',
276
+ annotations: {
277
+ readOnlyHint: true,
278
+ destructiveHint: false,
279
+ idempotentHint: true,
280
+ openWorldHint: false,
281
+ },
261
282
  description: "Return the connection status, brand info, and recent error rate for the tenant's SMS provider.",
262
283
  inputSchema: {},
263
284
  }, async () => {
@@ -278,6 +299,13 @@ export function registerSmsTools(server) {
278
299
  });
279
300
  });
280
301
  server.registerTool('set_contact_sms_consent', {
302
+ title: 'Set Contact SMS Consent',
303
+ annotations: {
304
+ readOnlyHint: false,
305
+ destructiveHint: false,
306
+ idempotentHint: true,
307
+ openWorldHint: false,
308
+ },
281
309
  description: 'Manually record an opt-in or opt-out for a contact (e.g., from a paper form or phone conversation).',
282
310
  inputSchema: {
283
311
  contact_id: z.string().uuid(),
@@ -340,6 +368,13 @@ export function registerSmsTools(server) {
340
368
  return j({ ok: true, contact_id, phone_e164: contact.phone_e164, status });
341
369
  });
342
370
  server.registerTool('preview_sms_cost', {
371
+ title: 'Preview SMS Cost',
372
+ annotations: {
373
+ readOnlyHint: true,
374
+ destructiveHint: false,
375
+ idempotentHint: true,
376
+ openWorldHint: false,
377
+ },
343
378
  description: 'Given a draft SMS body and a segment, return the segment count, encoding, and estimated total cost.',
344
379
  inputSchema: {
345
380
  body: z.string().min(1),
@@ -359,6 +394,13 @@ export function registerSmsTools(server) {
359
394
  });
360
395
  });
361
396
  server.registerTool('send_sms_campaign', {
397
+ title: 'Send SMS Campaign',
398
+ annotations: {
399
+ readOnlyHint: false,
400
+ destructiveHint: true,
401
+ idempotentHint: false,
402
+ openWorldHint: true,
403
+ },
362
404
  description: "Launch an SMS campaign or broadcast to a segment. Same shape as email campaigns but with channel='sms'.",
363
405
  inputSchema: {
364
406
  site_id: z.string().uuid(),
@@ -412,6 +454,13 @@ export function registerSmsTools(server) {
412
454
  });
413
455
  });
414
456
  server.registerTool('send_sms', {
457
+ title: 'Send SMS',
458
+ annotations: {
459
+ readOnlyHint: false,
460
+ destructiveHint: true,
461
+ idempotentHint: false,
462
+ openWorldHint: true,
463
+ },
415
464
  description: 'Send a single SMS through the active SMS provider to a contact or raw phone number. Validates consent and quiet hours before sending.',
416
465
  inputSchema: {
417
466
  body: z.string().min(1),
@@ -6,6 +6,7 @@ import { getAnthropicModelId } from '../anthropic-model.js';
6
6
  import { buildVoicePrompt } from '../lib/voice.js';
7
7
  import { buildSocialSystemPrompt, buildSocialUserPrompt, parseSocialResponse, selectArchetype, } from '../lib/social-engine/index.js';
8
8
  import { fireWebhookEvent } from '../lib/webhook-events.js';
9
+ import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
9
10
  const txt = (text) => ({ content: [{ type: 'text', text }] });
10
11
  const j = (data) => txt(JSON.stringify(data, null, 2));
11
12
  const PLATFORMS = [
@@ -53,6 +54,21 @@ function statusFromSchedule(scheduledAt, queueRequested) {
53
54
  return 'scheduled';
54
55
  return 'draft';
55
56
  }
57
+ function linkedInCompanyCrosspostMetadata(enabled, pageId, platforms) {
58
+ if (!enabled)
59
+ return {};
60
+ const target = pageId?.trim();
61
+ if (!platforms.includes('linkedin'))
62
+ throw new Error('LinkedIn company cross-post requires linkedin in platforms.');
63
+ if (!target)
64
+ throw new Error('linkedin_company_page_id is required when LinkedIn company cross-post is enabled.');
65
+ return {
66
+ linkedin_company_crosspost: {
67
+ enabled: true,
68
+ target_linkedin_page_id: target.slice(0, 120),
69
+ },
70
+ };
71
+ }
56
72
  function trackedUrl(input) {
57
73
  const url = new URL(input.destinationUrl);
58
74
  const source = input.platform === 'x' ? 'twitter' : input.platform;
@@ -86,6 +102,13 @@ async function requireProjectCampaign(campaignId) {
86
102
  }
87
103
  export function registerSocialPostTools(server) {
88
104
  server.registerTool('list_social_posts', {
105
+ title: 'List Social Posts',
106
+ annotations: {
107
+ readOnlyHint: true,
108
+ destructiveHint: false,
109
+ idempotentHint: true,
110
+ openWorldHint: false,
111
+ },
89
112
  description: 'List first-class social posts for the current project.',
90
113
  inputSchema: {
91
114
  status: z.enum(STATUSES).optional(),
@@ -111,6 +134,13 @@ export function registerSocialPostTools(server) {
111
134
  return j({ posts: data ?? [] });
112
135
  });
113
136
  server.registerTool('get_social_post', {
137
+ title: 'Get Social Post',
138
+ annotations: {
139
+ readOnlyHint: true,
140
+ destructiveHint: false,
141
+ idempotentHint: true,
142
+ openWorldHint: false,
143
+ },
114
144
  description: 'Get one social post by ID.',
115
145
  inputSchema: { post_id: z.string().uuid() },
116
146
  }, async ({ post_id }) => {
@@ -127,6 +157,13 @@ export function registerSocialPostTools(server) {
127
157
  return j(data);
128
158
  });
129
159
  server.registerTool('adapt_social_post', {
160
+ title: 'Adapt Social Post',
161
+ annotations: {
162
+ readOnlyHint: false,
163
+ destructiveHint: false,
164
+ idempotentHint: false,
165
+ openWorldHint: true,
166
+ },
130
167
  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
168
  inputSchema: {
132
169
  email_campaign_id: z.string().uuid(),
@@ -218,6 +255,13 @@ export function registerSocialPostTools(server) {
218
255
  });
219
256
  });
220
257
  server.registerTool('create_social_post', {
258
+ title: 'Create Social Post',
259
+ annotations: {
260
+ readOnlyHint: false,
261
+ destructiveHint: false,
262
+ idempotentHint: false,
263
+ openWorldHint: true,
264
+ },
221
265
  description: 'Create a first-class social post, optionally linked to an email campaign and UTM-tracked social campaign.',
222
266
  inputSchema: {
223
267
  title: z.string().min(1),
@@ -228,12 +272,15 @@ export function registerSocialPostTools(server) {
228
272
  platform_captions: z.record(z.string(), z.string()).optional(),
229
273
  media_urls: z.array(z.string().url()).optional().default([]),
230
274
  destination_url: z.string().url().optional(),
275
+ survey_id: z.string().uuid().optional(),
231
276
  utm_campaign: z.string().optional(),
232
277
  scheduled_at: z.string().datetime().optional(),
233
278
  queue_requested: z.boolean().optional().default(false),
234
279
  timezone: z.string().optional().default('Pacific/Honolulu'),
280
+ linkedin_company_crosspost: z.boolean().optional().default(false),
281
+ linkedin_company_page_id: z.string().optional(),
235
282
  },
236
- }, async ({ title, content, platforms, site_id, email_campaign_id, platform_captions, media_urls, destination_url, utm_campaign, scheduled_at, queue_requested, timezone }) => {
283
+ }, async ({ title, content, platforms, site_id, email_campaign_id, platform_captions, media_urls, destination_url, survey_id, utm_campaign, scheduled_at, queue_requested, timezone, linkedin_company_crosspost, linkedin_company_page_id }) => {
237
284
  const projectId = getProjectId();
238
285
  const orgId = await projectOrgId(projectId);
239
286
  if (!orgId)
@@ -254,10 +301,24 @@ export function registerSocialPostTools(server) {
254
301
  catch (err) {
255
302
  return txt(`Error: ${err instanceof Error ? err.message : 'Invalid schedule'}`);
256
303
  }
304
+ let metadata;
305
+ try {
306
+ metadata = linkedInCompanyCrosspostMetadata(linkedin_company_crosspost, linkedin_company_page_id, platforms);
307
+ }
308
+ catch (err) {
309
+ return txt(`Error: ${err instanceof Error ? err.message : 'Invalid LinkedIn cross-post settings'}`);
310
+ }
311
+ let resolvedDestinationUrl = destination_url;
312
+ if (survey_id) {
313
+ const surveyLink = await resolveSurveyShortLink(db(), projectId, survey_id);
314
+ if (!surveyLink)
315
+ return txt('Error: survey has no short link yet');
316
+ resolvedDestinationUrl = surveyLink;
317
+ }
257
318
  let socialCampaignId = null;
258
319
  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) });
320
+ if (resolvedDestinationUrl) {
321
+ tracked = trackedUrl({ destinationUrl: resolvedDestinationUrl, title, platform: platforms.includes('x') ? 'twitter' : platforms[0], utmCampaign: cleanText(utm_campaign) });
261
322
  const { data: socialCampaign, error: campaignError } = await db()
262
323
  .from('social_campaigns')
263
324
  .insert({
@@ -265,7 +326,7 @@ export function registerSocialPostTools(server) {
265
326
  site_id: site_id ?? null,
266
327
  name: title,
267
328
  platform: tracked.utm_source,
268
- destination_url,
329
+ destination_url: resolvedDestinationUrl,
269
330
  utm_source: tracked.utm_source,
270
331
  utm_medium: tracked.utm_medium,
271
332
  utm_campaign: tracked.utm_campaign,
@@ -292,9 +353,10 @@ export function registerSocialPostTools(server) {
292
353
  title,
293
354
  content,
294
355
  platform_captions: platform_captions ?? {},
356
+ metadata,
295
357
  media_urls: media_urls ?? [],
296
358
  platforms,
297
- destination_url: destination_url ?? null,
359
+ destination_url: resolvedDestinationUrl ?? null,
298
360
  tracked_url: tracked?.tracked_url ?? null,
299
361
  scheduled_at: scheduledAt,
300
362
  timezone: timezone ?? 'Pacific/Honolulu',
@@ -312,6 +374,13 @@ export function registerSocialPostTools(server) {
312
374
  return j(data);
313
375
  });
314
376
  server.registerTool('update_social_post', {
377
+ title: 'Update Social Post',
378
+ annotations: {
379
+ readOnlyHint: false,
380
+ destructiveHint: false,
381
+ idempotentHint: true,
382
+ openWorldHint: true,
383
+ },
315
384
  description: 'Update a social post by ID. Supports copy, captions, schedule, status, and metrics fields.',
316
385
  inputSchema: {
317
386
  post_id: z.string().uuid(),
@@ -362,6 +431,13 @@ export function registerSocialPostTools(server) {
362
431
  return j(data);
363
432
  });
364
433
  server.registerTool('delete_social_post', {
434
+ title: 'Delete Social Post',
435
+ annotations: {
436
+ readOnlyHint: false,
437
+ destructiveHint: true,
438
+ idempotentHint: false,
439
+ openWorldHint: true,
440
+ },
365
441
  description: 'Archive a social post so it disappears from active planner and list views.',
366
442
  inputSchema: { post_id: z.string().uuid() },
367
443
  }, async ({ post_id }) => {
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSurveyTools(server: McpServer): void;
@@ -0,0 +1,222 @@
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
+ const QUESTION_SCHEMA = z.object({
7
+ id: z.string().min(1).optional(),
8
+ text: z.string().min(1).optional(),
9
+ label: z.string().min(1).optional(),
10
+ type: z.enum(['single_choice', 'multi_choice', 'multiple_choice', 'text', 'rating', 'nps', 'csat']),
11
+ options: z.array(z.string()).optional(),
12
+ required: z.boolean().optional(),
13
+ });
14
+ function appBaseUrl() {
15
+ return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
16
+ }
17
+ function normalizeQuestions(questions) {
18
+ return questions.map((question, index) => {
19
+ const id = question.id?.trim() || `q${index + 1}`;
20
+ const label = question.label?.trim() || question.text?.trim() || '';
21
+ return {
22
+ ...question,
23
+ id,
24
+ label,
25
+ type: question.type === 'single_choice' ? 'multiple_choice' : question.type,
26
+ };
27
+ });
28
+ }
29
+ function tallyResponses(responses, questions) {
30
+ return questions.map((question) => {
31
+ const values = responses
32
+ .map((row) => row.answers?.[question.id])
33
+ .filter((value) => value !== undefined && value !== null && value !== '');
34
+ const numeric = values.map((value) => Number(value)).filter((value) => Number.isFinite(value));
35
+ const optionCounts = {};
36
+ for (const value of values) {
37
+ if (Array.isArray(value)) {
38
+ for (const item of value)
39
+ optionCounts[String(item)] = (optionCounts[String(item)] ?? 0) + 1;
40
+ }
41
+ else {
42
+ optionCounts[String(value)] = (optionCounts[String(value)] ?? 0) + 1;
43
+ }
44
+ }
45
+ return {
46
+ question_id: question.id,
47
+ type: question.type,
48
+ response_count: values.length,
49
+ average: numeric.length ? numeric.reduce((sum, value) => sum + value, 0) / numeric.length : null,
50
+ option_counts: optionCounts,
51
+ text_responses: question.type === 'text' ? values.map(String) : undefined,
52
+ };
53
+ });
54
+ }
55
+ export function registerSurveyTools(server) {
56
+ server.registerTool('create_survey', {
57
+ title: 'Create Survey',
58
+ annotations: {
59
+ readOnlyHint: false,
60
+ destructiveHint: false,
61
+ idempotentHint: false,
62
+ openWorldHint: false,
63
+ },
64
+ description: 'Create a project-scoped survey with one or more questions. Returns the survey and public response short link when available.',
65
+ inputSchema: {
66
+ title: z.string().min(1),
67
+ questions: z.array(QUESTION_SCHEMA).min(1),
68
+ site_id: z.string().uuid().optional(),
69
+ status: z.enum(['draft', 'active', 'closed']).optional().default('draft'),
70
+ },
71
+ }, async ({ title, questions, site_id, status }) => {
72
+ const projectId = getProjectId();
73
+ const { data: project } = await db().from('projects').select('org_id, plan').eq('id', projectId).maybeSingle();
74
+ if (!project?.org_id)
75
+ return j({ error: 'Project not found', code: 'NOT_FOUND' });
76
+ const { data: survey, error } = await db()
77
+ .from('surveys')
78
+ .insert({ project_id: projectId, title, questions: normalizeQuestions(questions), status })
79
+ .select()
80
+ .single();
81
+ if (error)
82
+ return j({ error: error.message });
83
+ const responseUrl = `${appBaseUrl()}/api/surveys/respond?survey_id=${survey.id}`;
84
+ const { data: slugData, error: slugError } = await db().rpc('next_short_link_slug');
85
+ if (slugError || !slugData)
86
+ return j({ survey, response_url: responseUrl, short_link: null, warning: slugError?.message ?? 'short link slug unavailable' });
87
+ const { data: shortLink, error: linkError } = await db()
88
+ .from('short_links')
89
+ .insert({
90
+ slug: slugData,
91
+ org_id: project.org_id,
92
+ project_id: projectId,
93
+ destination_url: responseUrl,
94
+ source_type: 'survey',
95
+ source_id: survey.id,
96
+ })
97
+ .select()
98
+ .single();
99
+ if (!linkError && shortLink?.id) {
100
+ await db().from('surveys').update({ short_link_id: shortLink.id }).eq('id', survey.id).eq('project_id', projectId);
101
+ }
102
+ return j({
103
+ survey: { ...survey, short_link_id: shortLink?.id ?? null, site_id: site_id ?? null },
104
+ response_url: responseUrl,
105
+ short_link: shortLink ?? null,
106
+ short_url: shortLink?.slug ? `${appBaseUrl()}/s/${shortLink.slug}` : null,
107
+ ...(linkError ? { warning: linkError.message } : {}),
108
+ });
109
+ });
110
+ server.registerTool('list_surveys', {
111
+ title: 'List Surveys',
112
+ annotations: {
113
+ readOnlyHint: true,
114
+ destructiveHint: false,
115
+ idempotentHint: true,
116
+ openWorldHint: false,
117
+ },
118
+ description: 'List surveys for the current project.',
119
+ inputSchema: {
120
+ status: z.enum(['draft', 'active', 'closed']).optional(),
121
+ limit: z.number().int().min(1).max(200).optional().default(50),
122
+ },
123
+ }, async ({ status, limit }) => {
124
+ const projectId = getProjectId();
125
+ let query = db().from('surveys').select('id, title, status, short_link_id, created_at, updated_at').eq('project_id', projectId).order('created_at', { ascending: false }).limit(limit);
126
+ if (status)
127
+ query = query.eq('status', status);
128
+ const { data, error } = await query;
129
+ if (error)
130
+ return j({ error: error.message });
131
+ return j({ surveys: data ?? [], count: data?.length ?? 0 });
132
+ });
133
+ server.registerTool('get_survey', {
134
+ title: 'Get Survey',
135
+ annotations: {
136
+ readOnlyHint: true,
137
+ destructiveHint: false,
138
+ idempotentHint: true,
139
+ openWorldHint: false,
140
+ },
141
+ description: 'Get a project-scoped survey by id, including questions and short-link metadata.',
142
+ inputSchema: { survey_id: z.string().uuid() },
143
+ }, async ({ survey_id }) => {
144
+ const projectId = getProjectId();
145
+ const { data: survey, error } = await db().from('surveys').select('*').eq('id', survey_id).eq('project_id', projectId).maybeSingle();
146
+ if (error)
147
+ return j({ error: error.message });
148
+ if (!survey)
149
+ return j({ error: 'Survey not found', code: 'NOT_FOUND' });
150
+ const { data: shortLink } = survey.short_link_id
151
+ ? await db().from('short_links').select('id, slug, destination_url, click_count, last_clicked_at').eq('id', survey.short_link_id).eq('project_id', projectId).maybeSingle()
152
+ : { data: null };
153
+ return j({ survey, short_link: shortLink ?? null });
154
+ });
155
+ server.registerTool('get_survey_results', {
156
+ title: 'Get Survey Results',
157
+ annotations: {
158
+ readOnlyHint: true,
159
+ destructiveHint: false,
160
+ idempotentHint: true,
161
+ openWorldHint: false,
162
+ },
163
+ description: 'Get aggregated response results for a project-scoped survey.',
164
+ inputSchema: { survey_id: z.string().uuid() },
165
+ }, async ({ survey_id }) => {
166
+ const projectId = getProjectId();
167
+ const { data: survey, error: surveyError } = await db().from('surveys').select('id, title, questions').eq('id', survey_id).eq('project_id', projectId).maybeSingle();
168
+ if (surveyError)
169
+ return j({ error: surveyError.message });
170
+ if (!survey)
171
+ return j({ error: 'Survey not found', code: 'NOT_FOUND' });
172
+ const { data: responses, error: responseError } = await db().from('survey_responses').select('id, answers, submitted_at').eq('survey_id', survey_id).eq('project_id', projectId).order('submitted_at', { ascending: false });
173
+ if (responseError)
174
+ return j({ error: responseError.message });
175
+ const questions = normalizeQuestions((survey.questions ?? []));
176
+ return j({
177
+ survey_id,
178
+ title: survey.title,
179
+ response_count: responses?.length ?? 0,
180
+ questions: tallyResponses((responses ?? []), questions),
181
+ });
182
+ });
183
+ server.registerTool('update_survey', {
184
+ title: 'Update Survey',
185
+ annotations: {
186
+ readOnlyHint: false,
187
+ destructiveHint: false,
188
+ idempotentHint: true,
189
+ openWorldHint: false,
190
+ },
191
+ description: 'Update a project-scoped survey title, questions, or status.',
192
+ inputSchema: {
193
+ survey_id: z.string().uuid(),
194
+ title: z.string().min(1).optional(),
195
+ questions: z.array(QUESTION_SCHEMA).min(1).optional(),
196
+ status: z.enum(['draft', 'active', 'closed']).optional(),
197
+ },
198
+ }, async ({ survey_id, title, questions, status }) => {
199
+ const projectId = getProjectId();
200
+ const updates = {};
201
+ if (title !== undefined)
202
+ updates.title = title;
203
+ if (questions !== undefined)
204
+ updates.questions = normalizeQuestions(questions);
205
+ if (status !== undefined)
206
+ updates.status = status;
207
+ if (Object.keys(updates).length === 0)
208
+ return j({ error: 'No fields to update', code: 'INVALID_INPUT' });
209
+ const { data, error } = await db()
210
+ .from('surveys')
211
+ .update({ ...updates, updated_at: new Date().toISOString() })
212
+ .eq('id', survey_id)
213
+ .eq('project_id', projectId)
214
+ .select()
215
+ .maybeSingle();
216
+ if (error)
217
+ return j({ error: error.message });
218
+ if (!data)
219
+ return j({ error: 'Survey not found', code: 'NOT_FOUND' });
220
+ return j({ survey: data, ok: true });
221
+ });
222
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
+ import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
4
5
  const txt = (text) => ({ content: [{ type: 'text', text }] });
5
6
  const j = (data) => txt(JSON.stringify(data, null, 2));
6
7
  function stripHtml(value) {
@@ -59,6 +60,13 @@ function translateMergeTags(body) {
59
60
  }
60
61
  export function registerTemplateTools(server) {
61
62
  server.registerTool('list_templates', {
63
+ title: 'List Templates',
64
+ annotations: {
65
+ readOnlyHint: true,
66
+ destructiveHint: false,
67
+ idempotentHint: true,
68
+ openWorldHint: false,
69
+ },
62
70
  description: 'List email templates for the current project, including reusable system templates.',
63
71
  inputSchema: {
64
72
  limit: z.number().int().min(1).max(100).default(50).optional(),
@@ -83,6 +91,13 @@ export function registerTemplateTools(server) {
83
91
  return j({ templates: data ?? [], count: data?.length ?? 0 });
84
92
  });
85
93
  server.registerTool('translate_template', {
94
+ title: 'Translate Template',
95
+ annotations: {
96
+ readOnlyHint: false,
97
+ destructiveHint: false,
98
+ idempotentHint: false,
99
+ openWorldHint: false,
100
+ },
86
101
  description: 'Translate rendered source-platform HTML into a Sendinel campaign template. Optionally save it.',
87
102
  inputSchema: {
88
103
  provider: z.enum(['customerio', 'mailchimp', 'klaviyo', 'activecampaign', 'kit', 'resend']).default('customerio').optional(),
@@ -92,8 +107,9 @@ export function registerTemplateTools(server) {
92
107
  html: z.string().optional(),
93
108
  text: z.string().optional(),
94
109
  save: z.boolean().default(false).optional(),
110
+ brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this template.'),
95
111
  },
96
- }, async ({ provider = 'customerio', source_template_id, name, subject, html, text, save = false }) => {
112
+ }, async ({ provider = 'customerio', source_template_id, name, subject, html, text, save = false, brand_asset_ids }) => {
97
113
  const projectId = getProjectId();
98
114
  const { data: project, error: projectError } = await db().from('projects').select('org_id').eq('id', projectId).single();
99
115
  if (projectError || !project?.org_id)
@@ -162,12 +178,29 @@ export function registerTemplateTools(server) {
162
178
  return j({ error: saveError.message });
163
179
  savedTemplateId = saved?.id ?? null;
164
180
  if (savedTemplateId) {
181
+ await recordBrandAssetUsage({
182
+ client: db(),
183
+ projectId,
184
+ source: 'campaign_template',
185
+ resourceId: savedTemplateId,
186
+ html: bodyHtml,
187
+ assetIds: brand_asset_ids,
188
+ }).catch((err) => {
189
+ console.warn('Failed to record brand asset usage for MCP translated template:', err);
190
+ });
165
191
  await db().from('template_translations').update({ saved_template_id: savedTemplateId, status: 'saved' }).eq('id', row.id);
166
192
  }
167
193
  }
168
194
  return j({ ok: true, translation_id: row.id, saved_template_id: savedTemplateId, translated });
169
195
  });
170
196
  server.registerTool('get_template', {
197
+ title: 'Get Template',
198
+ annotations: {
199
+ readOnlyHint: true,
200
+ destructiveHint: false,
201
+ idempotentHint: true,
202
+ openWorldHint: false,
203
+ },
171
204
  description: 'Get a single email template by ID.',
172
205
  inputSchema: { id: z.string().uuid() },
173
206
  }, async ({ id }) => {
@@ -182,6 +215,13 @@ export function registerTemplateTools(server) {
182
215
  return j({ template: data });
183
216
  });
184
217
  server.registerTool('create_template', {
218
+ title: 'Create Template',
219
+ annotations: {
220
+ readOnlyHint: false,
221
+ destructiveHint: false,
222
+ idempotentHint: false,
223
+ openWorldHint: false,
224
+ },
185
225
  description: 'Create a new email template. Use create_template_from_brief to AI-generate the content.',
186
226
  inputSchema: {
187
227
  name: z.string().min(1),
@@ -191,8 +231,9 @@ export function registerTemplateTools(server) {
191
231
  category: z.string().optional(),
192
232
  description: z.string().optional(),
193
233
  tags: z.array(z.string()).optional(),
234
+ brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this template.'),
194
235
  },
195
- }, async ({ name, brief, subject_hint, body_html, category, description, tags }) => {
236
+ }, async ({ name, brief, subject_hint, body_html, category, description, tags, brand_asset_ids }) => {
196
237
  const projectId = getProjectId();
197
238
  const { data, error } = await db().from('campaign_templates')
198
239
  .insert({
@@ -209,9 +250,26 @@ export function registerTemplateTools(server) {
209
250
  .single();
210
251
  if (error)
211
252
  return j({ error: error.message });
253
+ await recordBrandAssetUsage({
254
+ client: db(),
255
+ projectId,
256
+ source: 'campaign_template',
257
+ resourceId: data.id,
258
+ html: body_html,
259
+ assetIds: brand_asset_ids,
260
+ }).catch((err) => {
261
+ console.warn('Failed to record brand asset usage for MCP template:', err);
262
+ });
212
263
  return j({ template: data, ok: true });
213
264
  });
214
265
  server.registerTool('update_template', {
266
+ title: 'Update Template',
267
+ annotations: {
268
+ readOnlyHint: false,
269
+ destructiveHint: false,
270
+ idempotentHint: true,
271
+ openWorldHint: false,
272
+ },
215
273
  description: 'Update an existing email template.',
216
274
  inputSchema: {
217
275
  id: z.string().uuid(),
@@ -222,6 +280,7 @@ export function registerTemplateTools(server) {
222
280
  category: z.string().optional(),
223
281
  description: z.string().optional(),
224
282
  tags: z.array(z.string()).optional(),
283
+ brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this template.'),
225
284
  },
226
285
  }, async ({ id, ...updates }) => {
227
286
  const projectId = getProjectId();
@@ -256,9 +315,26 @@ export function registerTemplateTools(server) {
256
315
  .single();
257
316
  if (error)
258
317
  return j({ error: error.message });
318
+ await recordBrandAssetUsage({
319
+ client: db(),
320
+ projectId,
321
+ source: 'campaign_template',
322
+ resourceId: data.id,
323
+ html: updates.body_html,
324
+ assetIds: updates.brand_asset_ids,
325
+ }).catch((err) => {
326
+ console.warn('Failed to record brand asset usage for MCP template update:', err);
327
+ });
259
328
  return j({ template: data, ok: true });
260
329
  });
261
330
  server.registerTool('delete_template', {
331
+ title: 'Delete Template',
332
+ annotations: {
333
+ readOnlyHint: false,
334
+ destructiveHint: true,
335
+ idempotentHint: false,
336
+ openWorldHint: false,
337
+ },
262
338
  description: 'Archive an email template. System templates cannot be deleted.',
263
339
  inputSchema: { id: z.string().uuid() },
264
340
  }, async ({ id }) => {