@sendinel/mcp-server 1.0.9 → 1.0.11
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/README.md +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/analytics.js +97 -0
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +326 -52
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data.js +441 -0
- package/build/tools/delivery-ops.js +508 -1
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +549 -33
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +14 -13
|
@@ -1,8 +1,138 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import
|
|
2
|
+
import dns from 'node:dns/promises';
|
|
3
|
+
import { cancelSendQueueRows, findReplayableEnrollmentDlqRows, listSendQueueRows, replayEnrollmentDlqRows, retrySendQueueRows, } from '@sendinel/domain';
|
|
4
|
+
import { logAudit } from '../audit.js';
|
|
5
|
+
import { adminDb, db } from '../db.js';
|
|
6
|
+
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
3
8
|
import { getProjectId } from '../project.js';
|
|
4
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
return value && typeof value === 'object' ? value : {};
|
|
12
|
+
}
|
|
13
|
+
function v1ErrorMessage(value) {
|
|
14
|
+
const body = asRecord(value);
|
|
15
|
+
return asRecord(body.error).message ?? body.error ?? value;
|
|
16
|
+
}
|
|
17
|
+
async function resolveNameservers(domain) {
|
|
18
|
+
const labels = domain.toLowerCase().split('.').filter(Boolean);
|
|
19
|
+
for (let index = 0; index < labels.length - 1; index += 1) {
|
|
20
|
+
const candidate = labels.slice(index).join('.');
|
|
21
|
+
try {
|
|
22
|
+
return { nameservers: await dns.resolveNs(candidate), zone: candidate };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Try the next parent domain.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { nameservers: [], zone: null };
|
|
29
|
+
}
|
|
30
|
+
function detectDnsProvider(nameservers) {
|
|
31
|
+
const normalized = nameservers.map((value) => value.toLowerCase());
|
|
32
|
+
if (normalized.some((value) => value.endsWith('.ns.cloudflare.com')))
|
|
33
|
+
return 'cloudflare';
|
|
34
|
+
if (normalized.some((value) => value.includes('awsdns')))
|
|
35
|
+
return 'route53';
|
|
36
|
+
if (normalized.some((value) => value.includes('googledomains') || value.includes('google')))
|
|
37
|
+
return 'google';
|
|
38
|
+
if (normalized.some((value) => value.includes('domaincontrol.com')))
|
|
39
|
+
return 'godaddy';
|
|
40
|
+
if (normalized.some((value) => value.includes('registrar-servers.com') || value.includes('namecheap')))
|
|
41
|
+
return 'namecheap';
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
async function getOrProvisionDmarcInboundToken(projectId) {
|
|
45
|
+
const sb = adminDb();
|
|
46
|
+
const { data, error } = await sb.schema('email').from('projects')
|
|
47
|
+
.select('dmarc_inbound_token').eq('id', projectId).single();
|
|
48
|
+
if (error)
|
|
49
|
+
throw new Error(error.message);
|
|
50
|
+
let token = data?.dmarc_inbound_token;
|
|
51
|
+
if (!token) {
|
|
52
|
+
token = crypto.randomUUID().replace(/-/g, '').slice(0, 24);
|
|
53
|
+
const { error: updateError } = await sb.schema('email').from('projects')
|
|
54
|
+
.update({ dmarc_inbound_token: token }).eq('id', projectId);
|
|
55
|
+
if (updateError) {
|
|
56
|
+
const { data: retry } = await sb.schema('email').from('projects')
|
|
57
|
+
.select('dmarc_inbound_token').eq('id', projectId).single();
|
|
58
|
+
token = retry?.dmarc_inbound_token ?? token;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!token)
|
|
62
|
+
throw new Error('Unable to provision DMARC inbound token');
|
|
63
|
+
return token;
|
|
64
|
+
}
|
|
5
65
|
export function registerDeliveryOpsTools(server) {
|
|
66
|
+
server.registerTool('list_blackout_dates', {
|
|
67
|
+
title: 'List Blackout Dates',
|
|
68
|
+
annotations: {
|
|
69
|
+
readOnlyHint: true,
|
|
70
|
+
destructiveHint: false,
|
|
71
|
+
idempotentHint: true,
|
|
72
|
+
openWorldHint: false,
|
|
73
|
+
},
|
|
74
|
+
description: 'List sending blackout dates for this project. The send-enqueue pipeline skips a due send when the contact-local date matches one of these dates.',
|
|
75
|
+
inputSchema: {},
|
|
76
|
+
}, async () => {
|
|
77
|
+
const { data, error } = await db()
|
|
78
|
+
.from('blackout_dates')
|
|
79
|
+
.select('id, date, reason, created_at')
|
|
80
|
+
.eq('project_id', getProjectId())
|
|
81
|
+
.order('date', { ascending: true });
|
|
82
|
+
if (error)
|
|
83
|
+
return j({ error: error.message });
|
|
84
|
+
return j({ blackout_dates: data ?? [] });
|
|
85
|
+
});
|
|
86
|
+
server.registerTool('add_blackout_date', {
|
|
87
|
+
title: 'Add Blackout Date',
|
|
88
|
+
annotations: {
|
|
89
|
+
readOnlyHint: false,
|
|
90
|
+
destructiveHint: false,
|
|
91
|
+
idempotentHint: true,
|
|
92
|
+
openWorldHint: false,
|
|
93
|
+
},
|
|
94
|
+
description: 'Mark a date as a sending blackout for this project. Calling this again for the same date updates its reason. Due sends are skipped for the contact-local blackout date, not cancelled.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
date: z.string().describe('Date in YYYY-MM-DD format, for example 2026-12-25'),
|
|
97
|
+
reason: z.string().optional().describe('Optional human-readable reason'),
|
|
98
|
+
},
|
|
99
|
+
}, async ({ date, reason }) => {
|
|
100
|
+
if (Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) {
|
|
101
|
+
return j({ error: 'date must be a valid YYYY-MM-DD date' });
|
|
102
|
+
}
|
|
103
|
+
const { data, error } = await db()
|
|
104
|
+
.from('blackout_dates')
|
|
105
|
+
.upsert({ project_id: getProjectId(), date, reason: reason?.trim() || null }, { onConflict: 'project_id,date' })
|
|
106
|
+
.select('id, date, reason, created_at')
|
|
107
|
+
.single();
|
|
108
|
+
if (error)
|
|
109
|
+
return j({ error: error.message });
|
|
110
|
+
return j({ blackout_date: data });
|
|
111
|
+
});
|
|
112
|
+
server.registerTool('remove_blackout_date', {
|
|
113
|
+
title: 'Remove Blackout Date',
|
|
114
|
+
annotations: {
|
|
115
|
+
readOnlyHint: false,
|
|
116
|
+
destructiveHint: true,
|
|
117
|
+
idempotentHint: false,
|
|
118
|
+
openWorldHint: false,
|
|
119
|
+
},
|
|
120
|
+
description: 'Remove a sending blackout date for this project by id. Use list_blackout_dates first to find the id.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
id: z.string().uuid(),
|
|
123
|
+
},
|
|
124
|
+
}, async ({ id }) => {
|
|
125
|
+
const { error, count } = await db()
|
|
126
|
+
.from('blackout_dates')
|
|
127
|
+
.delete({ count: 'exact' })
|
|
128
|
+
.eq('id', id)
|
|
129
|
+
.eq('project_id', getProjectId());
|
|
130
|
+
if (error)
|
|
131
|
+
return j({ error: error.message });
|
|
132
|
+
if (!count)
|
|
133
|
+
return j({ error: 'Blackout date not found', code: 'NOT_FOUND' });
|
|
134
|
+
return j({ removed: true, id });
|
|
135
|
+
});
|
|
6
136
|
server.registerTool('refresh_domain_dns', {
|
|
7
137
|
title: 'Refresh Domain DNS',
|
|
8
138
|
annotations: {
|
|
@@ -228,6 +358,199 @@ export function registerDeliveryOpsTools(server) {
|
|
|
228
358
|
return j({ error: error.message });
|
|
229
359
|
return j({ dlq: data ?? [], count: data?.length ?? 0 });
|
|
230
360
|
});
|
|
361
|
+
server.registerTool('get_enrollment_dlq', {
|
|
362
|
+
title: 'Get Enrollment DLQ',
|
|
363
|
+
annotations: {
|
|
364
|
+
readOnlyHint: true,
|
|
365
|
+
destructiveHint: false,
|
|
366
|
+
idempotentHint: true,
|
|
367
|
+
openWorldHint: false,
|
|
368
|
+
},
|
|
369
|
+
description: 'List campaign enrollments that failed every retry and stopped sending. Use replay_enrollment_dlq_rows to preview and re-enter them into active sending.',
|
|
370
|
+
inputSchema: {
|
|
371
|
+
site_id: z.string().uuid().optional(),
|
|
372
|
+
limit: z.number().int().min(1).max(50).default(20).optional(),
|
|
373
|
+
unresolved_only: z.boolean().default(true).optional(),
|
|
374
|
+
},
|
|
375
|
+
}, async ({ site_id, limit = 20, unresolved_only = true }) => {
|
|
376
|
+
const projectId = getProjectId();
|
|
377
|
+
let query = db().from('enrollment_dlq')
|
|
378
|
+
.select('id, enrollment_id, campaign_id, contact_id, enrollment_snapshot, final_error, attempts, created_at, resolved, resolved_at')
|
|
379
|
+
.eq('project_id', projectId)
|
|
380
|
+
.order('created_at', { ascending: false })
|
|
381
|
+
.limit(limit);
|
|
382
|
+
if (site_id)
|
|
383
|
+
query = query.eq('site_id', site_id);
|
|
384
|
+
if (unresolved_only)
|
|
385
|
+
query = query.eq('resolved', false);
|
|
386
|
+
const { data, error } = await query;
|
|
387
|
+
if (error)
|
|
388
|
+
return j({ error: error.message });
|
|
389
|
+
return j({ dlq: data ?? [], count: data?.length ?? 0 });
|
|
390
|
+
});
|
|
391
|
+
server.registerTool('get_send_queue_rows', {
|
|
392
|
+
title: 'Get Send Queue Rows',
|
|
393
|
+
annotations: {
|
|
394
|
+
readOnlyHint: true,
|
|
395
|
+
destructiveHint: false,
|
|
396
|
+
idempotentHint: true,
|
|
397
|
+
openWorldHint: false,
|
|
398
|
+
},
|
|
399
|
+
description: 'List individual queued sends with status, attempts, the latest error, and next retry time. Use this when aggregate queue counts do not explain which recipients are still waiting.',
|
|
400
|
+
inputSchema: {
|
|
401
|
+
site_id: z.string().uuid().optional(),
|
|
402
|
+
campaign_id: z.string().uuid().optional(),
|
|
403
|
+
status: z.array(z.enum(['pending', 'processing', 'retry'])).optional(),
|
|
404
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
405
|
+
},
|
|
406
|
+
}, async ({ site_id, campaign_id, status, limit = 50 }) => {
|
|
407
|
+
try {
|
|
408
|
+
const { rows } = await listSendQueueRows({
|
|
409
|
+
projectId: getProjectId(),
|
|
410
|
+
siteId: site_id,
|
|
411
|
+
campaignId: campaign_id,
|
|
412
|
+
status,
|
|
413
|
+
limit,
|
|
414
|
+
}, db());
|
|
415
|
+
return j({ rows, count: rows.length });
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
return j({ error: error instanceof Error ? error.message : 'Queue query failed' });
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
server.registerTool('retry_send_queue_rows', {
|
|
422
|
+
title: 'Retry Send Queue Rows',
|
|
423
|
+
annotations: {
|
|
424
|
+
readOnlyHint: false,
|
|
425
|
+
destructiveHint: false,
|
|
426
|
+
idempotentHint: true,
|
|
427
|
+
openWorldHint: false,
|
|
428
|
+
},
|
|
429
|
+
description: 'Make selected unclaimed retry rows eligible for the next processing tick without inserting another queue row or resetting attempt limits.',
|
|
430
|
+
inputSchema: {
|
|
431
|
+
site_id: z.string().uuid(),
|
|
432
|
+
campaign_id: z.string().uuid().optional(),
|
|
433
|
+
queue_ids: z.array(z.string().uuid()).min(1).max(200).optional(),
|
|
434
|
+
},
|
|
435
|
+
}, async ({ site_id, campaign_id, queue_ids }) => {
|
|
436
|
+
try {
|
|
437
|
+
const { retriedCount } = await retrySendQueueRows({
|
|
438
|
+
projectId: getProjectId(),
|
|
439
|
+
siteId: site_id,
|
|
440
|
+
campaignId: campaign_id,
|
|
441
|
+
queueIds: queue_ids,
|
|
442
|
+
}, db());
|
|
443
|
+
await logAudit({
|
|
444
|
+
action: 'send_queue.retry',
|
|
445
|
+
resourceType: 'site',
|
|
446
|
+
resourceId: site_id,
|
|
447
|
+
details: { retried_count: retriedCount, campaign_id: campaign_id ?? null },
|
|
448
|
+
});
|
|
449
|
+
return j({ retried_count: retriedCount });
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
return j({ error: error instanceof Error ? error.message : 'Queue retry failed' });
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
server.registerTool('cancel_send_queue_rows', {
|
|
456
|
+
title: 'Cancel Send Queue Rows',
|
|
457
|
+
annotations: {
|
|
458
|
+
readOnlyHint: false,
|
|
459
|
+
destructiveHint: true,
|
|
460
|
+
idempotentHint: true,
|
|
461
|
+
openWorldHint: false,
|
|
462
|
+
},
|
|
463
|
+
description: 'Cancel selected unclaimed pending or retry rows, mark their queued email logs failed, and release their enrollment claims. Rows held by a processor are not changed.',
|
|
464
|
+
inputSchema: {
|
|
465
|
+
site_id: z.string().uuid(),
|
|
466
|
+
campaign_id: z.string().uuid().optional(),
|
|
467
|
+
queue_ids: z.array(z.string().uuid()).min(1).max(200).optional(),
|
|
468
|
+
},
|
|
469
|
+
}, async ({ site_id, campaign_id, queue_ids }) => {
|
|
470
|
+
try {
|
|
471
|
+
const { cancelledCount } = await cancelSendQueueRows({
|
|
472
|
+
projectId: getProjectId(),
|
|
473
|
+
siteId: site_id,
|
|
474
|
+
campaignId: campaign_id,
|
|
475
|
+
queueIds: queue_ids,
|
|
476
|
+
}, db());
|
|
477
|
+
await logAudit({
|
|
478
|
+
action: 'send_queue.cancel',
|
|
479
|
+
resourceType: 'site',
|
|
480
|
+
resourceId: site_id,
|
|
481
|
+
details: { cancelled_count: cancelledCount, campaign_id: campaign_id ?? null },
|
|
482
|
+
});
|
|
483
|
+
return j({ cancelled_count: cancelledCount });
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
return j({ error: error instanceof Error ? error.message : 'Queue cancellation failed' });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
server.registerTool('replay_enrollment_dlq_rows', {
|
|
490
|
+
title: 'Replay Enrollment DLQ Rows',
|
|
491
|
+
annotations: {
|
|
492
|
+
readOnlyHint: false,
|
|
493
|
+
destructiveHint: false,
|
|
494
|
+
idempotentHint: false,
|
|
495
|
+
openWorldHint: false,
|
|
496
|
+
},
|
|
497
|
+
description: 'Re-enter dead-lettered campaign enrollments into active sending, which can cause real emails to send on the next queue tick. Call once without confirmed to preview the matched count, then call again with confirmed=true to replay them.',
|
|
498
|
+
inputSchema: {
|
|
499
|
+
site_id: z.string().uuid(),
|
|
500
|
+
max_count: z.number().int().min(1).max(1000).default(100).optional(),
|
|
501
|
+
failed_after: z.string().datetime().optional(),
|
|
502
|
+
confirmed: z.boolean().optional().default(false)
|
|
503
|
+
.describe('Two-call confirmation. First call previews the matched rows; confirmed=true replays them.'),
|
|
504
|
+
},
|
|
505
|
+
}, async ({ site_id, max_count = 100, failed_after, confirmed = false }) => {
|
|
506
|
+
const projectId = getProjectId();
|
|
507
|
+
if (!confirmed) {
|
|
508
|
+
try {
|
|
509
|
+
const { rows } = await findReplayableEnrollmentDlqRows({
|
|
510
|
+
projectId,
|
|
511
|
+
siteId: site_id,
|
|
512
|
+
maxCount: max_count,
|
|
513
|
+
failedAfter: failed_after,
|
|
514
|
+
}, db());
|
|
515
|
+
return j({
|
|
516
|
+
confirmation_required: true,
|
|
517
|
+
tool: 'replay_enrollment_dlq_rows',
|
|
518
|
+
site_id,
|
|
519
|
+
matched_count: rows.length,
|
|
520
|
+
preview: rows,
|
|
521
|
+
message: rows.length === 0
|
|
522
|
+
? 'No unresolved enrollment DLQ rows match this filter — nothing to replay.'
|
|
523
|
+
: `Replaying will re-enter ${rows.length} enrollment(s) into active sending, which can trigger real sends. Call again with confirmed=true to proceed.`,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
return j({ error: error instanceof Error ? error.message : 'Replay preview failed' });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
const { replayedCount, skippedCount } = await replayEnrollmentDlqRows({
|
|
532
|
+
projectId,
|
|
533
|
+
siteId: site_id,
|
|
534
|
+
maxCount: max_count,
|
|
535
|
+
failedAfter: failed_after,
|
|
536
|
+
}, db());
|
|
537
|
+
await logAudit({
|
|
538
|
+
action: 'enrollment_dlq.replay',
|
|
539
|
+
resourceType: 'site',
|
|
540
|
+
resourceId: site_id,
|
|
541
|
+
details: {
|
|
542
|
+
replayed_count: replayedCount,
|
|
543
|
+
skipped_count: skippedCount,
|
|
544
|
+
max_count,
|
|
545
|
+
failed_after: failed_after ?? null,
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
return j({ ok: true, replayed_count: replayedCount, skipped_count: skippedCount });
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
return j({ error: error instanceof Error ? error.message : 'Replay failed' });
|
|
552
|
+
}
|
|
553
|
+
});
|
|
231
554
|
server.registerTool('get_scoring_rules', {
|
|
232
555
|
title: 'Get Scoring Rules',
|
|
233
556
|
annotations: {
|
|
@@ -468,4 +791,188 @@ export function registerDeliveryOpsTools(server) {
|
|
|
468
791
|
return j({ error: linkErr.message });
|
|
469
792
|
return j({ ok: true, site_id: site.id, domain: created });
|
|
470
793
|
});
|
|
794
|
+
server.registerTool('get_dmarc_status', {
|
|
795
|
+
title: 'Get DMARC Status',
|
|
796
|
+
annotations: {
|
|
797
|
+
readOnlyHint: true,
|
|
798
|
+
destructiveHint: false,
|
|
799
|
+
idempotentHint: true,
|
|
800
|
+
openWorldHint: true,
|
|
801
|
+
},
|
|
802
|
+
description: "Return the inbound DMARC RUA address, Cloudflare publish setup state, and monitor state for this project (or a specific site's sending domain).",
|
|
803
|
+
inputSchema: {
|
|
804
|
+
site_id: z.string().uuid().optional()
|
|
805
|
+
.describe('Site to scope the sending-domain setup metadata to. Omit for the project default.'),
|
|
806
|
+
},
|
|
807
|
+
}, async ({ site_id }) => {
|
|
808
|
+
const projectId = getProjectId();
|
|
809
|
+
const central = adminDb();
|
|
810
|
+
const { data: sites, error: siteError } = await db().schema('email').from('sites')
|
|
811
|
+
.select('id, sending_domain').eq('active', true).eq('project_id', projectId).order('name');
|
|
812
|
+
if (siteError)
|
|
813
|
+
return j({ error: siteError.message });
|
|
814
|
+
const selectedSite = site_id ? (sites ?? []).find((site) => site.id === site_id) : null;
|
|
815
|
+
if (site_id && !selectedSite)
|
|
816
|
+
return j({ error: 'site_id is not a valid site for this project', status: 400 });
|
|
817
|
+
try {
|
|
818
|
+
const [{ data: project, error: projectError }, { data: monitors, error: monitorError }] = await Promise.all([
|
|
819
|
+
central.schema('email').from('projects').select('org_id').eq('id', projectId).maybeSingle(),
|
|
820
|
+
central.schema('email').from('dmarc_monitors').select('domain, status')
|
|
821
|
+
.eq('project_id', projectId).order('created_at', { ascending: false }),
|
|
822
|
+
]);
|
|
823
|
+
if (projectError)
|
|
824
|
+
throw new Error(projectError.message);
|
|
825
|
+
if (monitorError)
|
|
826
|
+
throw new Error(monitorError.message);
|
|
827
|
+
const token = await getOrProvisionDmarcInboundToken(projectId);
|
|
828
|
+
const sendingDomains = (sites ?? []).map((site) => String(site.sending_domain ?? '').trim().toLowerCase()).filter(Boolean);
|
|
829
|
+
const domain = selectedSite?.sending_domain ?? sendingDomains[0] ?? null;
|
|
830
|
+
const ns = domain ? await resolveNameservers(domain) : { nameservers: [], zone: null };
|
|
831
|
+
const dnsProvider = detectDnsProvider(ns.nameservers);
|
|
832
|
+
let cloudflareConnected = false;
|
|
833
|
+
if (project?.org_id && dnsProvider === 'cloudflare') {
|
|
834
|
+
const { data: connection, error: connectionError } = await central.schema('email')
|
|
835
|
+
.from('cloudflare_oauth_connections').select('id').eq('org_id', project.org_id).maybeSingle();
|
|
836
|
+
if (connectionError)
|
|
837
|
+
throw new Error(connectionError.message);
|
|
838
|
+
cloudflareConnected = Boolean(connection?.id);
|
|
839
|
+
}
|
|
840
|
+
const monitorRows = (monitors ?? []).map((monitor) => ({
|
|
841
|
+
domain: monitor.domain,
|
|
842
|
+
status: monitor.status,
|
|
843
|
+
rua_record: 'v=DMARC1; p=none; rua=mailto:dmarc@sendinel.ai',
|
|
844
|
+
report_destination: 'dmarc@sendinel.ai',
|
|
845
|
+
}));
|
|
846
|
+
return j({
|
|
847
|
+
inbound_address: `dmarc+${token}@sendinel.ai`,
|
|
848
|
+
setup: {
|
|
849
|
+
domain,
|
|
850
|
+
cloudflare_connected: cloudflareConnected,
|
|
851
|
+
can_publish_cloudflare: cloudflareConnected,
|
|
852
|
+
dns_provider: dnsProvider,
|
|
853
|
+
dns_zone: ns.zone,
|
|
854
|
+
},
|
|
855
|
+
monitors: selectedSite?.sending_domain
|
|
856
|
+
? monitorRows.filter((monitor) => monitor.domain === selectedSite.sending_domain)
|
|
857
|
+
: monitorRows,
|
|
858
|
+
auth_record_errors: [],
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
return j({ error: error instanceof Error ? error.message : 'DMARC status query failed' });
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
server.registerTool('list_dmarc_reports', {
|
|
866
|
+
title: 'List DMARC Reports',
|
|
867
|
+
annotations: {
|
|
868
|
+
readOnlyHint: true,
|
|
869
|
+
destructiveHint: false,
|
|
870
|
+
idempotentHint: true,
|
|
871
|
+
openWorldHint: true,
|
|
872
|
+
},
|
|
873
|
+
description: 'List aggregate DMARC (RUA) report summaries ingested for this project. Raw XML is excluded.',
|
|
874
|
+
inputSchema: {
|
|
875
|
+
site_id: z.string().uuid().optional(),
|
|
876
|
+
domain: z.string().optional().describe('Filter to a policy_domain from the reports.'),
|
|
877
|
+
start_date: z.string().datetime().optional(),
|
|
878
|
+
end_date: z.string().datetime().optional(),
|
|
879
|
+
cursor: z.string().optional().describe('created_at cursor from a previous page.'),
|
|
880
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
881
|
+
},
|
|
882
|
+
}, async ({ site_id, domain, start_date, end_date, cursor, limit }) => {
|
|
883
|
+
const pageLimit = limit ?? 50;
|
|
884
|
+
let query = db().schema('email').from('dmarc_reports')
|
|
885
|
+
.select('id, site_id, org_name, report_id, period_begin, period_end, policy_domain, policy_p, policy_sp, policy_pct, total_messages, pass_count, fail_count, created_at')
|
|
886
|
+
.eq('project_id', getProjectId())
|
|
887
|
+
.order('created_at', { ascending: false })
|
|
888
|
+
.limit(pageLimit + 1);
|
|
889
|
+
if (site_id)
|
|
890
|
+
query = query.eq('site_id', site_id);
|
|
891
|
+
if (domain)
|
|
892
|
+
query = query.eq('policy_domain', domain);
|
|
893
|
+
if (start_date)
|
|
894
|
+
query = query.gte('period_begin', new Date(start_date).toISOString());
|
|
895
|
+
if (end_date)
|
|
896
|
+
query = query.lte('period_begin', new Date(end_date).toISOString());
|
|
897
|
+
if (cursor)
|
|
898
|
+
query = query.lt('created_at', cursor);
|
|
899
|
+
const { data, error } = await query;
|
|
900
|
+
if (error)
|
|
901
|
+
return j({ error: 'Query failed', status: 500 });
|
|
902
|
+
const rows = (data ?? []);
|
|
903
|
+
const hasMore = rows.length > pageLimit;
|
|
904
|
+
const reports = hasMore ? rows.slice(0, pageLimit) : rows;
|
|
905
|
+
return j({
|
|
906
|
+
reports,
|
|
907
|
+
pagination: {
|
|
908
|
+
cursor: hasMore && reports.length > 0 ? reports[reports.length - 1].created_at : null,
|
|
909
|
+
has_more: hasMore,
|
|
910
|
+
limit: pageLimit,
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
server.registerTool('publish_dmarc_record', {
|
|
915
|
+
title: 'Publish DMARC Record',
|
|
916
|
+
annotations: {
|
|
917
|
+
readOnlyHint: false,
|
|
918
|
+
destructiveHint: true,
|
|
919
|
+
idempotentHint: true,
|
|
920
|
+
openWorldHint: true,
|
|
921
|
+
},
|
|
922
|
+
description: "Merge Sendinel's inbound RUA address into a sending domain's _dmarc TXT record via the organization's connected Cloudflare zone. Previews the exact merged record before writing; writing requires approval.",
|
|
923
|
+
inputSchema: {
|
|
924
|
+
domain: z.string().min(1)
|
|
925
|
+
.describe('Active sending domain for this project, managed by a connected Cloudflare zone.'),
|
|
926
|
+
confirmed: z.boolean().optional().default(false)
|
|
927
|
+
.describe('Two-call confirmation. First call previews the merged record, second call with confirmed=true writes it.'),
|
|
928
|
+
approval_id: z.string().uuid().optional()
|
|
929
|
+
.describe('Approval token from a previous confirmed call without approval_id.'),
|
|
930
|
+
},
|
|
931
|
+
}, async ({ domain, confirmed, approval_id }) => {
|
|
932
|
+
const approvalParams = { domain };
|
|
933
|
+
const preview = await callSendinelV1('/api/v1/dmarc/publish-cloudflare', {
|
|
934
|
+
method: 'POST',
|
|
935
|
+
body: { domain, dry_run: true },
|
|
936
|
+
});
|
|
937
|
+
if (!preview.ok) {
|
|
938
|
+
return j({ error: v1ErrorMessage(preview.error), status: preview.status });
|
|
939
|
+
}
|
|
940
|
+
const previewBody = asRecord(preview.data);
|
|
941
|
+
if (!confirmed) {
|
|
942
|
+
return j({
|
|
943
|
+
confirmation_required: true,
|
|
944
|
+
tool: 'publish_dmarc_record',
|
|
945
|
+
domain,
|
|
946
|
+
dns_zone: previewBody.dns_zone,
|
|
947
|
+
record_name: previewBody.record_name,
|
|
948
|
+
record_content: previewBody.record_content,
|
|
949
|
+
message: `Publishing will write this exact TXT record for ${previewBody.record_name} in the organization's Cloudflare zone. Call again with confirmed=true to proceed.`,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
const channelContext = currentChannelContext();
|
|
953
|
+
const decision = assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
|
|
954
|
+
if (!decision.allowed)
|
|
955
|
+
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
956
|
+
if (decision.tier !== 'autonomous') {
|
|
957
|
+
if (!approval_id) {
|
|
958
|
+
const approval = await requestApproval('publish_dmarc_record', approvalParams, undefined, channelContext);
|
|
959
|
+
return j(approval);
|
|
960
|
+
}
|
|
961
|
+
const check = await checkApproval(approval_id, {
|
|
962
|
+
consume: true,
|
|
963
|
+
action: 'publish_dmarc_record',
|
|
964
|
+
params: approvalParams,
|
|
965
|
+
});
|
|
966
|
+
if (!check.approved) {
|
|
967
|
+
return j({ error: `Approval ${approval_id} is ${check.status}.`, status: check.status });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const result = await callSendinelV1('/api/v1/dmarc/publish-cloudflare', {
|
|
971
|
+
method: 'POST',
|
|
972
|
+
body: { domain, dry_run: false },
|
|
973
|
+
});
|
|
974
|
+
if (!result.ok)
|
|
975
|
+
return j({ error: v1ErrorMessage(result.error), status: result.status });
|
|
976
|
+
return j(result.data);
|
|
977
|
+
});
|
|
471
978
|
}
|
package/build/tools/forms.js
CHANGED
|
@@ -11,10 +11,10 @@ export function registerFormTools(server) {
|
|
|
11
11
|
idempotentHint: true,
|
|
12
12
|
openWorldHint: false,
|
|
13
13
|
},
|
|
14
|
-
description: 'List
|
|
14
|
+
description: 'List forms with destination, field, and submission counts.',
|
|
15
15
|
inputSchema: { site_id: z.string().uuid().optional() },
|
|
16
16
|
}, async ({ site_id }) => {
|
|
17
|
-
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
17
|
+
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, form_type, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
18
18
|
if (site_id)
|
|
19
19
|
query = query.eq('site_id', site_id);
|
|
20
20
|
const { data, error } = await query;
|
|
@@ -58,19 +58,20 @@ export function registerFormTools(server) {
|
|
|
58
58
|
idempotentHint: false,
|
|
59
59
|
openWorldHint: false,
|
|
60
60
|
},
|
|
61
|
-
description:
|
|
61
|
+
description: "Create an embeddable form. destination 'signup' (default) creates/updates a subscriber on submission; destination 'management' looks up an existing contact by the submitted email and merges the other submitted fields into that contact's custom_properties -- it never creates a new contact.",
|
|
62
62
|
inputSchema: {
|
|
63
63
|
site_id: z.string().uuid(),
|
|
64
64
|
name: z.string().min(1),
|
|
65
65
|
fields: z.array(z.record(z.string(), z.unknown())),
|
|
66
66
|
redirect_url: z.string().url().optional(),
|
|
67
67
|
double_opt_in: z.boolean().optional(),
|
|
68
|
+
form_type: z.enum(['signup', 'management']).optional(),
|
|
68
69
|
},
|
|
69
|
-
}, async ({ site_id, name, fields, redirect_url, double_opt_in }) => {
|
|
70
|
+
}, async ({ site_id, name, fields, redirect_url, double_opt_in, form_type }) => {
|
|
70
71
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
71
72
|
const { data, error } = await db().from('signup_forms')
|
|
72
|
-
.insert({ project_id: getProjectId(), site_id, name, slug, fields, settings: { redirect_url: redirect_url ?? null, double_opt_in: double_opt_in ?? false }, status: 'active' })
|
|
73
|
-
.select('id, name, slug, fields, status')
|
|
73
|
+
.insert({ project_id: getProjectId(), site_id, name, slug, fields, settings: { redirect_url: redirect_url ?? null, double_opt_in: double_opt_in ?? false }, form_type: form_type ?? 'signup', status: 'active' })
|
|
74
|
+
.select('id, name, slug, fields, status, form_type')
|
|
74
75
|
.single();
|
|
75
76
|
if (error)
|
|
76
77
|
return j({ error: error.message });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { getRegisteredToolGroups, registerAllTools } from '../server.js';
|
|
6
|
+
const textResult = (text) => ({ content: [{ type: 'text', text }] });
|
|
7
|
+
const jsonResult = (data) => textResult(JSON.stringify(data, null, 2));
|
|
8
|
+
const notFound = (message) => ({
|
|
9
|
+
isError: true,
|
|
10
|
+
content: [{ type: 'text', text: message }],
|
|
11
|
+
});
|
|
12
|
+
async function buildCatalog(options) {
|
|
13
|
+
const innerServer = new McpServer({ name: 'sendinel-gateway-inner', version: '1.0.0' });
|
|
14
|
+
registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups });
|
|
15
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
16
|
+
const client = new Client({ name: 'sendinel-gateway', version: '1.0.0' });
|
|
17
|
+
await innerServer.connect(serverTransport);
|
|
18
|
+
await client.connect(clientTransport);
|
|
19
|
+
const toolGroupMap = getRegisteredToolGroups({ scopes: options.scopes, toolGroups: options.toolGroups });
|
|
20
|
+
const { tools } = await client.listTools();
|
|
21
|
+
const entries = tools
|
|
22
|
+
.map((tool) => ({
|
|
23
|
+
name: tool.name,
|
|
24
|
+
group: toolGroupMap[tool.name] ?? 'unknown',
|
|
25
|
+
description: tool.description ?? '',
|
|
26
|
+
inputSchema: tool.inputSchema,
|
|
27
|
+
}))
|
|
28
|
+
.sort((a, b) => {
|
|
29
|
+
const groupCompare = a.group.localeCompare(b.group);
|
|
30
|
+
return groupCompare !== 0 ? groupCompare : a.name.localeCompare(b.name);
|
|
31
|
+
});
|
|
32
|
+
return { client, entries, byName: new Map(entries.map((entry) => [entry.name, entry])) };
|
|
33
|
+
}
|
|
34
|
+
export function registerGatewayTools(server, options) {
|
|
35
|
+
let catalogPromise = null;
|
|
36
|
+
const getCatalog = () => (catalogPromise ??= buildCatalog(options));
|
|
37
|
+
server.registerTool('search_tools', {
|
|
38
|
+
title: 'Search Tools',
|
|
39
|
+
description: 'Search the permitted Sendinel MCP tool catalog by keyword and/or group. Returns name, group, and description. Call describe_tool for the full input schema before invoke_tool.',
|
|
40
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
41
|
+
inputSchema: {
|
|
42
|
+
query: z.string().optional().describe('Keyword to match against tool name, group, or description'),
|
|
43
|
+
group: z.string().optional().describe('Restrict results to one tool group, for example campaigns'),
|
|
44
|
+
},
|
|
45
|
+
}, async ({ query, group }) => {
|
|
46
|
+
const catalog = await getCatalog();
|
|
47
|
+
const q = query?.trim().toLowerCase() ?? '';
|
|
48
|
+
const requestedGroup = group?.trim();
|
|
49
|
+
const results = catalog.entries.filter((tool) => {
|
|
50
|
+
if (requestedGroup && tool.group !== requestedGroup)
|
|
51
|
+
return false;
|
|
52
|
+
if (!q)
|
|
53
|
+
return true;
|
|
54
|
+
return (tool.name.toLowerCase().includes(q) ||
|
|
55
|
+
tool.group.toLowerCase().includes(q) ||
|
|
56
|
+
tool.description.toLowerCase().includes(q));
|
|
57
|
+
});
|
|
58
|
+
return jsonResult({
|
|
59
|
+
tools: results.map(({ name, group, description }) => ({ name, group, description })),
|
|
60
|
+
total: results.length,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
server.registerTool('describe_tool', {
|
|
64
|
+
title: 'Describe Tool',
|
|
65
|
+
description: 'Return one permitted Sendinel MCP tool input schema by name. Call this before invoke_tool so arguments match the real signature.',
|
|
66
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
67
|
+
inputSchema: {
|
|
68
|
+
name: z.string().min(1).describe('Tool name to describe'),
|
|
69
|
+
},
|
|
70
|
+
}, async ({ name }) => {
|
|
71
|
+
const catalog = await getCatalog();
|
|
72
|
+
const entry = catalog.byName.get(name);
|
|
73
|
+
if (!entry)
|
|
74
|
+
return notFound(`Tool not found: ${name}`);
|
|
75
|
+
return jsonResult(entry);
|
|
76
|
+
});
|
|
77
|
+
server.registerTool('invoke_tool', {
|
|
78
|
+
title: 'Invoke Tool',
|
|
79
|
+
description: 'Execute one permitted Sendinel MCP tool by name with the same scope and approval enforcement as calling it directly. Use search_tools and describe_tool first.',
|
|
80
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
81
|
+
inputSchema: {
|
|
82
|
+
name: z.string().min(1).describe('Tool name to invoke'),
|
|
83
|
+
args: z.record(z.string(), z.unknown()).optional().default({}).describe('Arguments for the target tool'),
|
|
84
|
+
},
|
|
85
|
+
}, async ({ name, args }) => {
|
|
86
|
+
const catalog = await getCatalog();
|
|
87
|
+
if (!catalog.byName.has(name)) {
|
|
88
|
+
return notFound(`Tool not found or not permitted: ${name}`);
|
|
89
|
+
}
|
|
90
|
+
return await catalog.client.callTool({ name, arguments: args ?? {} });
|
|
91
|
+
});
|
|
92
|
+
}
|