@sendinel/mcp-server 1.0.141 → 1.0.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/lib/action-approvals.d.ts +2 -0
- package/build/lib/action-approvals.js +6 -4
- package/build/lib/sandbox-audience.d.ts +30 -0
- package/build/lib/sandbox-audience.js +48 -0
- package/build/tools/ab-testing.js +9 -2
- package/build/tools/approvals.js +2 -2
- package/build/tools/campaigns.js +24 -8
- package/package.json +1 -1
|
@@ -50,6 +50,8 @@ export interface ApprovalResult {
|
|
|
50
50
|
summary: string;
|
|
51
51
|
expires_at: string;
|
|
52
52
|
approve_via: string;
|
|
53
|
+
decided_via?: 'sandbox';
|
|
54
|
+
decided_by?: null;
|
|
53
55
|
/** One-tap approval page; works on a phone and needs no project switching (#3661). */
|
|
54
56
|
approve_url: string;
|
|
55
57
|
preview_text: string;
|
|
@@ -137,7 +137,7 @@ export function approvalExcerpt(html, max = 200) {
|
|
|
137
137
|
function broadcastSummary(d) {
|
|
138
138
|
if (!d.subject)
|
|
139
139
|
return null;
|
|
140
|
-
const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : null;
|
|
140
|
+
const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : d.recipient_count === null ? 'unknown recipients' : null;
|
|
141
141
|
const audience = d.segment_name ? `segment "${d.segment_name}"` : (d.audience_label ? String(d.audience_label) : 'the chosen audience');
|
|
142
142
|
const when = d.send_at ? ` at ${d.send_at}` : '';
|
|
143
143
|
const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
|
|
@@ -323,11 +323,13 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
323
323
|
}
|
|
324
324
|
catch { /* Missing display data omits the project line. */ }
|
|
325
325
|
const approveUrl = approvalPageUrl(data.id);
|
|
326
|
-
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary;
|
|
326
|
+
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName, ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) }).plain_summary;
|
|
327
327
|
if (sandboxApproved) {
|
|
328
328
|
return { requires_approval: true, approval_id: data.id, tier, action, summary, expires_at: expiresAt,
|
|
329
|
-
approve_via: 'sandbox', approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
330
|
-
message:
|
|
329
|
+
approve_via: 'sandbox', decided_via: 'sandbox', decided_by: null, approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
330
|
+
message: action === 'send_broadcast'
|
|
331
|
+
? 'Sandbox project: approved automatically (no person decided it). Call send_broadcast again with only approval_id to send.'
|
|
332
|
+
: 'Sandbox project: approved automatically\nCall this tool again with the same arguments plus approval_id to run it.' };
|
|
331
333
|
}
|
|
332
334
|
const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
|
|
333
335
|
? `\nAsk the user in plain chat: "${previewText.split('\n')[0].trim()}". Only after they say yes, call confirm_approval with approval_id, action, echo (the first line of preview_text) and user_reply (their exact words), then call the original tool again with approval_id.`
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Display only: never narrow or otherwise change the frozen enrollment payload. */
|
|
2
|
+
export declare function sandboxAudience(projectId: string, contactIds: string[]): Promise<{
|
|
3
|
+
recipient_count: number;
|
|
4
|
+
audience_matched_count?: undefined;
|
|
5
|
+
sandbox?: undefined;
|
|
6
|
+
} | {
|
|
7
|
+
recipient_count: number;
|
|
8
|
+
audience_matched_count: number;
|
|
9
|
+
sandbox: {
|
|
10
|
+
active: true;
|
|
11
|
+
allowed_domains: string[] | null;
|
|
12
|
+
filtered_out: number;
|
|
13
|
+
count_unknown?: undefined;
|
|
14
|
+
};
|
|
15
|
+
} | {
|
|
16
|
+
recipient_count: null;
|
|
17
|
+
audience_matched_count: number;
|
|
18
|
+
sandbox: {
|
|
19
|
+
active: boolean | null;
|
|
20
|
+
allowed_domains: string[] | null;
|
|
21
|
+
filtered_out: null;
|
|
22
|
+
count_unknown: boolean;
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
25
|
+
export declare function broadcastApprovalNext(approval: {
|
|
26
|
+
approve_via?: string;
|
|
27
|
+
decision_status?: string;
|
|
28
|
+
}, sandbox?: {
|
|
29
|
+
allowed_domains: string[] | null;
|
|
30
|
+
}): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { chunkIds, isSandboxDomain, matchSandboxDomains, readAllRows } from '@sendinel/domain';
|
|
2
|
+
import { adminDb, db } from '../db.js';
|
|
3
|
+
/** Display only: never narrow or otherwise change the frozen enrollment payload. */
|
|
4
|
+
export async function sandboxAudience(projectId, contactIds) {
|
|
5
|
+
let active = null;
|
|
6
|
+
let allowedDomains = null;
|
|
7
|
+
try {
|
|
8
|
+
const { data, error } = await adminDb().schema('email').from('projects')
|
|
9
|
+
.select('sandbox_mode, sandbox_allowed_domains').eq('id', projectId).maybeSingle();
|
|
10
|
+
if (error || !data || typeof data.sandbox_mode !== 'boolean')
|
|
11
|
+
throw new Error('Sandbox policy unavailable');
|
|
12
|
+
active = data.sandbox_mode;
|
|
13
|
+
if (!active)
|
|
14
|
+
return { recipient_count: contactIds.length };
|
|
15
|
+
if (!Array.isArray(data.sandbox_allowed_domains) || !data.sandbox_allowed_domains.every(isSandboxDomain)) {
|
|
16
|
+
throw new Error('Sandbox domains unavailable');
|
|
17
|
+
}
|
|
18
|
+
allowedDomains = data.sandbox_allowed_domains;
|
|
19
|
+
const emails = new Map();
|
|
20
|
+
for (const chunk of chunkIds(contactIds)) {
|
|
21
|
+
const rows = await readAllRows(db().schema('email').from('subscribers').select('id, email').eq('project_id', projectId).in('id', chunk));
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
if (typeof row.email !== 'string')
|
|
24
|
+
throw new Error('Contact email unavailable');
|
|
25
|
+
emails.set(row.id, row.email);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (contactIds.some(id => !emails.has(id)))
|
|
29
|
+
throw new Error('Contact lookup incomplete');
|
|
30
|
+
const count = contactIds.filter(id => !matchSandboxDomains(allowedDomains, { to: emails.get(id) })).length;
|
|
31
|
+
return { recipient_count: count, audience_matched_count: contactIds.length,
|
|
32
|
+
sandbox: { active, allowed_domains: allowedDomains, filtered_out: contactIds.length - count } };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { recipient_count: null, audience_matched_count: contactIds.length,
|
|
36
|
+
sandbox: { active, allowed_domains: allowedDomains, filtered_out: null, count_unknown: true } };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function broadcastApprovalNext(approval, sandbox) {
|
|
40
|
+
if (approval.approve_via === 'sandbox' && approval.decision_status === 'approved') {
|
|
41
|
+
const domains = sandbox?.allowed_domains;
|
|
42
|
+
return 'Sandbox project: this was approved automatically (no person decided it). Call send_broadcast again with only approval_id to send. ' +
|
|
43
|
+
(domains ? domains.length ? `Only recipients on ${domains.join(', ')} can be delivered, subject to send-time policy checks.` : 'No sandbox domains are allowed; no recipients can be delivered.'
|
|
44
|
+
: 'Sandbox domains could not be loaded; the deliverable recipient count is unknown.') +
|
|
45
|
+
' Do not say it was sent before that call succeeds.';
|
|
46
|
+
}
|
|
47
|
+
return 'Show the user the subject, recipient count and approve_url. After a person approves, call send_broadcast with only approval_id. Do not say it was sent before that call succeeds.';
|
|
48
|
+
}
|
|
@@ -13,7 +13,7 @@ export function registerAbTestingTools(server) {
|
|
|
13
13
|
idempotentHint: false,
|
|
14
14
|
openWorldHint: false,
|
|
15
15
|
},
|
|
16
|
-
description: 'Create an A/B test for a campaign step. Requires at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
|
|
16
|
+
description: 'Create an A/B test for a campaign step. Requires a non-blank subject and body on every variant, and at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
|
|
17
17
|
inputSchema: {
|
|
18
18
|
campaign_id: z.string().uuid(),
|
|
19
19
|
step_order: z.number().int().min(0).optional().default(0).describe('Which step to test (default: 0, the first email)'),
|
|
@@ -38,13 +38,20 @@ export function registerAbTestingTools(server) {
|
|
|
38
38
|
// Check variant steps exist
|
|
39
39
|
const { data: steps } = await db()
|
|
40
40
|
.from('campaign_steps')
|
|
41
|
-
.select('id, variant_label, subject')
|
|
41
|
+
.select('id, variant_label, subject, body_html, body_text')
|
|
42
42
|
.eq('campaign_id', campaign_id)
|
|
43
43
|
.eq('step_order', step_order ?? 0);
|
|
44
44
|
const variantLabels = (steps ?? []).map((s) => s.variant_label);
|
|
45
45
|
if (variantLabels.length < 2) {
|
|
46
46
|
return txt(`Error: Need at least 2 variant steps for step_order ${step_order ?? 0}. Found: [${variantLabels.join(', ')}]. Add more variants via add_campaign_step with variant_label parameter.`);
|
|
47
47
|
}
|
|
48
|
+
const emptyVariants = (steps ?? []).filter((step) => !step.subject?.trim() || !(step.body_html?.trim() || step.body_text?.trim()));
|
|
49
|
+
if (emptyVariants.length) {
|
|
50
|
+
return j({
|
|
51
|
+
error: `Variant(s) [${emptyVariants.map((step) => step.variant_label).join(', ')}] for step_order ${step_order ?? 0} need a non-blank subject and body. Add content with add_campaign_step before creating the test.`,
|
|
52
|
+
code: 'validation_failed',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
48
55
|
// Check no existing test for this step
|
|
49
56
|
const { data: existing } = await db()
|
|
50
57
|
.from('ab_tests')
|
package/build/tools/approvals.js
CHANGED
|
@@ -48,7 +48,7 @@ export function registerApprovalTools(server, options) {
|
|
|
48
48
|
}, async () => {
|
|
49
49
|
const { data, error } = await db()
|
|
50
50
|
.from('action_approvals')
|
|
51
|
-
.select('id, action, tier, channel, summary, status, requested_at, expires_at')
|
|
51
|
+
.select('id, action, tier, channel, summary, status, requested_at, expires_at, decided_via, decided_by')
|
|
52
52
|
.eq('project_id', getProjectId())
|
|
53
53
|
.eq('status', 'pending')
|
|
54
54
|
.gt('expires_at', new Date().toISOString())
|
|
@@ -56,6 +56,6 @@ export function registerApprovalTools(server, options) {
|
|
|
56
56
|
.limit(25);
|
|
57
57
|
if (error)
|
|
58
58
|
return toolError(dbToolError(error));
|
|
59
|
-
return j({ pending: data ?? [], count: data?.length ?? 0 });
|
|
59
|
+
return j({ pending: (data ?? []).map((row) => ({ ...row, ...(row.decided_via === 'sandbox' ? { decision_channel: 'sandbox' } : {}) })), count: data?.length ?? 0 });
|
|
60
60
|
});
|
|
61
61
|
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -18,6 +18,7 @@ import { checkPlanLimit } from '../plan-limits.js';
|
|
|
18
18
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
19
19
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
20
20
|
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
21
|
+
import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
|
|
21
22
|
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
22
23
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
23
24
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
@@ -137,6 +138,9 @@ export function buildCampaignTriggerDiagnostics(campaign, seenEvents) {
|
|
|
137
138
|
if ((campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event') && (events.length || tags.length) && campaign.type === 'triggered') {
|
|
138
139
|
diagnostics.push(`trigger_type is "${String(campaign.trigger_type)}" but event/tag triggers are also set; event intake will start it immediately, outside the ${String(campaign.trigger_type)} schedule`);
|
|
139
140
|
}
|
|
141
|
+
if (campaign.type === 'triggered' && campaignTriggerKind(campaign) === 'manual' && !campaign.segment_id) {
|
|
142
|
+
diagnostics.push('campaign type is "triggered" but no trigger is set (it will never fire)');
|
|
143
|
+
}
|
|
140
144
|
if (campaign.status !== 'active') {
|
|
141
145
|
diagnostics.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
|
|
142
146
|
}
|
|
@@ -855,7 +859,7 @@ export function registerCampaignTools(server) {
|
|
|
855
859
|
.single();
|
|
856
860
|
if (error)
|
|
857
861
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
858
|
-
return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION }, null, 2) }] };
|
|
862
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) }, null, 2) }] };
|
|
859
863
|
});
|
|
860
864
|
const propertyConditions = z.array(z.union([
|
|
861
865
|
z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
|
|
@@ -869,7 +873,7 @@ export function registerCampaignTools(server) {
|
|
|
869
873
|
idempotentHint: false,
|
|
870
874
|
openWorldHint: false,
|
|
871
875
|
},
|
|
872
|
-
description: 'Add an email step to a campaign. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
|
|
876
|
+
description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
|
|
873
877
|
inputSchema: {
|
|
874
878
|
campaign_id: z.string().uuid(),
|
|
875
879
|
step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
|
|
@@ -892,6 +896,16 @@ export function registerCampaignTools(server) {
|
|
|
892
896
|
const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
|
|
893
897
|
if (!properties.ok)
|
|
894
898
|
return j({ error: properties.error, code: "INVALID_INPUT" });
|
|
899
|
+
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
900
|
+
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
901
|
+
if (!subject.trim())
|
|
902
|
+
return j({ error: 'A step needs a non-blank subject.', code: 'validation_failed' });
|
|
903
|
+
const hasBody = body_html?.trim() || body_text?.trim()
|
|
904
|
+
|| htmlFromBlocks?.replace(/<[^>]*>/g, '').trim()
|
|
905
|
+
|| normalizedBlocks?.some((block) => block.type === 'image' && block.url);
|
|
906
|
+
if (!hasBody) {
|
|
907
|
+
return j({ error: 'A step needs body_html or body_text (or blocks that render to content).', code: 'validation_failed' });
|
|
908
|
+
}
|
|
895
909
|
const { data: campaign, error: campaignError } = await db()
|
|
896
910
|
.schema('email')
|
|
897
911
|
.from('campaigns')
|
|
@@ -911,8 +925,6 @@ export function registerCampaignTools(server) {
|
|
|
911
925
|
renderVars.survey_link = surveyLink;
|
|
912
926
|
}
|
|
913
927
|
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
914
|
-
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
915
|
-
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
916
928
|
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
917
929
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
918
930
|
const { data, error } = await db()
|
|
@@ -975,12 +987,15 @@ export function registerCampaignTools(server) {
|
|
|
975
987
|
// Placeholder text and links to the sending domain never go out (#3853).
|
|
976
988
|
const { data: campRow, error: campErr } = await db()
|
|
977
989
|
.from('campaigns')
|
|
978
|
-
.select('id, name, site_id, trigger_event_name')
|
|
990
|
+
.select('id, name, site_id, type, trigger_type, trigger_event_name, trigger_tag, trigger_config, segment_id')
|
|
979
991
|
.eq('id', campaign_id)
|
|
980
992
|
.eq('project_id', getProjectId())
|
|
981
993
|
.maybeSingle();
|
|
982
994
|
if (campErr)
|
|
983
995
|
return j({ error: `Could not check the campaign: ${campErr.message}`, code: 'CAMPAIGN_LOOKUP_FAILED' });
|
|
996
|
+
if (campRow?.type === 'triggered' && campaignTriggerKind(campRow) === 'manual' && !campRow.segment_id) {
|
|
997
|
+
return j({ error: 'This triggered campaign has no trigger. Set an event, tag, date, contact event, or segment before activating it.', code: 'validation_failed' });
|
|
998
|
+
}
|
|
984
999
|
let contentWarnings = [];
|
|
985
1000
|
if (campRow) {
|
|
986
1001
|
const { data: contentSteps, error: contentErr } = await db()
|
|
@@ -1564,6 +1579,7 @@ export function registerCampaignTools(server) {
|
|
|
1564
1579
|
else {
|
|
1565
1580
|
content = { subject: subject, body_html: html, body_text: body_text ?? approvalExcerpt(html, 100000), blocks: null };
|
|
1566
1581
|
}
|
|
1582
|
+
const delivery = await sandboxAudience(projectId, resolved.contactIds);
|
|
1567
1583
|
const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
|
|
1568
1584
|
const payload = {
|
|
1569
1585
|
site_id: site.id,
|
|
@@ -1589,18 +1605,18 @@ export function registerCampaignTools(server) {
|
|
|
1589
1605
|
subject: content.subject,
|
|
1590
1606
|
segment_name: segment_id ? segmentName : null,
|
|
1591
1607
|
audience_label: segment_id ? null : `"${segmentName}"`,
|
|
1592
|
-
recipient_count:
|
|
1608
|
+
recipient_count: delivery.recipient_count,
|
|
1593
1609
|
body_excerpt: approvalExcerpt(content.body_html),
|
|
1594
1610
|
send_at: sendAt,
|
|
1595
1611
|
});
|
|
1596
1612
|
return j({
|
|
1597
1613
|
...approval,
|
|
1598
1614
|
rendered: { subject: content.subject, body_html: stripActiveEmailContent(content.body_html).html, body_text: content.body_text },
|
|
1599
|
-
|
|
1615
|
+
...delivery,
|
|
1600
1616
|
audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
|
|
1601
1617
|
site: { id: site.id, name: site.name, from_email: site.from_email },
|
|
1602
1618
|
send_at: sendAt ?? 'right after approval',
|
|
1603
|
-
next:
|
|
1619
|
+
next: broadcastApprovalNext(approval, delivery.sandbox),
|
|
1604
1620
|
});
|
|
1605
1621
|
});
|
|
1606
1622
|
server.registerTool('clone_campaign', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.143",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|