@sendinel/mcp-server 1.0.0

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 (90) hide show
  1. package/README.md +113 -0
  2. package/build/anthropic-model.d.ts +5 -0
  3. package/build/anthropic-model.js +6 -0
  4. package/build/audit.d.ts +21 -0
  5. package/build/audit.js +38 -0
  6. package/build/auth.d.ts +10 -0
  7. package/build/auth.js +57 -0
  8. package/build/byod-client.d.ts +30 -0
  9. package/build/byod-client.js +95 -0
  10. package/build/crypto.d.ts +2 -0
  11. package/build/crypto.js +27 -0
  12. package/build/db.d.ts +38 -0
  13. package/build/db.js +70 -0
  14. package/build/index.d.ts +1 -0
  15. package/build/index.js +97 -0
  16. package/build/lib/action-approvals.d.ts +30 -0
  17. package/build/lib/action-approvals.js +154 -0
  18. package/build/lib/schedule.d.ts +36 -0
  19. package/build/lib/schedule.js +63 -0
  20. package/build/lib/social-engine/archetypes.d.ts +27 -0
  21. package/build/lib/social-engine/archetypes.js +160 -0
  22. package/build/lib/social-engine/index.d.ts +5 -0
  23. package/build/lib/social-engine/index.js +6 -0
  24. package/build/lib/social-engine/parse.d.ts +7 -0
  25. package/build/lib/social-engine/parse.js +83 -0
  26. package/build/lib/social-engine/platform-constraints.d.ts +27 -0
  27. package/build/lib/social-engine/platform-constraints.js +70 -0
  28. package/build/lib/social-engine/prompt-builder.d.ts +13 -0
  29. package/build/lib/social-engine/prompt-builder.js +80 -0
  30. package/build/lib/social-engine/types.d.ts +60 -0
  31. package/build/lib/social-engine/types.js +19 -0
  32. package/build/lib/url-validation.d.ts +3 -0
  33. package/build/lib/url-validation.js +51 -0
  34. package/build/lib/voice.d.ts +32 -0
  35. package/build/lib/voice.js +140 -0
  36. package/build/lib/webhook-events.d.ts +15 -0
  37. package/build/lib/webhook-events.js +120 -0
  38. package/build/plan-limits.d.ts +8 -0
  39. package/build/plan-limits.js +9 -0
  40. package/build/project.d.ts +1 -0
  41. package/build/project.js +9 -0
  42. package/build/server.d.ts +18 -0
  43. package/build/server.js +235 -0
  44. package/build/tools/ab-testing.d.ts +2 -0
  45. package/build/tools/ab-testing.js +204 -0
  46. package/build/tools/advisor.d.ts +23 -0
  47. package/build/tools/advisor.js +762 -0
  48. package/build/tools/analytics.d.ts +33 -0
  49. package/build/tools/analytics.js +1105 -0
  50. package/build/tools/approvals.d.ts +2 -0
  51. package/build/tools/approvals.js +32 -0
  52. package/build/tools/automations.d.ts +2 -0
  53. package/build/tools/automations.js +344 -0
  54. package/build/tools/campaigns.d.ts +2 -0
  55. package/build/tools/campaigns.js +1335 -0
  56. package/build/tools/compound.d.ts +2 -0
  57. package/build/tools/compound.js +312 -0
  58. package/build/tools/contacts.d.ts +2 -0
  59. package/build/tools/contacts.js +1483 -0
  60. package/build/tools/content.d.ts +2 -0
  61. package/build/tools/content.js +68 -0
  62. package/build/tools/data-proposals.d.ts +2 -0
  63. package/build/tools/data-proposals.js +155 -0
  64. package/build/tools/data.d.ts +2 -0
  65. package/build/tools/data.js +707 -0
  66. package/build/tools/delivery-ops.d.ts +2 -0
  67. package/build/tools/delivery-ops.js +387 -0
  68. package/build/tools/drafts.d.ts +2 -0
  69. package/build/tools/drafts.js +204 -0
  70. package/build/tools/forms.d.ts +2 -0
  71. package/build/tools/forms.js +46 -0
  72. package/build/tools/gdpr.d.ts +2 -0
  73. package/build/tools/gdpr.js +61 -0
  74. package/build/tools/org.d.ts +2 -0
  75. package/build/tools/org.js +71 -0
  76. package/build/tools/segments.d.ts +2 -0
  77. package/build/tools/segments.js +384 -0
  78. package/build/tools/sites.d.ts +2 -0
  79. package/build/tools/sites.js +182 -0
  80. package/build/tools/sms.d.ts +2 -0
  81. package/build/tools/sms.js +489 -0
  82. package/build/tools/social-posts.d.ts +2 -0
  83. package/build/tools/social-posts.js +380 -0
  84. package/build/tools/templates.d.ts +2 -0
  85. package/build/tools/templates.js +282 -0
  86. package/build/tools/warmup.d.ts +2 -0
  87. package/build/tools/warmup.js +57 -0
  88. package/build/tools/webhooks.d.ts +2 -0
  89. package/build/tools/webhooks.js +127 -0
  90. package/package.json +63 -0
