@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
@@ -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: 500, monthly_sends: 1000, sites: 1, ai_drafts: 10, log_retention_days: 30 },
8
- byod: { contacts: 50000, monthly_sends: 250000, sites: 10, ai_drafts: 500, log_retention_days: 180 },
9
- managed: { contacts: 250000, monthly_sends: 1000000, sites: 50, ai_drafts: 2000, log_retention_days: 365 },
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
  }
@@ -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,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerShortLinkTools(server: McpServer): void;
@@ -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
+ }
@@ -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(),