@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.
- package/build/audit.d.ts +1 -0
- package/build/audit.js +9 -3
- package/build/auth.d.ts +10 -0
- package/build/auth.js +82 -9
- package/build/db.d.ts +12 -0
- package/build/db.js +37 -2
- package/build/index.js +38 -12
- package/build/lib/action-approvals.d.ts +28 -1
- package/build/lib/action-approvals.js +89 -2
- package/build/lib/brand-asset-persist.d.ts +35 -0
- package/build/lib/brand-asset-persist.js +161 -0
- package/build/lib/brand-asset-usage.d.ts +13 -0
- package/build/lib/brand-asset-usage.js +46 -0
- package/build/lib/email-provider.d.ts +19 -0
- package/build/lib/email-provider.js +281 -0
- package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
- package/build/lib/ssrf-pinned-fetch.js +101 -0
- package/build/lib/survey-link-resolver.d.ts +6 -0
- package/build/lib/survey-link-resolver.js +23 -0
- package/build/lib/svg-sanitize.d.ts +2 -0
- package/build/lib/svg-sanitize.js +110 -0
- package/build/lib/webhook-events.d.ts +1 -1
- package/build/scoped-token.d.ts +11 -0
- package/build/scoped-token.js +59 -0
- package/build/server.js +30 -9
- package/build/tools/ab-testing.js +28 -0
- package/build/tools/advisor.js +52 -2
- package/build/tools/ai-work.d.ts +2 -0
- package/build/tools/ai-work.js +460 -0
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +203 -0
- package/build/tools/approvals.js +15 -1
- package/build/tools/automations.js +288 -65
- package/build/tools/brand-kit.d.ts +2 -0
- package/build/tools/brand-kit.js +277 -0
- package/build/tools/campaigns.js +900 -69
- package/build/tools/compound.js +28 -0
- package/build/tools/contacts.js +338 -17
- package/build/tools/content.js +266 -1
- package/build/tools/data-proposals.js +21 -0
- package/build/tools/data.js +121 -5
- package/build/tools/delivery-ops.js +84 -0
- package/build/tools/drafts.js +64 -1
- package/build/tools/forms.js +42 -9
- package/build/tools/gdpr.js +22 -3
- package/build/tools/newsletters.d.ts +2 -0
- package/build/tools/newsletters.js +583 -0
- package/build/tools/org.js +297 -3
- package/build/tools/segments.js +45 -3
- package/build/tools/short-links.d.ts +2 -0
- package/build/tools/short-links.js +157 -0
- package/build/tools/sites.js +35 -0
- package/build/tools/sms.js +49 -0
- package/build/tools/social-posts.js +81 -5
- package/build/tools/surveys.d.ts +2 -0
- package/build/tools/surveys.js +222 -0
- package/build/tools/templates.js +78 -2
- package/build/tools/warmup.js +28 -0
- package/build/tools/webhooks.js +29 -0
- package/package.json +10 -4
package/build/tools/sms.js
CHANGED
|
@@ -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 (
|
|
260
|
-
tracked = trackedUrl({ destinationUrl:
|
|
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:
|
|
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,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
|
+
}
|
package/build/tools/templates.js
CHANGED
|
@@ -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 }) => {
|