@@ -0,0 +1,707 @@
1
+ import { createHash, randomBytes } from 'crypto';
2
+ import { z } from 'zod';
3
+ import { adminDb, db } from '../db.js';
4
+ import { decrypt, encrypt } from '../crypto.js';
5
+ import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
6
+ import { getProjectId } from '../project.js';
7
+ import { logAudit } from '../audit.js';
8
+ const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
9
+ function generateApiKey() {
10
+ const secret = `snk_${randomBytes(32).toString('hex')}`;
11
+ return {
12
+ secret,
13
+ hash: createHash('sha256').update(secret).digest('hex'),
14
+ prefix: secret.slice(0, 8),
15
+ };
16
+ }
17
+ function sanitizeSuppression(row) {
18
+ return {
19
+ ...row,
20
+ email: row.reason === 'gdpr_deleted' || row.email?.startsWith('sha256:') ? '[deleted]' : row.email,
21
+ };
22
+ }
23
+ function csvEscape(value) {
24
+ const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
25
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
26
+ }
27
+ function toCsv(rows, columns) {
28
+ return [columns.join(','), ...rows.map((row) => columns.map((column) => csvEscape(row[column])).join(','))].join('\n');
29
+ }
30
+ function toJsonl(rows) {
31
+ return rows.map((row) => JSON.stringify(row)).join('\n');
32
+ }
33
+ const MIGRATION_PROVIDERS = ['customerio', 'mailchimp', 'klaviyo', 'activecampaign', 'convertkit', 'kit', 'resend'];
34
+ const CONNECTED_PLATFORMS = [
35
+ 'stripe',
36
+ 'shopify',
37
+ 'woocommerce',
38
+ 'bigcommerce',
39
+ 'square',
40
+ 'hubspot',
41
+ 'salesforce',
42
+ 'pipedrive',
43
+ 'zoho_crm',
44
+ 'typeform',
45
+ 'tally',
46
+ 'gravity_forms',
47
+ 'google_forms',
48
+ 'calendly',
49
+ 'cal_com',
50
+ 'acuity_scheduling',
51
+ 'mailchimp',
52
+ 'klaviyo',
53
+ 'activecampaign',
54
+ 'convertkit',
55
+ 'kit',
56
+ 'intercom',
57
+ 'zendesk',
58
+ 'google_sheets',
59
+ 'airtable',
60
+ 'customerio',
61
+ 'resend',
62
+ ];
63
+ const PLATFORM_STATUSES = ['connected', 'pending', 'error', 'not_connected'];
64
+ function normalizePlatform(platform) {
65
+ return platform === 'kit' ? 'convertkit' : platform;
66
+ }
67
+ function sanitizeConnection(row) {
68
+ return {
69
+ id: row.id,
70
+ org_id: row.org_id,
71
+ project_id: row.project_id,
72
+ platform: row.platform,
73
+ display_name: row.display_name,
74
+ status: row.status ?? (row.is_active ? 'connected' : 'not_connected'),
75
+ is_active: row.is_active,
76
+ config: row.config ?? row.meta ?? {},
77
+ last_synced_at: row.last_synced_at,
78
+ last_error: row.last_error ?? null,
79
+ created_at: row.created_at,
80
+ updated_at: row.updated_at,
81
+ };
82
+ }
83
+ async function getPlatformProjectContext(client = adminDb(), orgId) {
84
+ const projectId = getProjectId();
85
+ const { data: project, error } = await client
86
+ .schema('email')
87
+ .from('projects')
88
+ .select('id, org_id, owner_user_id')
89
+ .eq('id', projectId)
90
+ .maybeSingle();
91
+ if (error)
92
+ throw new Error(error.message);
93
+ if (!project?.org_id)
94
+ throw new Error('project has no organization');
95
+ if (orgId && orgId !== project.org_id)
96
+ throw new Error('org_id does not match current project');
97
+ let userId = project.owner_user_id;
98
+ if (!userId) {
99
+ const { data: member, error: memberError } = await client
100
+ .schema('email')
101
+ .from('org_members')
102
+ .select('user_id')
103
+ .eq('org_id', project.org_id)
104
+ .not('user_id', 'is', null)
105
+ .order('created_at', { ascending: true })
106
+ .limit(1)
107
+ .maybeSingle();
108
+ if (memberError)
109
+ throw new Error(memberError.message);
110
+ userId = member?.user_id;
111
+ }
112
+ if (!userId)
113
+ throw new Error('organization has no Supabase-auth user to own the connection');
114
+ return { projectId, orgId: project.org_id, userId: userId };
115
+ }
116
+ function createCampaignMigrationTotals(options) {
117
+ return {
118
+ contacts: { total: options.includeContacts ? 1 : 0, processed: 0 },
119
+ templates: { total: 1, processed: 0 },
120
+ campaigns: { total: 1, processed: 0 },
121
+ suppressions: { total: options.includeSuppressions ? 1 : 0, processed: 0 },
122
+ processed: 0,
123
+ errors: 0,
124
+ account_name: 'MCP campaign migration',
125
+ options: {
126
+ import_contacts: options.includeContacts,
127
+ import_templates: true,
128
+ import_campaigns: true,
129
+ import_suppressions: options.includeSuppressions,
130
+ migration_entry_point: 'mcp',
131
+ },
132
+ };
133
+ }
134
+ function createPlatformMigrationTotals(artifactTypes) {
135
+ const requested = artifactTypes?.length ? new Set(artifactTypes) : null;
136
+ const enabled = (key) => !requested || requested.has(key);
137
+ return {
138
+ contacts: { total: enabled('contacts') ? 1 : 0, processed: 0 },
139
+ templates: { total: enabled('templates') ? 1 : 0, processed: 0 },
140
+ campaigns: { total: enabled('campaigns') ? 1 : 0, processed: 0 },
141
+ suppressions: { total: enabled('suppressions') ? 1 : 0, processed: 0 },
142
+ processed: 0,
143
+ errors: 0,
144
+ account_name: 'Platform migration',
145
+ options: {
146
+ import_contacts: enabled('contacts'),
147
+ import_templates: enabled('templates'),
148
+ import_campaigns: enabled('campaigns'),
149
+ import_suppressions: enabled('suppressions'),
150
+ migration_entry_point: 'mcp_connection',
151
+ },
152
+ };
153
+ }
154
+ function crc32(buf) {
155
+ let crc = -1;
156
+ for (let i = 0; i < buf.length; i++) {
157
+ crc ^= buf[i];
158
+ for (let bit = 0; bit < 8; bit++)
159
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
160
+ }
161
+ return (crc ^ -1) >>> 0;
162
+ }
163
+ function createZip(files) {
164
+ const localParts = [];
165
+ const centralParts = [];
166
+ let offset = 0;
167
+ for (const file of files) {
168
+ const name = Buffer.from(file.name);
169
+ const content = Buffer.from(file.content);
170
+ const crc = crc32(content);
171
+ const local = Buffer.alloc(30);
172
+ local.writeUInt32LE(0x04034b50, 0);
173
+ local.writeUInt16LE(20, 4);
174
+ local.writeUInt16LE(0, 6);
175
+ local.writeUInt16LE(0, 8);
176
+ local.writeUInt32LE(crc, 14);
177
+ local.writeUInt32LE(content.length, 18);
178
+ local.writeUInt32LE(content.length, 22);
179
+ local.writeUInt16LE(name.length, 26);
180
+ localParts.push(local, name, content);
181
+ const central = Buffer.alloc(46);
182
+ central.writeUInt32LE(0x02014b50, 0);
183
+ central.writeUInt16LE(20, 4);
184
+ central.writeUInt16LE(20, 6);
185
+ central.writeUInt32LE(crc, 16);
186
+ central.writeUInt32LE(content.length, 20);
187
+ central.writeUInt32LE(content.length, 24);
188
+ central.writeUInt16LE(name.length, 28);
189
+ central.writeUInt32LE(offset, 42);
190
+ centralParts.push(central, name);
191
+ offset += local.length + name.length + content.length;
192
+ }
193
+ const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
194
+ const end = Buffer.alloc(22);
195
+ end.writeUInt32LE(0x06054b50, 0);
196
+ end.writeUInt16LE(files.length, 8);
197
+ end.writeUInt16LE(files.length, 10);
198
+ end.writeUInt32LE(centralSize, 12);
199
+ end.writeUInt32LE(offset, 16);
200
+ return Buffer.concat([...localParts, ...centralParts, end]);
201
+ }
202
+ export function registerDataTools(server) {
203
+ server.registerTool('list_connected_platforms', {
204
+ description: 'List connected external platforms and integration status for the current project organization. Does not return credential material.',
205
+ inputSchema: {
206
+ org_id: z.string().uuid().optional(),
207
+ include_inactive: z.boolean().optional().default(false),
208
+ },
209
+ }, async ({ org_id, include_inactive = false }) => {
210
+ try {
211
+ const client = adminDb();
212
+ const context = await getPlatformProjectContext(client, org_id);
213
+ let query = client
214
+ .schema('email')
215
+ .from('platform_connections')
216
+ .select('id, org_id, project_id, platform, display_name, config, meta, status, is_active, last_synced_at, last_error, created_at, updated_at')
217
+ .eq('org_id', context.orgId)
218
+ .eq('project_id', context.projectId)
219
+ .order('platform', { ascending: true });
220
+ if (!include_inactive)
221
+ query = query.eq('is_active', true);
222
+ const { data, error } = await query;
223
+ if (error)
224
+ return j({ error: error.message });
225
+ return j({
226
+ platforms: CONNECTED_PLATFORMS.filter((platform) => platform !== 'kit'),
227
+ connections: (data ?? []).map(sanitizeConnection),
228
+ });
229
+ }
230
+ catch (error) {
231
+ return j({ error: error instanceof Error ? error.message : 'Failed to list connected platforms' });
232
+ }
233
+ });
234
+ server.registerTool('connect_platform', {
235
+ description: 'Store encrypted credentials for an external platform connection and mark it ready for trigger configuration.',
236
+ inputSchema: {
237
+ org_id: z.string().uuid().optional(),
238
+ platform: z.enum(CONNECTED_PLATFORMS),
239
+ credentials: z.record(z.unknown()),
240
+ display_name: z.string().min(1).optional(),
241
+ config: z.record(z.unknown()).optional().default({}),
242
+ status: z.enum(PLATFORM_STATUSES).optional().default('connected'),
243
+ },
244
+ }, async ({ org_id, platform, credentials, display_name, config = {}, status = 'connected' }) => {
245
+ try {
246
+ if (Object.keys(credentials ?? {}).length === 0)
247
+ return j({ error: 'credentials are required' });
248
+ const client = adminDb();
249
+ const context = await getPlatformProjectContext(client, org_id);
250
+ const normalizedPlatform = normalizePlatform(platform);
251
+ const safeConfig = {
252
+ ...config,
253
+ connected_by: 'mcp',
254
+ credential_keys: Object.keys(credentials).sort(),
255
+ };
256
+ const { data, error } = await client
257
+ .schema('email')
258
+ .from('platform_connections')
259
+ .upsert({
260
+ user_id: context.userId,
261
+ org_id: context.orgId,
262
+ project_id: context.projectId,
263
+ platform: normalizedPlatform,
264
+ display_name: display_name ?? normalizedPlatform,
265
+ credentials_enc: encrypt(JSON.stringify(credentials)),
266
+ config: safeConfig,
267
+ meta: safeConfig,
268
+ status,
269
+ is_active: status !== 'not_connected',
270
+ last_error: null,
271
+ updated_at: new Date().toISOString(),
272
+ }, { onConflict: 'user_id,org_id,project_id,platform' })
273
+ .select('id, org_id, project_id, platform, display_name, config, status, is_active, last_synced_at, last_error, created_at, updated_at')
274
+ .single();
275
+ if (error)
276
+ return j({ error: error.message });
277
+ await logAudit({
278
+ action: 'platform_connection.connected',
279
+ resourceType: 'platform_connection',
280
+ resourceId: data.id,
281
+ details: { platform: normalizedPlatform, status },
282
+ });
283
+ return j({
284
+ connection: sanitizeConnection(data),
285
+ credential_keys: Object.keys(credentials).sort(),
286
+ connection_test: { status: 'stored', message: 'Credentials were encrypted and saved. Live provider validation is handled by provider-specific connector workers.' },
287
+ });
288
+ }
289
+ catch (error) {
290
+ return j({ error: error instanceof Error ? error.message : 'Failed to connect platform' });
291
+ }
292
+ });
293
+ server.registerTool('disconnect_platform', {
294
+ description: 'Disconnect an external platform connection for the current project organization and remove stored credentials.',
295
+ inputSchema: {
296
+ org_id: z.string().uuid().optional(),
297
+ platform: z.enum(CONNECTED_PLATFORMS).optional(),
298
+ connection_id: z.string().uuid().optional(),
299
+ },
300
+ }, async ({ org_id, platform, connection_id }) => {
301
+ try {
302
+ if (!platform && !connection_id)
303
+ return j({ error: 'platform or connection_id is required' });
304
+ const client = adminDb();
305
+ const context = await getPlatformProjectContext(client, org_id);
306
+ let query = client
307
+ .schema('email')
308
+ .from('platform_connections')
309
+ .delete()
310
+ .eq('org_id', context.orgId)
311
+ .eq('project_id', context.projectId);
312
+ if (connection_id)
313
+ query = query.eq('id', connection_id);
314
+ if (platform)
315
+ query = query.eq('platform', normalizePlatform(platform));
316
+ const { data, error } = await query.select('id, platform');
317
+ if (error)
318
+ return j({ error: error.message });
319
+ await logAudit({
320
+ action: 'platform_connection.disconnected',
321
+ resourceType: 'platform_connection',
322
+ resourceId: connection_id,
323
+ details: { platform: platform ? normalizePlatform(platform) : null, disconnected_count: data?.length ?? 0 },
324
+ });
325
+ return j({ disconnected: data?.length ?? 0, connections: data ?? [] });
326
+ }
327
+ catch (error) {
328
+ return j({ error: error instanceof Error ? error.message : 'Failed to disconnect platform' });
329
+ }
330
+ });
331
+ server.registerTool('list_platform_events', {
332
+ description: 'List recent raw events received from connected platforms for troubleshooting trigger mappings.',
333
+ inputSchema: {
334
+ org_id: z.string().uuid().optional(),
335
+ platform: z.enum(CONNECTED_PLATFORMS).optional(),
336
+ limit: z.number().int().min(1).max(100).optional().default(25),
337
+ },
338
+ }, async ({ org_id, platform, limit = 25 }) => {
339
+ try {
340
+ const client = adminDb();
341
+ const context = await getPlatformProjectContext(client, org_id);
342
+ let query = client
343
+ .schema('email')
344
+ .from('platform_events')
345
+ .select('id, platform, event_type, source_event_id, payload, processed_at, contact_id, received_at')
346
+ .eq('org_id', context.orgId)
347
+ .eq('project_id', context.projectId)
348
+ .order('received_at', { ascending: false })
349
+ .limit(limit);
350
+ if (platform)
351
+ query = query.eq('platform', normalizePlatform(platform));
352
+ const { data, error } = await query;
353
+ if (error)
354
+ return j({ error: error.message });
355
+ return j({ events: data ?? [] });
356
+ }
357
+ catch (error) {
358
+ return j({ error: error instanceof Error ? error.message : 'Failed to list platform events' });
359
+ }
360
+ });
361
+ server.registerTool('configure_platform_trigger', {
362
+ description: 'Configure an event-to-action mapping for a connected platform, such as adding a Shopify customer to a list after an order.',
363
+ inputSchema: {
364
+ org_id: z.string().uuid().optional(),
365
+ platform: z.enum(CONNECTED_PLATFORMS),
366
+ event_type: z.string().min(1),
367
+ action: z.object({
368
+ type: z.enum(['add_to_list', 'add_tag', 'trigger_campaign', 'update_field', 'record_event', 'noop']),
369
+ target_id: z.string().optional(),
370
+ list_id: z.string().uuid().optional(),
371
+ tag: z.string().optional(),
372
+ campaign_id: z.string().uuid().optional(),
373
+ field: z.string().optional(),
374
+ value: z.unknown().optional(),
375
+ }).passthrough(),
376
+ },
377
+ }, async ({ org_id, platform, event_type, action }) => {
378
+ try {
379
+ const client = adminDb();
380
+ const context = await getPlatformProjectContext(client, org_id);
381
+ const normalizedPlatform = normalizePlatform(platform);
382
+ const { data: connection, error: connectionError } = await client
383
+ .schema('email')
384
+ .from('platform_connections')
385
+ .select('id, config, meta')
386
+ .eq('org_id', context.orgId)
387
+ .eq('project_id', context.projectId)
388
+ .eq('platform', normalizedPlatform)
389
+ .eq('is_active', true)
390
+ .maybeSingle();
391
+ if (connectionError)
392
+ return j({ error: connectionError.message });
393
+ if (!connection)
394
+ return j({ error: 'active platform connection not found' });
395
+ const currentConfig = connection.config ?? connection.meta ?? {};
396
+ const existingTriggers = Array.isArray(currentConfig.triggers) ? currentConfig.triggers : [];
397
+ const trigger = { event_type, action, updated_at: new Date().toISOString() };
398
+ const nextTriggers = [
399
+ ...existingTriggers.filter((item) => item?.event_type !== event_type),
400
+ trigger,
401
+ ];
402
+ const nextConfig = { ...currentConfig, triggers: nextTriggers };
403
+ const { data, error } = await client
404
+ .schema('email')
405
+ .from('platform_connections')
406
+ .update({ config: nextConfig, meta: nextConfig, updated_at: new Date().toISOString() })
407
+ .eq('id', connection.id)
408
+ .select('id, platform, display_name, config, status, is_active, last_synced_at, updated_at')
409
+ .single();
410
+ if (error)
411
+ return j({ error: error.message });
412
+ await logAudit({
413
+ action: 'platform_trigger.configured',
414
+ resourceType: 'platform_connection',
415
+ resourceId: connection.id,
416
+ details: { platform: normalizedPlatform, event_type, action_type: action.type },
417
+ });
418
+ return j({ connection: sanitizeConnection(data), trigger });
419
+ }
420
+ catch (error) {
421
+ return j({ error: error instanceof Error ? error.message : 'Failed to configure platform trigger' });
422
+ }
423
+ });
424
+ server.registerTool('list_import_history', {
425
+ description: 'List recent subscriber import history rows.',
426
+ inputSchema: { limit: z.number().int().min(1).max(100).optional().default(25) },
427
+ }, async ({ limit = 25 }) => {
428
+ const { data, error } = await db().from('import_log')
429
+ .select('id, source, status, imported_count, failed_count, created_at')
430
+ .eq('project_id', getProjectId())
431
+ .order('created_at', { ascending: false })
432
+ .limit(limit);
433
+ if (error)
434
+ return j({ error: error.message });
435
+ return j({ imports: data ?? [] });
436
+ });
437
+ server.registerTool('list_api_keys', {
438
+ description: 'List API keys without returning secret material.',
439
+ inputSchema: {},
440
+ }, async () => {
441
+ const { data, error } = await db().from('api_keys')
442
+ .select('id, name, slug, key_prefix, scopes, tool_groups, created_at, last_used_at, revoked_at')
443
+ .eq('project_id', getProjectId())
444
+ .is('revoked_at', null)
445
+ .order('created_at', { ascending: false });
446
+ if (error)
447
+ return j({ error: error.message });
448
+ return j({ keys: data ?? [] });
449
+ });
450
+ server.registerTool('create_api_key', {
451
+ description: 'Create an API key and return the secret once.',
452
+ inputSchema: {
453
+ name: z.string().min(1),
454
+ slug: z.string().optional(),
455
+ scopes: z.array(z.string()).optional().default(['read']),
456
+ },
457
+ }, async ({ name, slug, scopes = ['read'] }) => {
458
+ const { secret, hash, prefix } = generateApiKey();
459
+ const cleanSlug = slug?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || null;
460
+ const { data, error } = await db().from('api_keys')
461
+ .insert({ project_id: getProjectId(), name, slug: cleanSlug, key_hash: hash, key_prefix: prefix, scopes })
462
+ .select('id, name, slug')
463
+ .single();
464
+ if (error)
465
+ return j({ error: error.message });
466
+ return j({ key_id: data.id, name: data.name, slug: data.slug, secret });
467
+ });
468
+ server.registerTool('revoke_api_key', {
469
+ description: 'Revoke an API key.',
470
+ inputSchema: { key_id: z.string().uuid() },
471
+ }, async ({ key_id }) => {
472
+ const { error } = await db().from('api_keys').update({ revoked_at: new Date().toISOString() }).eq('project_id', getProjectId()).eq('id', key_id);
473
+ if (error)
474
+ return j({ error: error.message });
475
+ return j({ revoked: true });
476
+ });
477
+ server.registerTool('migrate_campaigns_from', {
478
+ description: 'Start a campaign-artifact migration from a provider token. Imports templates and campaigns as draft, approval-required Sendinel assets; Customer.io is the first-class path.',
479
+ inputSchema: {
480
+ provider: z.enum(MIGRATION_PROVIDERS),
481
+ oauth_token: z.string().min(1),
482
+ account_url: z.string().optional(),
483
+ include_contacts: z.boolean().optional().default(false),
484
+ include_suppressions: z.boolean().optional().default(true),
485
+ },
486
+ }, async ({ provider, oauth_token, account_url, include_contacts = false, include_suppressions = true }) => {
487
+ const projectId = getProjectId();
488
+ const client = adminDb();
489
+ const { data: project, error: projectError } = await client
490
+ .schema('email')
491
+ .from('projects')
492
+ .select('id, org_id')
493
+ .eq('id', projectId)
494
+ .maybeSingle();
495
+ if (projectError)
496
+ return j({ error: projectError.message });
497
+ if (!project?.org_id)
498
+ return j({ error: 'project not found' });
499
+ let normalizedAccountUrl = account_url;
500
+ if (provider === 'activecampaign') {
501
+ try {
502
+ normalizedAccountUrl = normalizeActiveCampaignAccountUrl(account_url ?? '');
503
+ }
504
+ catch (error) {
505
+ return j({ error: error instanceof Error ? error.message : 'Invalid ActiveCampaign account URL' });
506
+ }
507
+ }
508
+ const credentials = { api_key: oauth_token, account_url: normalizedAccountUrl };
509
+ const { data, error } = await client
510
+ .schema('email')
511
+ .from('import_jobs')
512
+ .insert({
513
+ org_id: project.org_id,
514
+ project_id: projectId,
515
+ provider,
516
+ status: 'pending',
517
+ progress_pct: 0,
518
+ totals: createCampaignMigrationTotals({ includeContacts: include_contacts, includeSuppressions: include_suppressions }),
519
+ errors: [],
520
+ credentials_enc: encrypt(JSON.stringify(credentials)),
521
+ field_mapping: null,
522
+ created_by: null,
523
+ })
524
+ .select('id')
525
+ .single();
526
+ if (error)
527
+ return j({ error: error.message });
528
+ return j({
529
+ job_id: data.id,
530
+ status: 'pending',
531
+ review_required: true,
532
+ imported_categories: {
533
+ contacts: include_contacts,
534
+ templates: true,
535
+ campaigns: true,
536
+ suppressions: include_suppressions,
537
+ },
538
+ note: 'Campaign artifacts are imported as drafts and require approval before sending.',
539
+ });
540
+ });
541
+ server.registerTool('start_platform_migration', {
542
+ description: 'Start a migration from a stored external platform connection into Sendinel. Returns a job_id for status polling.',
543
+ inputSchema: {
544
+ connection_id: z.string().uuid(),
545
+ artifact_types: z.array(z.enum(['contacts', 'templates', 'campaigns', 'suppressions'])).optional(),
546
+ dry_run: z.boolean().optional().default(false),
547
+ },
548
+ }, async ({ connection_id, artifact_types, dry_run = false }) => {
549
+ const projectId = getProjectId();
550
+ const client = adminDb();
551
+ const { data: connection, error: connectionError } = await client
552
+ .schema('email')
553
+ .from('platform_connections')
554
+ .select('id, org_id, project_id, platform, credentials_enc')
555
+ .eq('id', connection_id)
556
+ .eq('project_id', projectId)
557
+ .eq('is_active', true)
558
+ .maybeSingle();
559
+ if (connectionError)
560
+ return j({ error: connectionError.message });
561
+ if (!connection)
562
+ return j({ error: 'platform connection not found' });
563
+ if (!MIGRATION_PROVIDERS.includes(connection.platform))
564
+ return j({ error: 'unsupported platform' });
565
+ if (dry_run)
566
+ return j({ error: 'dry_run staging is not available through MCP yet; start from the dashboard preview first.' });
567
+ const credentials = JSON.parse(decrypt(connection.credentials_enc));
568
+ const { data, error } = await client
569
+ .schema('email')
570
+ .from('import_jobs')
571
+ .insert({
572
+ org_id: connection.org_id,
573
+ project_id: projectId,
574
+ provider: connection.platform,
575
+ status: 'pending',
576
+ progress_pct: 0,
577
+ totals: createPlatformMigrationTotals(artifact_types),
578
+ errors: [],
579
+ credentials_enc: encrypt(JSON.stringify(credentials)),
580
+ field_mapping: null,
581
+ created_by: null,
582
+ })
583
+ .select('id')
584
+ .single();
585
+ if (error)
586
+ return j({ error: error.message });
587
+ await client.schema('email').from('platform_connections').update({ last_synced_at: new Date().toISOString() }).eq('id', connection_id);
588
+ return j({ job_id: data.id, status: 'pending', connection_id, provider: connection.platform });
589
+ });
590
+ server.registerTool('list_notifications', {
591
+ description: 'List in-app notifications.',
592
+ inputSchema: { unread_only: z.boolean().optional().default(false) },
593
+ }, async ({ unread_only = false }) => {
594
+ let query = db().from('notifications').select('id, type, title, body, severity, read_at, created_at, action_url').eq('project_id', getProjectId()).order('created_at', { ascending: false }).limit(50);
595
+ if (unread_only)
596
+ query = query.is('read_at', null);
597
+ const { data, error } = await query;
598
+ if (error)
599
+ return j({ error: error.message });
600
+ return j({ notifications: data ?? [] });
601
+ });
602
+ server.registerTool('mark_notifications_read', {
603
+ description: 'Mark selected notifications read, or all unread notifications if ids is omitted.',
604
+ inputSchema: { ids: z.array(z.string().uuid()).optional() },
605
+ }, async ({ ids }) => {
606
+ let query = db().from('notifications').update({ read_at: new Date().toISOString() }).eq('project_id', getProjectId()).is('read_at', null);
607
+ if (ids?.length)
608
+ query = query.in('id', ids);
609
+ const { data, error } = await query.select('id');
610
+ if (error)
611
+ return j({ error: error.message });
612
+ return j({ updated: data?.length ?? 0 });
613
+ });
614
+ server.registerTool('tenant_export_full', {
615
+ description: 'Export all tenant data classes for portability. Returns inline data for small exports or a signed archive URL for large exports.',
616
+ inputSchema: {
617
+ siteId: z.string().uuid(),
618
+ format: z.enum(['json', 'csv']).optional().default('json'),
619
+ },
620
+ }, async ({ siteId, format = 'json' }) => {
621
+ const projectId = getProjectId();
622
+ const since = new Date(Date.now() - 90 * 24 * 3600 * 1000).toISOString();
623
+ const client = db();
624
+ const { data: siteSubscriptions } = await client.from('site_subscriptions')
625
+ .select('contact_id')
626
+ .eq('site_id', siteId)
627
+ .eq('subscribed', true);
628
+ const contactIds = (siteSubscriptions ?? []).map((row) => row.contact_id);
629
+ const contactsQuery = client.from('subscribers')
630
+ .select('id, email, first_name, last_name, tags, custom_properties, created_at, last_engaged_at, source')
631
+ .eq('project_id', projectId);
632
+ const [{ data: contacts }, { data: emailHistory }, { data: suppressions }, { data: templates }, { data: site }] = await Promise.all([
633
+ contactIds.length ? contactsQuery.in('id', contactIds) : Promise.resolve({ data: [], error: null }),
634
+ client.from('email_log')
635
+ .select('id, to_email, from_email, subject, status, sent_at, opened_at, clicked_at, bounced_at, complained_at, campaign_id')
636
+ .eq('project_id', projectId)
637
+ .eq('site_id', siteId)
638
+ .not('to_email', 'like', 'sha256:%')
639
+ .gte('sent_at', since),
640
+ client.from('suppressions').select('email, reason, source, created_at').eq('project_id', projectId),
641
+ client.from('campaign_templates').select('name, subject_hint, body_html, body_text, brief_template').eq('project_id', projectId),
642
+ client.from('sites')
643
+ .select('id, name, brand_voice, projects(org_id, organizations(brand_settings))')
644
+ .eq('id', siteId)
645
+ .eq('project_id', projectId)
646
+ .maybeSingle(),
647
+ ]);
648
+ const safeSuppressions = (suppressions ?? []).map(sanitizeSuppression);
649
+ const brandVoiceProfile = {
650
+ site_id: siteId,
651
+ site_name: site?.name ?? null,
652
+ site_brand_voice: site?.brand_voice ?? {},
653
+ organization_brand_settings: site?.projects?.organizations?.brand_settings ?? {},
654
+ };
655
+ const recordCounts = {
656
+ contacts: contacts?.length ?? 0,
657
+ email_history: emailHistory?.length ?? 0,
658
+ suppressions: safeSuppressions.length,
659
+ templates: templates?.length ?? 0,
660
+ };
661
+ const totalRecords = Object.values(recordCounts).reduce((sum, count) => sum + count, 0);
662
+ const files = format === 'csv'
663
+ ? [
664
+ { name: 'contacts.csv', content: toCsv(contacts ?? [], ['id', 'email', 'first_name', 'last_name', 'tags', 'custom_properties', 'created_at', 'last_engaged_at', 'source']) },
665
+ { name: 'email_history.jsonl', content: toJsonl(emailHistory ?? []) },
666
+ { name: 'suppressions.csv', content: toCsv(safeSuppressions, ['email', 'reason', 'source', 'created_at']) },
667
+ { name: 'templates.json', content: JSON.stringify(templates ?? [], null, 2) },
668
+ { name: 'brand_voice_profile.json', content: JSON.stringify(brandVoiceProfile, null, 2) },
669
+ ]
670
+ : [
671
+ { name: 'contacts.json', content: JSON.stringify(contacts ?? [], null, 2) },
672
+ { name: 'email_history.jsonl', content: toJsonl(emailHistory ?? []) },
673
+ { name: 'suppressions.json', content: JSON.stringify(safeSuppressions, null, 2) },
674
+ { name: 'templates.json', content: JSON.stringify(templates ?? [], null, 2) },
675
+ { name: 'brand_voice_profile.json', content: JSON.stringify(brandVoiceProfile, null, 2) },
676
+ ];
677
+ if (totalRecords < 5000) {
678
+ return j({
679
+ inline: {
680
+ contacts: contacts ?? [],
681
+ email_history: emailHistory ?? [],
682
+ suppressions: safeSuppressions,
683
+ templates: templates ?? [],
684
+ brand_voice_profile: brandVoiceProfile,
685
+ },
686
+ record_counts: recordCounts,
687
+ });
688
+ }
689
+ const bucket = 'tenant-exports';
690
+ const path = `${projectId}/${siteId}/${Date.now()}-export.zip`;
691
+ let upload = await client.storage.from(bucket).upload(path, createZip(files), { contentType: 'application/zip', upsert: true });
692
+ if (upload.error && /bucket/i.test(upload.error.message)) {
693
+ await client.storage.createBucket(bucket, { public: false });
694
+ upload = await client.storage.from(bucket).upload(path, createZip(files), { contentType: 'application/zip', upsert: true });
695
+ }
696
+ if (upload.error)
697
+ return j({ error: upload.error.message });
698
+ const { data, error } = await client.storage.from(bucket).createSignedUrl(path, 24 * 3600);
699
+ if (error)
700
+ return j({ error: error.message });
701
+ return j({
702
+ download_url: data.signedUrl,
703
+ expires_at: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
704
+ record_counts: recordCounts,
705
+ });
706
+ });
707
+ }