@sendinel/mcp-server 1.0.4 → 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 +9 -3
package/build/tools/org.js
CHANGED
|
@@ -1,15 +1,123 @@
|
|
|
1
1
|
import { randomBytes } from 'crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { logAudit } from '../audit.js';
|
|
3
4
|
import { db } from '../db.js';
|
|
4
5
|
import { getProjectId } from '../project.js';
|
|
5
6
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
7
|
+
const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
|
|
8
|
+
async function callProviderSwitchApi(path, init) {
|
|
9
|
+
const apiKey = process.env.SENDINEL_API_KEY;
|
|
10
|
+
if (!apiKey)
|
|
11
|
+
return { ok: false, error: 'SENDINEL_API_KEY is required to drive provider-switch tools from MCP' };
|
|
12
|
+
const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
13
|
+
const res = await fetch(`${base}${path}`, {
|
|
14
|
+
...init,
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: `Bearer ${apiKey}`,
|
|
17
|
+
'X-Sendinel-Project-Id': getProjectId(),
|
|
18
|
+
...(init?.headers ?? {}),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const body = await res.text();
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
try {
|
|
24
|
+
return { ok: false, error: JSON.parse(body) };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { ok: false, error: body || `HTTP ${res.status}` };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return { ok: true, data: JSON.parse(body) };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { ok: true, data: body };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
6
37
|
const PLAN_LIMITS = {
|
|
7
|
-
free: { contacts:
|
|
8
|
-
byod: { contacts:
|
|
9
|
-
managed: { contacts:
|
|
38
|
+
free: { contacts: 250, monthly_sends: 500, sites: 1, ai_drafts: 5, log_retention_days: 30, white_label: false },
|
|
39
|
+
byod: { contacts: -1, monthly_sends: -1, sites: 10, ai_drafts: -1, log_retention_days: 365, white_label: false },
|
|
40
|
+
managed: { contacts: 50000, monthly_sends: 50000, sites: 10, ai_drafts: -1, log_retention_days: 730, white_label: false },
|
|
41
|
+
'agency-plus': { contacts: -1, monthly_sends: 500000, sites: -1, ai_drafts: -1, log_retention_days: -1, white_label: true },
|
|
42
|
+
enterprise: { contacts: -1, monthly_sends: 250000, sites: 50, ai_drafts: -1, log_retention_days: -1, white_label: true },
|
|
10
43
|
};
|
|
44
|
+
const whiteLabelSettingsSchema = z.object({
|
|
45
|
+
logo_url: z.string().url().nullable().optional(),
|
|
46
|
+
favicon_url: z.string().url().nullable().optional(),
|
|
47
|
+
brand_colors: z.record(z.string(), z.unknown()).optional(),
|
|
48
|
+
product_name: z.string().trim().min(1).max(80).nullable().optional(),
|
|
49
|
+
remove_sendinel_branding: z.boolean().optional(),
|
|
50
|
+
custom_from_identity: z.string().trim().min(1).max(255).nullable().optional(),
|
|
51
|
+
sign_in_branding: z.record(z.string(), z.unknown()).optional(),
|
|
52
|
+
support_url: z.string().url().nullable().optional(),
|
|
53
|
+
});
|
|
54
|
+
function normalizeHost(host) {
|
|
55
|
+
if (!host)
|
|
56
|
+
return null;
|
|
57
|
+
const value = host.split(':')[0]?.trim().toLowerCase().replace(/\.$/, '');
|
|
58
|
+
if (!value)
|
|
59
|
+
return null;
|
|
60
|
+
if (!/^[a-z0-9.-]+$/.test(value) || !value.includes('.'))
|
|
61
|
+
return null;
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function isWhiteLabelPlan(plan) {
|
|
65
|
+
const limits = PLAN_LIMITS[plan || 'free'] ?? PLAN_LIMITS.free;
|
|
66
|
+
return limits.white_label === true;
|
|
67
|
+
}
|
|
68
|
+
async function getCurrentProjectOrg() {
|
|
69
|
+
const { data, error } = await db()
|
|
70
|
+
.from('projects')
|
|
71
|
+
.select('id, org_id, plan')
|
|
72
|
+
.eq('id', getProjectId())
|
|
73
|
+
.maybeSingle();
|
|
74
|
+
if (error)
|
|
75
|
+
return { ok: false, error: error.message };
|
|
76
|
+
if (!data?.org_id)
|
|
77
|
+
return { ok: false, error: 'Project organization not found', code: 'NOT_FOUND' };
|
|
78
|
+
return { ok: true, project: data };
|
|
79
|
+
}
|
|
80
|
+
function formatWhiteLabelSettings(row) {
|
|
81
|
+
return row ? {
|
|
82
|
+
org_id: row.org_id,
|
|
83
|
+
custom_domain: row.custom_domain ?? null,
|
|
84
|
+
custom_domain_verified: row.custom_domain_verified === true,
|
|
85
|
+
custom_domain_verified_at: row.custom_domain_verified_at ?? null,
|
|
86
|
+
logo_url: row.logo_url ?? null,
|
|
87
|
+
favicon_url: row.favicon_url ?? null,
|
|
88
|
+
brand_colors: row.brand_colors && typeof row.brand_colors === 'object' ? row.brand_colors : {},
|
|
89
|
+
product_name: row.product_name ?? null,
|
|
90
|
+
remove_sendinel_branding: row.remove_sendinel_branding === true,
|
|
91
|
+
custom_from_identity: row.custom_from_identity ?? null,
|
|
92
|
+
sign_in_branding: row.sign_in_branding && typeof row.sign_in_branding === 'object' ? row.sign_in_branding : {},
|
|
93
|
+
support_url: row.support_url ?? null,
|
|
94
|
+
source: row.source ?? null,
|
|
95
|
+
created_at: row.created_at ?? null,
|
|
96
|
+
updated_at: row.updated_at ?? null,
|
|
97
|
+
} : null;
|
|
98
|
+
}
|
|
99
|
+
async function upsertWhiteLabelSettings(orgId, payload) {
|
|
100
|
+
const { data, error } = await db()
|
|
101
|
+
.from('white_label_settings')
|
|
102
|
+
.upsert({ org_id: orgId, ...payload }, { onConflict: 'org_id' })
|
|
103
|
+
.select('*')
|
|
104
|
+
.single();
|
|
105
|
+
if (error) {
|
|
106
|
+
if (error.code === '23505')
|
|
107
|
+
return { ok: false, error: 'Custom domain is already claimed', code: 'CONFLICT' };
|
|
108
|
+
return { ok: false, error: error.message };
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, settings: formatWhiteLabelSettings(data) };
|
|
111
|
+
}
|
|
11
112
|
export function registerOrgTools(server) {
|
|
12
113
|
server.registerTool('list_team_members', {
|
|
114
|
+
title: 'List Team Members',
|
|
115
|
+
annotations: {
|
|
116
|
+
readOnlyHint: true,
|
|
117
|
+
destructiveHint: false,
|
|
118
|
+
idempotentHint: true,
|
|
119
|
+
openWorldHint: false,
|
|
120
|
+
},
|
|
13
121
|
description: 'List team members for the project organization.',
|
|
14
122
|
inputSchema: { org_id: z.string().uuid().optional() },
|
|
15
123
|
}, async ({ org_id }) => {
|
|
@@ -26,6 +134,13 @@ export function registerOrgTools(server) {
|
|
|
26
134
|
return j({ members: (data ?? []).map((member) => ({ user_id: member.user_id, email: member.email ?? null, role: member.role, joined_at: member.created_at })) });
|
|
27
135
|
});
|
|
28
136
|
server.registerTool('invite_team_member', {
|
|
137
|
+
title: 'Invite Team Member',
|
|
138
|
+
annotations: {
|
|
139
|
+
readOnlyHint: false,
|
|
140
|
+
destructiveHint: false,
|
|
141
|
+
idempotentHint: false,
|
|
142
|
+
openWorldHint: false,
|
|
143
|
+
},
|
|
29
144
|
description: 'Create a team invitation for the current project organization.',
|
|
30
145
|
inputSchema: { email: z.string().email(), role: z.enum(['owner', 'admin', 'member']).default('member') },
|
|
31
146
|
}, async ({ email, role }) => {
|
|
@@ -43,6 +158,13 @@ export function registerOrgTools(server) {
|
|
|
43
158
|
return j({ invitation_id: data.id, expires_at: data.expires_at });
|
|
44
159
|
});
|
|
45
160
|
server.registerTool('get_plan_usage', {
|
|
161
|
+
title: 'Get Plan Usage',
|
|
162
|
+
annotations: {
|
|
163
|
+
readOnlyHint: true,
|
|
164
|
+
destructiveHint: false,
|
|
165
|
+
idempotentHint: true,
|
|
166
|
+
openWorldHint: false,
|
|
167
|
+
},
|
|
46
168
|
description: 'Get current plan usage for the project.',
|
|
47
169
|
inputSchema: {},
|
|
48
170
|
}, async () => {
|
|
@@ -65,7 +187,179 @@ export function registerOrgTools(server) {
|
|
|
65
187
|
});
|
|
66
188
|
});
|
|
67
189
|
server.registerTool('get_plan_limits', {
|
|
190
|
+
title: 'Get Plan Limits',
|
|
191
|
+
annotations: {
|
|
192
|
+
readOnlyHint: true,
|
|
193
|
+
destructiveHint: false,
|
|
194
|
+
idempotentHint: true,
|
|
195
|
+
openWorldHint: false,
|
|
196
|
+
},
|
|
68
197
|
description: 'Return plan limits for all plan tiers.',
|
|
69
198
|
inputSchema: {},
|
|
70
199
|
}, async () => j(PLAN_LIMITS));
|
|
200
|
+
server.registerTool('get_white_label_settings', {
|
|
201
|
+
title: 'Get White Label Settings',
|
|
202
|
+
annotations: {
|
|
203
|
+
readOnlyHint: true,
|
|
204
|
+
destructiveHint: false,
|
|
205
|
+
idempotentHint: true,
|
|
206
|
+
openWorldHint: false,
|
|
207
|
+
},
|
|
208
|
+
description: 'Read the current project organization white-label branding settings. Returns null settings when no row exists.',
|
|
209
|
+
inputSchema: {},
|
|
210
|
+
}, async () => {
|
|
211
|
+
const context = await getCurrentProjectOrg();
|
|
212
|
+
if (!context.ok)
|
|
213
|
+
return j({ error: context.error, code: context.code });
|
|
214
|
+
const { data, error } = await db()
|
|
215
|
+
.from('white_label_settings')
|
|
216
|
+
.select('*')
|
|
217
|
+
.eq('org_id', context.project.org_id)
|
|
218
|
+
.maybeSingle();
|
|
219
|
+
if (error)
|
|
220
|
+
return j({ error: error.message });
|
|
221
|
+
return j({
|
|
222
|
+
org_id: context.project.org_id,
|
|
223
|
+
plan: context.project.plan ?? 'free',
|
|
224
|
+
white_label_available: isWhiteLabelPlan(context.project.plan),
|
|
225
|
+
settings: formatWhiteLabelSettings(data),
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
server.registerTool('set_white_label_settings', {
|
|
229
|
+
title: 'Set White Label Settings',
|
|
230
|
+
annotations: {
|
|
231
|
+
readOnlyHint: false,
|
|
232
|
+
destructiveHint: false,
|
|
233
|
+
idempotentHint: true,
|
|
234
|
+
openWorldHint: false,
|
|
235
|
+
},
|
|
236
|
+
description: 'Update org-level white-label branding for the current project. Requires an Agency Plus or Enterprise plan.',
|
|
237
|
+
inputSchema: whiteLabelSettingsSchema.shape,
|
|
238
|
+
}, async (input) => {
|
|
239
|
+
const context = await getCurrentProjectOrg();
|
|
240
|
+
if (!context.ok)
|
|
241
|
+
return j({ error: context.error, code: context.code });
|
|
242
|
+
if (!isWhiteLabelPlan(context.project.plan)) {
|
|
243
|
+
return j({ error: 'White-label requires Agency Plus or Enterprise', code: 'PLAN_REQUIRED' });
|
|
244
|
+
}
|
|
245
|
+
const parsed = whiteLabelSettingsSchema.safeParse(input);
|
|
246
|
+
if (!parsed.success)
|
|
247
|
+
return j({ error: parsed.error.flatten(), code: 'INVALID_FIELD' });
|
|
248
|
+
const result = await upsertWhiteLabelSettings(context.project.org_id, {
|
|
249
|
+
...parsed.data,
|
|
250
|
+
source: 'mcp',
|
|
251
|
+
});
|
|
252
|
+
if (!result.ok)
|
|
253
|
+
return j({ error: result.error, code: result.code });
|
|
254
|
+
void logAudit({
|
|
255
|
+
action: 'white_label_settings.updated',
|
|
256
|
+
resourceType: 'white_label_settings',
|
|
257
|
+
resourceId: context.project.org_id,
|
|
258
|
+
details: {
|
|
259
|
+
custom_domain: result.settings?.custom_domain ?? null,
|
|
260
|
+
remove_sendinel_branding: result.settings?.remove_sendinel_branding ?? false,
|
|
261
|
+
source: 'mcp',
|
|
262
|
+
},
|
|
263
|
+
}).catch(() => { });
|
|
264
|
+
return j({ settings: result.settings });
|
|
265
|
+
});
|
|
266
|
+
server.registerTool('add_white_label_domain', {
|
|
267
|
+
title: 'Add White Label Domain',
|
|
268
|
+
annotations: {
|
|
269
|
+
readOnlyHint: false,
|
|
270
|
+
destructiveHint: false,
|
|
271
|
+
idempotentHint: false,
|
|
272
|
+
openWorldHint: false,
|
|
273
|
+
},
|
|
274
|
+
description: 'Claim a custom dashboard hostname for white-label setup. Stores it as unverified; DNS/certificate verification must complete before host routing can use it.',
|
|
275
|
+
inputSchema: { custom_domain: z.string().trim().min(1).max(255) },
|
|
276
|
+
}, async ({ custom_domain }) => {
|
|
277
|
+
const context = await getCurrentProjectOrg();
|
|
278
|
+
if (!context.ok)
|
|
279
|
+
return j({ error: context.error, code: context.code });
|
|
280
|
+
if (!isWhiteLabelPlan(context.project.plan)) {
|
|
281
|
+
return j({ error: 'White-label requires Agency Plus or Enterprise', code: 'PLAN_REQUIRED' });
|
|
282
|
+
}
|
|
283
|
+
const normalized = normalizeHost(custom_domain);
|
|
284
|
+
if (!normalized)
|
|
285
|
+
return j({ error: 'custom_domain must be a valid hostname', code: 'INVALID_FIELD' });
|
|
286
|
+
const result = await upsertWhiteLabelSettings(context.project.org_id, {
|
|
287
|
+
custom_domain: normalized,
|
|
288
|
+
custom_domain_verified: false,
|
|
289
|
+
custom_domain_verified_at: null,
|
|
290
|
+
source: 'mcp',
|
|
291
|
+
});
|
|
292
|
+
if (!result.ok)
|
|
293
|
+
return j({ error: result.error, code: result.code });
|
|
294
|
+
void logAudit({
|
|
295
|
+
action: 'white_label_domain.claimed',
|
|
296
|
+
resourceType: 'white_label_settings',
|
|
297
|
+
resourceId: context.project.org_id,
|
|
298
|
+
details: { custom_domain: normalized, verified: false, source: 'mcp' },
|
|
299
|
+
}).catch(() => { });
|
|
300
|
+
return j({
|
|
301
|
+
settings: result.settings,
|
|
302
|
+
verification_status: 'pending',
|
|
303
|
+
note: 'The domain is stored but will not resolve to this org until DNS/certificate verification marks custom_domain_verified true.',
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
server.registerTool('start_provider_switch', {
|
|
307
|
+
title: 'Start Provider Switch',
|
|
308
|
+
annotations: {
|
|
309
|
+
readOnlyHint: false,
|
|
310
|
+
destructiveHint: false,
|
|
311
|
+
idempotentHint: false,
|
|
312
|
+
openWorldHint: true,
|
|
313
|
+
},
|
|
314
|
+
description: 'Start moving a sending domain between supported providers using the existing provider-switch flow.',
|
|
315
|
+
inputSchema: {
|
|
316
|
+
site_id: z.string().uuid(),
|
|
317
|
+
to_provider: z.enum(PROVIDER_SWITCH_V1_PROVIDERS),
|
|
318
|
+
},
|
|
319
|
+
}, async ({ site_id, to_provider }) => {
|
|
320
|
+
const result = await callProviderSwitchApi('/api/dashboard/provider-switch/start', {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: { 'Content-Type': 'application/json' },
|
|
323
|
+
body: JSON.stringify({ site_id, to_provider }),
|
|
324
|
+
});
|
|
325
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
326
|
+
});
|
|
327
|
+
server.registerTool('get_provider_switch_status', {
|
|
328
|
+
title: 'Get Provider Switch Status',
|
|
329
|
+
annotations: {
|
|
330
|
+
readOnlyHint: true,
|
|
331
|
+
destructiveHint: false,
|
|
332
|
+
idempotentHint: true,
|
|
333
|
+
openWorldHint: true,
|
|
334
|
+
},
|
|
335
|
+
description: 'Poll and return the current status of a provider switch.',
|
|
336
|
+
inputSchema: {
|
|
337
|
+
id: z.string().uuid(),
|
|
338
|
+
},
|
|
339
|
+
}, async ({ id }) => {
|
|
340
|
+
const result = await callProviderSwitchApi(`/api/dashboard/provider-switch/status?id=${encodeURIComponent(id)}`, {
|
|
341
|
+
method: 'GET',
|
|
342
|
+
});
|
|
343
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
344
|
+
});
|
|
345
|
+
server.registerTool('rollback_provider_switch', {
|
|
346
|
+
title: 'Rollback Provider Switch',
|
|
347
|
+
annotations: {
|
|
348
|
+
readOnlyHint: false,
|
|
349
|
+
destructiveHint: true,
|
|
350
|
+
idempotentHint: false,
|
|
351
|
+
openWorldHint: true,
|
|
352
|
+
},
|
|
353
|
+
description: 'Roll back an in-progress or failed provider switch to the prior provider/domain state.',
|
|
354
|
+
inputSchema: {
|
|
355
|
+
id: z.string().uuid(),
|
|
356
|
+
},
|
|
357
|
+
}, async ({ id }) => {
|
|
358
|
+
const result = await callProviderSwitchApi('/api/dashboard/provider-switch/rollback', {
|
|
359
|
+
method: 'POST',
|
|
360
|
+
headers: { 'Content-Type': 'application/json' },
|
|
361
|
+
body: JSON.stringify({ id }),
|
|
362
|
+
});
|
|
363
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
364
|
+
});
|
|
71
365
|
}
|
package/build/tools/segments.js
CHANGED
|
@@ -212,11 +212,18 @@ const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
|
212
212
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
213
213
|
export function registerSegmentTools(server) {
|
|
214
214
|
server.registerTool('create_segment', {
|
|
215
|
+
title: 'Create Segment',
|
|
216
|
+
annotations: {
|
|
217
|
+
readOnlyHint: false,
|
|
218
|
+
destructiveHint: false,
|
|
219
|
+
idempotentHint: false,
|
|
220
|
+
openWorldHint: false,
|
|
221
|
+
},
|
|
215
222
|
description: 'Create a saved segment with filter rules. Segments evaluate dynamically at send time.',
|
|
216
223
|
inputSchema: {
|
|
217
224
|
name: z.string().min(1).describe('Segment name (unique per project)'),
|
|
218
225
|
description: z.string().optional(),
|
|
219
|
-
rules: z.record(z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, tags, custom_properties.<key>, payment.<key>.'),
|
|
226
|
+
rules: z.record(z.string(), z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, tags, custom_properties.<key>, payment.<key>.'),
|
|
220
227
|
},
|
|
221
228
|
}, async ({ name, description, rules }) => {
|
|
222
229
|
const { data, error } = await db()
|
|
@@ -230,6 +237,13 @@ export function registerSegmentTools(server) {
|
|
|
230
237
|
return j({ ...data, estimated_contacts: ids.length });
|
|
231
238
|
});
|
|
232
239
|
server.registerTool('list_segments', {
|
|
240
|
+
title: 'List Segments',
|
|
241
|
+
annotations: {
|
|
242
|
+
readOnlyHint: true,
|
|
243
|
+
destructiveHint: false,
|
|
244
|
+
idempotentHint: true,
|
|
245
|
+
openWorldHint: false,
|
|
246
|
+
},
|
|
233
247
|
description: 'List all saved segments with their contact counts',
|
|
234
248
|
inputSchema: {},
|
|
235
249
|
}, async () => {
|
|
@@ -248,12 +262,19 @@ export function registerSegmentTools(server) {
|
|
|
248
262
|
return j(segments);
|
|
249
263
|
});
|
|
250
264
|
server.registerTool('update_segment', {
|
|
265
|
+
title: 'Update Segment',
|
|
266
|
+
annotations: {
|
|
267
|
+
readOnlyHint: false,
|
|
268
|
+
destructiveHint: false,
|
|
269
|
+
idempotentHint: true,
|
|
270
|
+
openWorldHint: false,
|
|
271
|
+
},
|
|
251
272
|
description: 'Update a segment name, description, or rules',
|
|
252
273
|
inputSchema: {
|
|
253
274
|
segment_id: z.string().uuid(),
|
|
254
275
|
name: z.string().optional(),
|
|
255
276
|
description: z.string().optional(),
|
|
256
|
-
rules: z.record(z.unknown()).optional(),
|
|
277
|
+
rules: z.record(z.string(), z.unknown()).optional(),
|
|
257
278
|
},
|
|
258
279
|
}, async ({ segment_id, name, description, rules }) => {
|
|
259
280
|
const updates = { updated_at: new Date().toISOString() };
|
|
@@ -275,6 +296,13 @@ export function registerSegmentTools(server) {
|
|
|
275
296
|
return j(data);
|
|
276
297
|
});
|
|
277
298
|
server.registerTool('delete_segment', {
|
|
299
|
+
title: 'Delete Segment',
|
|
300
|
+
annotations: {
|
|
301
|
+
readOnlyHint: false,
|
|
302
|
+
destructiveHint: true,
|
|
303
|
+
idempotentHint: false,
|
|
304
|
+
openWorldHint: false,
|
|
305
|
+
},
|
|
278
306
|
description: 'Delete a saved segment. Campaigns using this segment will lose targeting.',
|
|
279
307
|
inputSchema: {
|
|
280
308
|
segment_id: z.string().uuid(),
|
|
@@ -290,10 +318,17 @@ export function registerSegmentTools(server) {
|
|
|
290
318
|
return j({ deleted: true, segment_id });
|
|
291
319
|
});
|
|
292
320
|
server.registerTool('preview_segment', {
|
|
321
|
+
title: 'Preview Segment',
|
|
322
|
+
annotations: {
|
|
323
|
+
readOnlyHint: true,
|
|
324
|
+
destructiveHint: false,
|
|
325
|
+
idempotentHint: true,
|
|
326
|
+
openWorldHint: false,
|
|
327
|
+
},
|
|
293
328
|
description: 'Preview which contacts match a saved segment or inline rules. Returns count and sample.',
|
|
294
329
|
inputSchema: {
|
|
295
330
|
segment_id: z.string().uuid().optional(),
|
|
296
|
-
rules: z.record(z.unknown()).optional(),
|
|
331
|
+
rules: z.record(z.string(), z.unknown()).optional(),
|
|
297
332
|
},
|
|
298
333
|
}, async ({ segment_id, rules }) => {
|
|
299
334
|
let segRules;
|
|
@@ -327,6 +362,13 @@ export function registerSegmentTools(server) {
|
|
|
327
362
|
return j({ total_contacts: ids.length, sample: sampleContacts });
|
|
328
363
|
});
|
|
329
364
|
server.registerTool('create_segment_nl', {
|
|
365
|
+
title: 'Create Segment NL',
|
|
366
|
+
annotations: {
|
|
367
|
+
readOnlyHint: false,
|
|
368
|
+
destructiveHint: false,
|
|
369
|
+
idempotentHint: false,
|
|
370
|
+
openWorldHint: true,
|
|
371
|
+
},
|
|
330
372
|
description: 'Create a segment from a natural language description. Parses description into filter rules using AI, previews contacts, creates on confirmation.',
|
|
331
373
|
inputSchema: {
|
|
332
374
|
name: z.string().min(1),
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { db } from '../db.js';
|
|
4
|
+
import { getProjectId } from '../project.js';
|
|
5
|
+
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
6
|
+
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
7
|
+
const VANITY_SLUG_RE = /^[a-zA-Z0-9-]{3,40}$/;
|
|
8
|
+
const PAID_PLANS = new Set(['byod', 'managed', 'agency-plus', 'enterprise']);
|
|
9
|
+
function appBaseUrl() {
|
|
10
|
+
return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
11
|
+
}
|
|
12
|
+
function assertHttpUrl(raw) {
|
|
13
|
+
const url = new URL(raw);
|
|
14
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
15
|
+
throw new Error('Only http and https URLs are supported.');
|
|
16
|
+
}
|
|
17
|
+
return url.toString();
|
|
18
|
+
}
|
|
19
|
+
function normalizeColor(input, fallback) {
|
|
20
|
+
const value = input?.trim() || fallback;
|
|
21
|
+
if (!/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value))
|
|
22
|
+
return fallback;
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function registerShortLinkTools(server) {
|
|
26
|
+
server.registerTool('create_short_link', {
|
|
27
|
+
title: 'Create Short Link',
|
|
28
|
+
annotations: {
|
|
29
|
+
readOnlyHint: false,
|
|
30
|
+
destructiveHint: false,
|
|
31
|
+
idempotentHint: false,
|
|
32
|
+
openWorldHint: false,
|
|
33
|
+
},
|
|
34
|
+
description: 'Create a project-scoped short link that redirects to a destination URL with click tracking.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
destination_url: z.string().url(),
|
|
37
|
+
slug: z.string().optional().describe('Custom slug; paid plans only. Auto-generated when omitted.'),
|
|
38
|
+
campaign_id: z.string().uuid().optional(),
|
|
39
|
+
},
|
|
40
|
+
}, async ({ destination_url, slug, campaign_id }) => {
|
|
41
|
+
const projectId = getProjectId();
|
|
42
|
+
const { data: project } = await db().from('projects').select('org_id, plan').eq('id', projectId).maybeSingle();
|
|
43
|
+
if (!project?.org_id)
|
|
44
|
+
return j({ error: 'Project not found', code: 'NOT_FOUND' });
|
|
45
|
+
let destination;
|
|
46
|
+
try {
|
|
47
|
+
destination = assertHttpUrl(destination_url);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
return j({ error: err instanceof Error ? err.message : 'Invalid destination URL', code: 'INVALID_URL' });
|
|
51
|
+
}
|
|
52
|
+
const requestedSlug = slug?.trim() || null;
|
|
53
|
+
if (requestedSlug) {
|
|
54
|
+
if (!VANITY_SLUG_RE.test(requestedSlug)) {
|
|
55
|
+
return j({ error: 'Custom slug must be 3-40 characters and contain only letters, numbers, or hyphens.', code: 'INVALID_SLUG' });
|
|
56
|
+
}
|
|
57
|
+
if (!PAID_PLANS.has(project.plan ?? 'free')) {
|
|
58
|
+
return j({ error: 'Custom slugs require vanity_slug_access on a paid plan.', code: 'VANITY_SLUG_NOT_ALLOWED' });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const finalSlug = requestedSlug ?? (await db().rpc('next_short_link_slug')).data;
|
|
62
|
+
if (!finalSlug)
|
|
63
|
+
return j({ error: 'Unable to allocate short-link slug', code: 'SLUG_UNAVAILABLE' });
|
|
64
|
+
const { data, error } = await db()
|
|
65
|
+
.from('short_links')
|
|
66
|
+
.insert({
|
|
67
|
+
slug: finalSlug,
|
|
68
|
+
org_id: project.org_id,
|
|
69
|
+
project_id: projectId,
|
|
70
|
+
destination_url: destination,
|
|
71
|
+
is_vanity: Boolean(requestedSlug),
|
|
72
|
+
source_type: campaign_id ? 'campaign' : 'generic',
|
|
73
|
+
source_id: campaign_id ?? null,
|
|
74
|
+
})
|
|
75
|
+
.select()
|
|
76
|
+
.single();
|
|
77
|
+
if (error)
|
|
78
|
+
return j({ error: error.message });
|
|
79
|
+
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
80
|
+
});
|
|
81
|
+
server.registerTool('get_short_link_stats', {
|
|
82
|
+
title: 'Get Short Link Stats',
|
|
83
|
+
annotations: {
|
|
84
|
+
readOnlyHint: true,
|
|
85
|
+
destructiveHint: false,
|
|
86
|
+
idempotentHint: true,
|
|
87
|
+
openWorldHint: false,
|
|
88
|
+
},
|
|
89
|
+
description: 'Get project-scoped click stats for a short link.',
|
|
90
|
+
inputSchema: { short_link_id: z.string().uuid() },
|
|
91
|
+
}, async ({ short_link_id }) => {
|
|
92
|
+
const projectId = getProjectId();
|
|
93
|
+
const { data: link, error: linkError } = await db()
|
|
94
|
+
.from('short_links')
|
|
95
|
+
.select('id, slug, destination_url, click_count, last_clicked_at, created_at')
|
|
96
|
+
.eq('id', short_link_id)
|
|
97
|
+
.eq('project_id', projectId)
|
|
98
|
+
.maybeSingle();
|
|
99
|
+
if (linkError)
|
|
100
|
+
return j({ error: linkError.message });
|
|
101
|
+
if (!link)
|
|
102
|
+
return j({ error: 'Short link not found', code: 'NOT_FOUND' });
|
|
103
|
+
const { data: clicks, error: clickError } = await db()
|
|
104
|
+
.from('short_link_clicks')
|
|
105
|
+
.select('clicked_at, ip_hash, referer')
|
|
106
|
+
.eq('short_link_id', short_link_id)
|
|
107
|
+
.order('clicked_at', { ascending: false })
|
|
108
|
+
.limit(500);
|
|
109
|
+
if (clickError)
|
|
110
|
+
return j({ error: clickError.message });
|
|
111
|
+
const uniqueClicks = new Set((clicks ?? []).map((click) => click.ip_hash).filter(Boolean)).size;
|
|
112
|
+
return j({
|
|
113
|
+
short_link: link,
|
|
114
|
+
total_clicks: link.click_count ?? clicks?.length ?? 0,
|
|
115
|
+
unique_clicks: uniqueClicks,
|
|
116
|
+
recent_clicks: clicks ?? [],
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
server.registerTool('generate_qr_code', {
|
|
120
|
+
title: 'Generate QR Code',
|
|
121
|
+
annotations: {
|
|
122
|
+
readOnlyHint: true,
|
|
123
|
+
destructiveHint: false,
|
|
124
|
+
idempotentHint: true,
|
|
125
|
+
openWorldHint: false,
|
|
126
|
+
},
|
|
127
|
+
description: 'Generate a QR code image for a URL. Returns a PNG data URI in JSON text content.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
url: z.string().url(),
|
|
130
|
+
style: z.object({
|
|
131
|
+
foreground_color: z.string().optional(),
|
|
132
|
+
background_color: z.string().optional(),
|
|
133
|
+
margin: z.number().int().min(0).max(12).optional(),
|
|
134
|
+
width: z.number().int().min(128).max(2048).optional(),
|
|
135
|
+
}).optional(),
|
|
136
|
+
},
|
|
137
|
+
}, async ({ url, style }) => {
|
|
138
|
+
let normalizedUrl;
|
|
139
|
+
try {
|
|
140
|
+
normalizedUrl = assertHttpUrl(url);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
return j({ error: err instanceof Error ? err.message : 'Invalid QR URL', code: 'INVALID_URL' });
|
|
144
|
+
}
|
|
145
|
+
const dataUri = await QRCode.toDataURL(normalizedUrl, {
|
|
146
|
+
type: 'image/png',
|
|
147
|
+
errorCorrectionLevel: 'H',
|
|
148
|
+
margin: style?.margin ?? 4,
|
|
149
|
+
width: style?.width ?? 512,
|
|
150
|
+
color: {
|
|
151
|
+
dark: normalizeColor(style?.foreground_color, '#000000'),
|
|
152
|
+
light: normalizeColor(style?.background_color, '#ffffff'),
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
return j({ data_uri: dataUri, url: normalizedUrl, content_type: 'image/png' });
|
|
156
|
+
});
|
|
157
|
+
}
|
package/build/tools/sites.js
CHANGED
|
@@ -103,6 +103,13 @@ async function createSender({ name, slug, domain_id, local_part, sending_domain,
|
|
|
103
103
|
}
|
|
104
104
|
export function registerSiteTools(server) {
|
|
105
105
|
server.registerTool('get_brand_voice', {
|
|
106
|
+
title: 'Get Brand Voice',
|
|
107
|
+
annotations: {
|
|
108
|
+
readOnlyHint: true,
|
|
109
|
+
destructiveHint: false,
|
|
110
|
+
idempotentHint: true,
|
|
111
|
+
openWorldHint: false,
|
|
112
|
+
},
|
|
106
113
|
description: 'Read the brand voice configuration for a sender/site.',
|
|
107
114
|
inputSchema: {
|
|
108
115
|
site_id: z.string().uuid(),
|
|
@@ -121,6 +128,13 @@ export function registerSiteTools(server) {
|
|
|
121
128
|
return j(data);
|
|
122
129
|
});
|
|
123
130
|
server.registerTool('update_brand_voice', {
|
|
131
|
+
title: 'Update Brand Voice',
|
|
132
|
+
annotations: {
|
|
133
|
+
readOnlyHint: false,
|
|
134
|
+
destructiveHint: false,
|
|
135
|
+
idempotentHint: true,
|
|
136
|
+
openWorldHint: false,
|
|
137
|
+
},
|
|
124
138
|
description: 'Replace the brand voice configuration for a sender/site. Stores JSON in sites.brand_voice. Use the canonical Brand Brain keys so the dashboard and email generation can read the values: mission (string), audience (string), differentiators (string), tone_words (string[], max 8), forbidden_phrases (string[], max 20), default_signoff_name (string), ps_line (string). This REPLACES the whole object and overrides the org default — read the current value first if you only mean to change one field.',
|
|
125
139
|
inputSchema: {
|
|
126
140
|
site_id: z.string().uuid(),
|
|
@@ -139,14 +153,35 @@ export function registerSiteTools(server) {
|
|
|
139
153
|
return j(data);
|
|
140
154
|
});
|
|
141
155
|
server.registerTool('create_site', {
|
|
156
|
+
title: 'Create Site',
|
|
157
|
+
annotations: {
|
|
158
|
+
readOnlyHint: false,
|
|
159
|
+
destructiveHint: false,
|
|
160
|
+
idempotentHint: false,
|
|
161
|
+
openWorldHint: false,
|
|
162
|
+
},
|
|
142
163
|
description: 'Create a new sender. Accepts preferred domain_id + local_part or legacy sending_domain + from_email.',
|
|
143
164
|
inputSchema: createSenderSchema,
|
|
144
165
|
}, createSender);
|
|
145
166
|
server.registerTool('create_sender', {
|
|
167
|
+
title: 'Create Sender',
|
|
168
|
+
annotations: {
|
|
169
|
+
readOnlyHint: false,
|
|
170
|
+
destructiveHint: false,
|
|
171
|
+
idempotentHint: false,
|
|
172
|
+
openWorldHint: false,
|
|
173
|
+
},
|
|
146
174
|
description: 'Create a new sender under an existing verified domain.',
|
|
147
175
|
inputSchema: createSenderSchema,
|
|
148
176
|
}, createSender);
|
|
149
177
|
server.registerTool('update_site', {
|
|
178
|
+
title: 'Update Site',
|
|
179
|
+
annotations: {
|
|
180
|
+
readOnlyHint: false,
|
|
181
|
+
destructiveHint: false,
|
|
182
|
+
idempotentHint: true,
|
|
183
|
+
openWorldHint: false,
|
|
184
|
+
},
|
|
150
185
|
description: 'Update a site name, from address, or other settings',
|
|
151
186
|
inputSchema: {
|
|
152
187
|
site_id: z.string().uuid(),
|