@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.
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 +9 -3
@@ -0,0 +1,460 @@
1
+ import { createHash } from 'node:crypto';
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 workItemSelect = [
8
+ 'id',
9
+ 'org_id',
10
+ 'project_id',
11
+ 'site_id',
12
+ 'kind',
13
+ 'status',
14
+ 'severity',
15
+ 'confidence',
16
+ 'source',
17
+ 'title',
18
+ 'why_now',
19
+ 'evidence',
20
+ 'recommended_actions',
21
+ 'proposal_ids',
22
+ 'surfaces',
23
+ 'generated_at',
24
+ 'snoozed_until',
25
+ ].join(', ');
26
+ const proposalSelect = [
27
+ 'id',
28
+ 'project_id',
29
+ 'org_id',
30
+ 'type',
31
+ 'target_table',
32
+ 'target_id',
33
+ 'target_label',
34
+ 'before_json',
35
+ 'after_json',
36
+ 'reasoning',
37
+ 'source_data_refs',
38
+ 'confidence',
39
+ 'status',
40
+ 'expires_at',
41
+ 'applied_at',
42
+ 'applied_by',
43
+ 'apply_method',
44
+ 'apply_error',
45
+ 'generated_at',
46
+ 'generated_by',
47
+ 'llm_model',
48
+ ].join(', ');
49
+ const workStatusSchema = z.enum(['new', 'seen', 'accepted', 'dismissed', 'snoozed', 'resolved', 'failed']);
50
+ const activeStatuses = ['new', 'seen', 'snoozed'];
51
+ const contextEventTypes = ['strategic_segment_intent', 'business_priority', 'customer_journey_signal', 'campaign_opportunity', 'risk_signal'];
52
+ const contextEventInput = {
53
+ workspace_id: z.string().min(1).max(255),
54
+ event_type: z.enum(contextEventTypes),
55
+ title: z.string().min(1).max(255),
56
+ detail: z.string().min(1).max(4000),
57
+ evidence: z.record(z.string(), z.unknown()).optional(),
58
+ suggested_actions: z.array(z.record(z.string(), z.unknown())).optional(),
59
+ idempotency_key: z.string().min(1).max(255),
60
+ };
61
+ function parseStatuses(status) {
62
+ if (!status)
63
+ return [...activeStatuses];
64
+ const requested = status.split(',').map((item) => item.trim()).filter(Boolean);
65
+ const valid = requested.filter((item) => workStatusSchema.safeParse(item).success);
66
+ return valid.length ? valid : [...activeStatuses];
67
+ }
68
+ async function updateWorkItemStatus(id, status, snoozedUntil) {
69
+ const updates = { status };
70
+ if (status === 'dismissed')
71
+ updates.dismissed_at = new Date().toISOString();
72
+ if (status === 'snoozed') {
73
+ if (!snoozedUntil)
74
+ return { error: 'snoozed_until is required.' };
75
+ updates.snoozed_until = snoozedUntil;
76
+ }
77
+ else {
78
+ updates.snoozed_until = null;
79
+ }
80
+ const { data, error } = await db()
81
+ .from('ai_work_items')
82
+ .update(updates)
83
+ .eq('project_id', getProjectId())
84
+ .eq('id', id)
85
+ .select(workItemSelect)
86
+ .maybeSingle();
87
+ if (error)
88
+ return { error: error.message };
89
+ if (!data)
90
+ return { error: 'AI work item not found.' };
91
+ return { work_item: data };
92
+ }
93
+ function stableUuid(input) {
94
+ const hex = createHash('sha256').update(input).digest('hex').slice(0, 32);
95
+ const variant = ((parseInt(hex.slice(16, 18), 16) & 0x3f) | 0x80).toString(16).padStart(2, '0');
96
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(18, 20)}-${hex.slice(20, 32)}`;
97
+ }
98
+ async function getOrgIdForProject(projectId) {
99
+ const { data, error } = await db()
100
+ .from('projects')
101
+ .select('org_id')
102
+ .eq('id', projectId)
103
+ .maybeSingle();
104
+ if (error)
105
+ throw new Error(error.message);
106
+ if (!data?.org_id)
107
+ throw new Error('Project org not found.');
108
+ return data.org_id;
109
+ }
110
+ function segmentActionFromContext(eventType, suggestedActions, title, detail) {
111
+ if (eventType !== 'strategic_segment_intent')
112
+ return null;
113
+ const action = (suggestedActions ?? []).find((candidate) => (candidate.type === 'create_recommended_segment' || candidate.type === 'create_segment' || candidate.kind === 'segment'));
114
+ if (!action || !action.rules || typeof action.rules !== 'object' || Array.isArray(action.rules))
115
+ return null;
116
+ const rawName = typeof action.segment_name === 'string' ? action.segment_name : typeof action.name === 'string' ? action.name : title;
117
+ return {
118
+ name: rawName.trim().startsWith('AI:') ? rawName.trim() : `AI: ${rawName.trim()}`,
119
+ description: typeof action.description === 'string' && action.description.trim() ? action.description.trim() : detail,
120
+ rules: action.rules,
121
+ };
122
+ }
123
+ async function submitContextEvent(input) {
124
+ const projectId = getProjectId();
125
+ const existing = await db()
126
+ .from('ai_context_events')
127
+ .select('*')
128
+ .eq('project_id', projectId)
129
+ .eq('source', 'synchronex')
130
+ .eq('idempotency_key', input.idempotency_key)
131
+ .maybeSingle();
132
+ if (existing.error)
133
+ return { error: existing.error.message };
134
+ if (existing.data)
135
+ return { context_event: existing.data, work_item: null, proposals: [], idempotent: true };
136
+ const orgId = await getOrgIdForProject(projectId);
137
+ const inserted = await db()
138
+ .from('ai_context_events')
139
+ .insert({
140
+ org_id: orgId,
141
+ project_id: projectId,
142
+ workspace_id: input.workspace_id,
143
+ source: 'synchronex',
144
+ event_type: input.event_type,
145
+ title: input.title,
146
+ detail: input.detail,
147
+ evidence: input.evidence ?? {},
148
+ suggested_actions: input.suggested_actions ?? [],
149
+ idempotency_key: input.idempotency_key,
150
+ })
151
+ .select('*')
152
+ .single();
153
+ if (inserted.error?.code === '23505') {
154
+ const duplicate = await db()
155
+ .from('ai_context_events')
156
+ .select('*')
157
+ .eq('project_id', projectId)
158
+ .eq('source', 'synchronex')
159
+ .eq('idempotency_key', input.idempotency_key)
160
+ .maybeSingle();
161
+ if (duplicate.error)
162
+ return { error: duplicate.error.message };
163
+ if (duplicate.data)
164
+ return { context_event: duplicate.data, work_item: null, proposals: [], idempotent: true };
165
+ }
166
+ if (inserted.error)
167
+ return { error: inserted.error.message };
168
+ const event = inserted.data;
169
+ const segmentAction = segmentActionFromContext(input.event_type, input.suggested_actions, input.title, input.detail);
170
+ const proposals = [];
171
+ if (segmentAction) {
172
+ const targetId = stableUuid(`synchronex-context:${projectId}:${event.id}:segment`);
173
+ const { data: proposal, error } = await db()
174
+ .from('agent_proposals')
175
+ .insert({
176
+ project_id: projectId,
177
+ org_id: orgId,
178
+ type: 'create_recommended_segment',
179
+ target_table: 'segments',
180
+ target_id: targetId,
181
+ target_label: segmentAction.name,
182
+ before_json: { existing_segment_id: null },
183
+ after_json: {
184
+ segment_id: targetId,
185
+ segment_name: segmentAction.name,
186
+ description: segmentAction.description,
187
+ rules: segmentAction.rules,
188
+ estimated_contacts: 0,
189
+ sample_contact_ids: [],
190
+ recommendation_kind: 'synchronex_context',
191
+ },
192
+ reasoning: input.detail,
193
+ source_data_refs: [{ table: 'email.ai_context_events', ids: [event.id] }],
194
+ confidence: typeof input.evidence?.confidence === 'number' ? Math.max(0, Math.min(100, Math.trunc(input.evidence.confidence))) : 75,
195
+ status: 'pending',
196
+ expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
197
+ generated_by: 'synchronex',
198
+ llm_model: null,
199
+ })
200
+ .select(proposalSelect)
201
+ .single();
202
+ if (error)
203
+ return { error: error.message };
204
+ proposals.push(proposal);
205
+ }
206
+ const proposalIds = proposals.map((proposal) => proposal.id);
207
+ const { data: workItem, error: workError } = await db()
208
+ .from('ai_work_items')
209
+ .insert({
210
+ org_id: orgId,
211
+ project_id: projectId,
212
+ kind: proposalIds.length ? 'segment_recommendation' : `synchronex_${input.event_type}`,
213
+ status: 'new',
214
+ severity: input.event_type === 'risk_signal' ? 'medium' : 'info',
215
+ confidence: typeof input.evidence?.confidence === 'number' ? Math.max(0, Math.min(100, Math.trunc(input.evidence.confidence))) : null,
216
+ source: 'synchronex',
217
+ title: input.title,
218
+ why_now: input.detail,
219
+ evidence: { ...(input.evidence ?? {}), context_event_id: event.id, synchronex_workspace_id: input.workspace_id, event_type: input.event_type },
220
+ recommended_actions: proposalIds.length
221
+ ? proposalIds.map((proposalId) => ({ type: 'review_proposal', label: 'Review recommended segment', proposal_id: proposalId, risk: 'low' }))
222
+ : [{ type: 'review_context', label: 'Review Synchronex context', risk: 'low' }],
223
+ surfaces: proposalIds.length ? ['overview', 'segments'] : ['overview'],
224
+ proposal_ids: proposalIds,
225
+ dedupe_key: `synchronex:${input.workspace_id}:${input.idempotency_key}`,
226
+ cooldown_until: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
227
+ })
228
+ .select(workItemSelect)
229
+ .single();
230
+ if (workError)
231
+ return { error: workError.message };
232
+ const { data: updatedEvent, error: updateError } = await db()
233
+ .from('ai_context_events')
234
+ .update({ work_item_id: workItem.id, proposal_ids: proposalIds })
235
+ .eq('project_id', projectId)
236
+ .eq('id', event.id)
237
+ .select('*')
238
+ .single();
239
+ if (updateError)
240
+ return { error: updateError.message };
241
+ return { context_event: updatedEvent, work_item: workItem, proposals, idempotent: false };
242
+ }
243
+ export function registerAiWorkTools(server) {
244
+ server.registerTool('list_ai_work_items', {
245
+ title: 'List AI Work Items',
246
+ annotations: {
247
+ readOnlyHint: true,
248
+ destructiveHint: false,
249
+ idempotentHint: true,
250
+ openWorldHint: false,
251
+ },
252
+ description: 'List proactive Sendinel AI work items for the current project.',
253
+ inputSchema: {
254
+ status: z.string().optional().describe('Comma-separated statuses. Defaults to active: new,seen,snoozed.'),
255
+ surface: z.string().min(1).max(64).optional(),
256
+ limit: z.number().int().min(1).max(100).optional().default(25),
257
+ },
258
+ }, async ({ status, surface, limit }) => {
259
+ let query = db()
260
+ .from('ai_work_items')
261
+ .select(workItemSelect)
262
+ .eq('project_id', getProjectId())
263
+ .in('status', parseStatuses(status))
264
+ .order('generated_at', { ascending: false })
265
+ .limit(limit ?? 25);
266
+ if (surface)
267
+ query = query.contains('surfaces', [surface]);
268
+ const { data, error } = await query;
269
+ if (error)
270
+ return txt(`Error: ${error.message}`);
271
+ return j({ work_items: data ?? [] });
272
+ });
273
+ server.registerTool('get_ai_work_item', {
274
+ title: 'Get AI Work Item',
275
+ annotations: {
276
+ readOnlyHint: true,
277
+ destructiveHint: false,
278
+ idempotentHint: true,
279
+ openWorldHint: false,
280
+ },
281
+ description: 'Get one proactive Sendinel AI work item by id for the current project.',
282
+ inputSchema: {
283
+ work_item_id: z.string().uuid(),
284
+ },
285
+ }, async ({ work_item_id }) => {
286
+ const { data, error } = await db()
287
+ .from('ai_work_items')
288
+ .select(workItemSelect)
289
+ .eq('project_id', getProjectId())
290
+ .eq('id', work_item_id)
291
+ .maybeSingle();
292
+ if (error)
293
+ return txt(`Error: ${error.message}`);
294
+ if (!data)
295
+ return txt('AI work item not found.');
296
+ return j({ work_item: data });
297
+ });
298
+ server.registerTool('list_recommended_segments', {
299
+ title: 'List Recommended Segments',
300
+ annotations: {
301
+ readOnlyHint: true,
302
+ destructiveHint: false,
303
+ idempotentHint: true,
304
+ openWorldHint: false,
305
+ },
306
+ description: 'List proactive segment recommendation work items and linked segment proposals.',
307
+ inputSchema: {
308
+ status: z.string().optional().describe('Comma-separated work item statuses. Defaults to active: new,seen,snoozed.'),
309
+ limit: z.number().int().min(1).max(100).optional().default(25),
310
+ },
311
+ }, async ({ status, limit }) => {
312
+ const { data: workItems, error } = await db()
313
+ .from('ai_work_items')
314
+ .select(workItemSelect)
315
+ .eq('project_id', getProjectId())
316
+ .eq('kind', 'segment_recommendation')
317
+ .in('status', parseStatuses(status))
318
+ .order('generated_at', { ascending: false })
319
+ .limit(limit ?? 25);
320
+ if (error)
321
+ return txt(`Error: ${error.message}`);
322
+ const proposalIds = [...new Set((workItems ?? []).flatMap((item) => Array.isArray(item.proposal_ids) ? item.proposal_ids : []))];
323
+ let proposals = [];
324
+ if (proposalIds.length) {
325
+ const proposalResult = await db()
326
+ .from('agent_proposals')
327
+ .select(proposalSelect)
328
+ .eq('project_id', getProjectId())
329
+ .in('id', proposalIds);
330
+ if (proposalResult.error)
331
+ return txt(`Error: ${proposalResult.error.message}`);
332
+ proposals = proposalResult.data ?? [];
333
+ }
334
+ return j({ work_items: workItems ?? [], proposals });
335
+ });
336
+ server.registerTool('mark_ai_work_item_seen', {
337
+ title: 'Mark AI Work Item Seen',
338
+ annotations: {
339
+ readOnlyHint: false,
340
+ destructiveHint: false,
341
+ idempotentHint: true,
342
+ openWorldHint: false,
343
+ },
344
+ description: 'Mark a proactive Sendinel AI work item as seen.',
345
+ inputSchema: {
346
+ work_item_id: z.string().uuid(),
347
+ },
348
+ }, async ({ work_item_id }) => {
349
+ const result = await updateWorkItemStatus(work_item_id, 'seen');
350
+ return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
351
+ });
352
+ server.registerTool('snooze_ai_work_item', {
353
+ title: 'Snooze AI Work Item',
354
+ annotations: {
355
+ readOnlyHint: false,
356
+ destructiveHint: false,
357
+ idempotentHint: true,
358
+ openWorldHint: false,
359
+ },
360
+ description: 'Snooze a proactive Sendinel AI work item until a future ISO timestamp.',
361
+ inputSchema: {
362
+ work_item_id: z.string().uuid(),
363
+ snoozed_until: z.string().datetime(),
364
+ },
365
+ }, async ({ work_item_id, snoozed_until }) => {
366
+ const result = await updateWorkItemStatus(work_item_id, 'snoozed', snoozed_until);
367
+ return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
368
+ });
369
+ server.registerTool('dismiss_ai_work_item', {
370
+ title: 'Dismiss AI Work Item',
371
+ annotations: {
372
+ readOnlyHint: false,
373
+ destructiveHint: false,
374
+ idempotentHint: true,
375
+ openWorldHint: false,
376
+ },
377
+ description: 'Dismiss a proactive Sendinel AI work item.',
378
+ inputSchema: {
379
+ work_item_id: z.string().uuid(),
380
+ },
381
+ }, async ({ work_item_id }) => {
382
+ const result = await updateWorkItemStatus(work_item_id, 'dismissed');
383
+ return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
384
+ });
385
+ server.registerTool('submit_ai_context_event', {
386
+ title: 'Submit AI Context Event',
387
+ annotations: {
388
+ readOnlyHint: false,
389
+ destructiveHint: false,
390
+ idempotentHint: false,
391
+ openWorldHint: false,
392
+ },
393
+ description: 'Submit Synchronex strategic context into Sendinel AI work queue without directly mutating email state.',
394
+ inputSchema: contextEventInput,
395
+ }, async (input) => {
396
+ const result = await submitContextEvent({ ...input, source: 'synchronex' });
397
+ return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
398
+ });
399
+ server.registerTool('list_agent_proposals', {
400
+ title: 'List Agent Proposals',
401
+ annotations: {
402
+ readOnlyHint: true,
403
+ destructiveHint: false,
404
+ idempotentHint: true,
405
+ openWorldHint: false,
406
+ },
407
+ description: 'List Sendinel agent proposals for the current project.',
408
+ inputSchema: {
409
+ status: z.enum(['pending', 'applied', 'auto_applied', 'dismissed', 'disputed', 'stale', 'failed', 'expired']).optional(),
410
+ type: z.string().min(1).max(96).optional(),
411
+ target_table: z.string().min(1).max(96).optional(),
412
+ target_id: z.string().optional(),
413
+ limit: z.number().int().min(1).max(100).optional().default(25),
414
+ },
415
+ }, async ({ status, type, target_table, target_id, limit }) => {
416
+ let query = db()
417
+ .from('agent_proposals')
418
+ .select(proposalSelect)
419
+ .eq('project_id', getProjectId())
420
+ .order('generated_at', { ascending: false })
421
+ .limit(limit ?? 25);
422
+ if (status)
423
+ query = query.eq('status', status);
424
+ if (type)
425
+ query = query.eq('type', type);
426
+ if (target_table)
427
+ query = query.eq('target_table', target_table);
428
+ if (target_id)
429
+ query = query.eq('target_id', target_id);
430
+ const { data, error } = await query;
431
+ if (error)
432
+ return txt(`Error: ${error.message}`);
433
+ return j({ proposals: data ?? [] });
434
+ });
435
+ server.registerTool('get_agent_proposal', {
436
+ title: 'Get Agent Proposal',
437
+ annotations: {
438
+ readOnlyHint: true,
439
+ destructiveHint: false,
440
+ idempotentHint: true,
441
+ openWorldHint: false,
442
+ },
443
+ description: 'Get one Sendinel agent proposal by id for the current project.',
444
+ inputSchema: {
445
+ proposal_id: z.string().uuid(),
446
+ },
447
+ }, async ({ proposal_id }) => {
448
+ const { data, error } = await db()
449
+ .from('agent_proposals')
450
+ .select(proposalSelect)
451
+ .eq('project_id', getProjectId())
452
+ .eq('id', proposal_id)
453
+ .maybeSingle();
454
+ if (error)
455
+ return txt(`Error: ${error.message}`);
456
+ if (!data)
457
+ return txt('Agent proposal not found.');
458
+ return j({ proposal: data });
459
+ });
460
+ }
@@ -3,6 +3,7 @@ type EmailLogRow = {
3
3
  id?: string;
4
4
  status: string;
5
5
  sent_at?: string | null;
6
+ campaign_id?: string | null;
6
7
  };
7
8
  type AnalyticsMetrics = {
8
9
  sent: number;