@sendinel/mcp-server 1.0.63 → 1.0.65
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 +22 -0
- package/build/lib/action-approvals.js +94 -1
- package/build/lib/approval-exempt.d.ts +13 -0
- package/build/lib/approval-exempt.js +134 -0
- package/build/server.d.ts +9 -0
- package/build/server.js +27 -3
- package/build/tools/ab-testing.js +4 -2
- package/build/tools/analytics.js +4 -2
- package/build/tools/automations.js +6 -3
- package/build/tools/brand-kit.js +4 -2
- package/build/tools/campaigns.js +23 -18
- package/build/tools/contacts.js +17 -13
- package/build/tools/content.js +7 -5
- package/build/tools/data.js +13 -10
- package/build/tools/delivery-ops.js +4 -3
- package/build/tools/landing-pages.js +7 -5
- package/build/tools/lists.js +4 -4
- package/build/tools/newsletters.js +8 -5
- package/build/tools/org.js +13 -10
- package/build/tools/segments.js +4 -2
- package/build/tools/signatures.js +4 -3
- package/build/tools/sms.js +11 -7
- package/build/tools/social-posts.js +4 -3
- package/build/tools/social-replies.js +4 -3
- package/build/tools/templates.js +4 -3
- package/build/tools/webhooks.js +10 -6
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -12,7 +12,7 @@ import { getProjectId } from '../project.js';
|
|
|
12
12
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
13
13
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
14
14
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
15
|
-
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
15
|
+
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
16
16
|
import { resolveSendAudience } from '../lib/send-audience.js';
|
|
17
17
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
18
18
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
@@ -873,11 +873,12 @@ export function registerCampaignTools(server) {
|
|
|
873
873
|
},
|
|
874
874
|
description: 'Enroll a single contact in a campaign',
|
|
875
875
|
inputSchema: {
|
|
876
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
876
877
|
campaign_id: z.string().uuid(),
|
|
877
878
|
contact_id: z.string().uuid(),
|
|
878
879
|
next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
|
|
879
880
|
},
|
|
880
|
-
}, async ({ campaign_id, contact_id, next_send_at }) => {
|
|
881
|
+
}, withApproval('enroll_contact', async ({ campaign_id, contact_id, next_send_at }) => {
|
|
881
882
|
const { data: contact, error: contactError } = await db()
|
|
882
883
|
.from('subscribers')
|
|
883
884
|
.select('id, global_unsubscribed, cooled_at')
|
|
@@ -905,7 +906,7 @@ export function registerCampaignTools(server) {
|
|
|
905
906
|
await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + 1 }).eq('id', campaign_id);
|
|
906
907
|
}
|
|
907
908
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
908
|
-
});
|
|
909
|
+
}));
|
|
909
910
|
server.registerTool('get_enrollment_status', {
|
|
910
911
|
title: 'Get Enrollment Status',
|
|
911
912
|
annotations: {
|
|
@@ -995,11 +996,12 @@ export function registerCampaignTools(server) {
|
|
|
995
996
|
},
|
|
996
997
|
description: 'Enroll all active subscribers from a site into a campaign. Skips globally unsubscribed and suppressed contacts.',
|
|
997
998
|
inputSchema: {
|
|
999
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
998
1000
|
campaign_id: z.string().uuid(),
|
|
999
1001
|
site_id: z.string().uuid(),
|
|
1000
1002
|
next_send_at: z.string().optional().describe('ISO datetime for first send. Defaults to now.'),
|
|
1001
1003
|
},
|
|
1002
|
-
}, async ({ campaign_id, site_id, next_send_at }) => {
|
|
1004
|
+
}, withApproval('enroll_segment', async ({ campaign_id, site_id, next_send_at }) => {
|
|
1003
1005
|
const sendAt = next_send_at ?? new Date().toISOString();
|
|
1004
1006
|
const { data: subs, error: subErr } = await db()
|
|
1005
1007
|
.from('site_subscriptions')
|
|
@@ -1034,7 +1036,7 @@ export function registerCampaignTools(server) {
|
|
|
1034
1036
|
await db().from('campaigns').update({ total_enrolled: (campaign.total_enrolled ?? 0) + eligible.length }).eq('id', campaign_id);
|
|
1035
1037
|
}
|
|
1036
1038
|
return { content: [{ type: 'text', text: JSON.stringify({ enrolled: eligible.length, campaign_id, site_id }, null, 2) }] };
|
|
1037
|
-
});
|
|
1039
|
+
}));
|
|
1038
1040
|
server.registerTool('broadcast_to_segment', {
|
|
1039
1041
|
title: 'Broadcast To Segment',
|
|
1040
1042
|
annotations: {
|
|
@@ -2217,6 +2219,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2217
2219
|
},
|
|
2218
2220
|
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html. Logged as test, not counted in campaign metrics.',
|
|
2219
2221
|
inputSchema: {
|
|
2222
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
2220
2223
|
to_email: z.string().email().describe('Email address to send the test to'),
|
|
2221
2224
|
site_id: z.string().uuid().describe('Site UUID (determines from address)'),
|
|
2222
2225
|
step_id: z.string().uuid().optional(),
|
|
@@ -2225,7 +2228,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2225
2228
|
body_html: z.string().optional(),
|
|
2226
2229
|
body_text: z.string().optional(),
|
|
2227
2230
|
},
|
|
2228
|
-
}, async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
|
|
2231
|
+
}, withApproval('send_test_email', async ({ to_email, site_id, step_id, draft_id, subject, body_html, body_text }) => {
|
|
2229
2232
|
const projectId = getProjectId();
|
|
2230
2233
|
let subj = subject ?? '';
|
|
2231
2234
|
let html = body_html ?? '';
|
|
@@ -2308,7 +2311,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2308
2311
|
provider_message_id: sendResult.messageId,
|
|
2309
2312
|
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
2310
2313
|
});
|
|
2311
|
-
});
|
|
2314
|
+
}));
|
|
2312
2315
|
server.registerTool('send_transactional', {
|
|
2313
2316
|
title: 'Send Transactional',
|
|
2314
2317
|
annotations: {
|
|
@@ -2319,6 +2322,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2319
2322
|
},
|
|
2320
2323
|
description: 'Send a transactional email (password reset, receipt, notification) using the site\'s configured provider. Checks suppressions and plan limits. Logged with category transactional.',
|
|
2321
2324
|
inputSchema: {
|
|
2325
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
2322
2326
|
to: z.string().email().describe('Recipient email address'),
|
|
2323
2327
|
site_id: z.string().uuid().describe('Site UUID (determines from address and Resend key)'),
|
|
2324
2328
|
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
@@ -2329,7 +2333,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2329
2333
|
template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
|
|
2330
2334
|
template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
|
|
2331
2335
|
},
|
|
2332
|
-
}, async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
|
|
2336
|
+
}, withApproval('send_transactional', async ({ to, site_id, subject, html, text, reply_to, idempotency_key, template_id, template_vars }) => {
|
|
2333
2337
|
const projectId = getProjectId();
|
|
2334
2338
|
if (template_id && html)
|
|
2335
2339
|
return txt("Error: Provide either 'template_id' or 'html', not both");
|
|
@@ -2459,7 +2463,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2459
2463
|
provider_message_id: sendResult.messageId,
|
|
2460
2464
|
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
2461
2465
|
});
|
|
2462
|
-
});
|
|
2466
|
+
}));
|
|
2463
2467
|
function parseReplyWindow(input) {
|
|
2464
2468
|
const match = /^(\d{1,2})d$/.exec(input.trim());
|
|
2465
2469
|
if (!match)
|
|
@@ -2485,8 +2489,9 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2485
2489
|
idempotentHint: false,
|
|
2486
2490
|
openWorldHint: true,
|
|
2487
2491
|
},
|
|
2488
|
-
description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only.',
|
|
2492
|
+
description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
|
|
2489
2493
|
inputSchema: {
|
|
2494
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
2490
2495
|
to: z.string().email().describe('Recipient email address'),
|
|
2491
2496
|
site_id: z.string().uuid().describe('Site UUID (determines from address and provider)'),
|
|
2492
2497
|
subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
|
|
@@ -2498,7 +2503,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2498
2503
|
template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
|
|
2499
2504
|
template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
|
|
2500
2505
|
},
|
|
2501
|
-
}, async ({ to, site_id, subject, html, text, expect_reply_within, correlation_id, idempotency_key, template_id, template_vars }) => {
|
|
2506
|
+
}, withApproval('send_awaiting_reply', async ({ to, site_id, subject, html, text, expect_reply_within, correlation_id, idempotency_key, template_id, template_vars }) => {
|
|
2502
2507
|
const projectId = getProjectId();
|
|
2503
2508
|
if (template_id && html)
|
|
2504
2509
|
return txt("Error: Provide either 'template_id' or 'html', not both");
|
|
@@ -2694,7 +2699,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2694
2699
|
provider_message_id: sendResult.messageId,
|
|
2695
2700
|
resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
|
|
2696
2701
|
});
|
|
2697
|
-
});
|
|
2702
|
+
}));
|
|
2698
2703
|
server.registerTool('get_reply_status', {
|
|
2699
2704
|
title: 'Get Reply Status',
|
|
2700
2705
|
annotations: {
|
|
@@ -3190,8 +3195,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3190
3195
|
openWorldHint: false,
|
|
3191
3196
|
},
|
|
3192
3197
|
description: 'Remove a step from a campaign. Only allowed on draft campaigns.',
|
|
3193
|
-
inputSchema: { step_id: z.string().uuid() },
|
|
3194
|
-
}, async ({ step_id }) => {
|
|
3198
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), step_id: z.string().uuid() },
|
|
3199
|
+
}, withApproval('delete_campaign_step', async ({ step_id }) => {
|
|
3195
3200
|
const projectId = getProjectId();
|
|
3196
3201
|
const { data: step } = await db()
|
|
3197
3202
|
.from('campaign_steps')
|
|
@@ -3218,7 +3223,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3218
3223
|
if (error)
|
|
3219
3224
|
return j({ error: error.message });
|
|
3220
3225
|
return j({ ok: true, deleted: step_id });
|
|
3221
|
-
});
|
|
3226
|
+
}));
|
|
3222
3227
|
server.registerTool('delete_campaign', {
|
|
3223
3228
|
title: 'Delete Campaign',
|
|
3224
3229
|
annotations: {
|
|
@@ -3228,8 +3233,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3228
3233
|
openWorldHint: false,
|
|
3229
3234
|
},
|
|
3230
3235
|
description: 'Permanently delete a campaign. Campaign must not be active — pause it first. Deletes all steps and enrollments.',
|
|
3231
|
-
inputSchema: { campaign_id: z.string().uuid() },
|
|
3232
|
-
}, async ({ campaign_id }) => {
|
|
3236
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), campaign_id: z.string().uuid() },
|
|
3237
|
+
}, withApproval('delete_campaign', async ({ campaign_id }) => {
|
|
3233
3238
|
const projectId = getProjectId();
|
|
3234
3239
|
const { data: campaign, error: fetchErr } = await db()
|
|
3235
3240
|
.from('campaigns')
|
|
@@ -3250,7 +3255,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3250
3255
|
if (error)
|
|
3251
3256
|
return j({ error: error.message });
|
|
3252
3257
|
return j({ success: true, deleted: campaign.name });
|
|
3253
|
-
});
|
|
3258
|
+
}));
|
|
3254
3259
|
server.registerTool('update_campaign', {
|
|
3255
3260
|
title: 'Update Campaign',
|
|
3256
3261
|
annotations: {
|
package/build/tools/contacts.js
CHANGED
|
@@ -7,7 +7,7 @@ import { db, projectClientForWriteback } from '../db.js';
|
|
|
7
7
|
import { getProjectId } from '../project.js';
|
|
8
8
|
import { checkPlanLimit } from '../plan-limits.js';
|
|
9
9
|
import { logConsent, logAudit } from '../audit.js';
|
|
10
|
-
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
10
|
+
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
11
11
|
import { decrypt } from '../crypto.js';
|
|
12
12
|
import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
|
|
13
13
|
import { diffCustomProperties } from '../lib/custom-properties-diff.js';
|
|
@@ -1059,9 +1059,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1059
1059
|
},
|
|
1060
1060
|
description: 'Delete a saved subscriber view owned by the current project.',
|
|
1061
1061
|
inputSchema: {
|
|
1062
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
1062
1063
|
view_id: z.string().uuid(),
|
|
1063
1064
|
},
|
|
1064
|
-
}, async ({ view_id }) => {
|
|
1065
|
+
}, withApproval('delete_subscriber_view', async ({ view_id }) => {
|
|
1065
1066
|
const { data, error } = await db()
|
|
1066
1067
|
.from('subscriber_list_views')
|
|
1067
1068
|
.delete()
|
|
@@ -1074,7 +1075,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1074
1075
|
if (!data)
|
|
1075
1076
|
return txt('View not found');
|
|
1076
1077
|
return j({ deleted: true, view_id });
|
|
1077
|
-
});
|
|
1078
|
+
}));
|
|
1078
1079
|
server.registerTool('list_companies', {
|
|
1079
1080
|
title: 'List Companies',
|
|
1080
1081
|
annotations: {
|
|
@@ -1231,6 +1232,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1231
1232
|
},
|
|
1232
1233
|
description: 'Bulk import contacts and subscribe them to a site. Max 200 per call.',
|
|
1233
1234
|
inputSchema: {
|
|
1235
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
1234
1236
|
contacts: z.array(z.object({
|
|
1235
1237
|
email: z.string().email(),
|
|
1236
1238
|
first_name: z.string().optional(),
|
|
@@ -1238,7 +1240,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1238
1240
|
})).min(1).max(200),
|
|
1239
1241
|
site_id: z.string().uuid(),
|
|
1240
1242
|
},
|
|
1241
|
-
}, async ({ contacts, site_id }) => {
|
|
1243
|
+
}, withApproval('import_subscribers', async ({ contacts, site_id }) => {
|
|
1242
1244
|
const projectId = getProjectId();
|
|
1243
1245
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1244
1246
|
// Distribution list / system address block — same patterns as dashboard email-validation.ts
|
|
@@ -1314,7 +1316,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1314
1316
|
const created = contactRows.filter((c) => !existingEmails.has(c.email)).length;
|
|
1315
1317
|
const updated = contactRows.length - created;
|
|
1316
1318
|
return { content: [{ type: 'text', text: JSON.stringify({ created, updated, total: contactRows.length }, null, 2) }] };
|
|
1317
|
-
});
|
|
1319
|
+
}));
|
|
1318
1320
|
server.registerTool('unsubscribe_subscriber', {
|
|
1319
1321
|
title: 'Unsubscribe Subscriber',
|
|
1320
1322
|
annotations: {
|
|
@@ -1862,10 +1864,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1862
1864
|
},
|
|
1863
1865
|
description: 'Merge custom_properties into many contacts at once. Same merge semantics as update_subscriber, applied across a batch.',
|
|
1864
1866
|
inputSchema: {
|
|
1867
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
1865
1868
|
contact_ids: z.array(z.string().uuid()).min(1),
|
|
1866
1869
|
custom_properties: z.record(z.string(), z.unknown()).describe('Merged into each contact\'s existing custom_properties; a key set to null deletes that key'),
|
|
1867
1870
|
},
|
|
1868
|
-
}, async ({ contact_ids, custom_properties }) => {
|
|
1871
|
+
}, withApproval('bulk_update_subscribers', async ({ contact_ids, custom_properties }) => {
|
|
1869
1872
|
const projectId = getProjectId();
|
|
1870
1873
|
const orgId = await getProjectOrgId(projectId);
|
|
1871
1874
|
const fields = await loadContactPropertyDefinitions(projectId);
|
|
@@ -1921,7 +1924,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1921
1924
|
await writeBackMcpContact(projectId, contact, { custom_properties: merged });
|
|
1922
1925
|
}
|
|
1923
1926
|
return j({ updated, total: contact_ids.length });
|
|
1924
|
-
});
|
|
1927
|
+
}));
|
|
1925
1928
|
server.registerTool('estimate_segment_size', {
|
|
1926
1929
|
title: 'Estimate Segment Size',
|
|
1927
1930
|
annotations: {
|
|
@@ -2141,11 +2144,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2141
2144
|
idempotentHint: false,
|
|
2142
2145
|
openWorldHint: false,
|
|
2143
2146
|
},
|
|
2144
|
-
description: 'Remove an email address from the suppression list',
|
|
2147
|
+
description: 'Remove an email address from the suppression list. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
|
|
2145
2148
|
inputSchema: {
|
|
2149
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
2146
2150
|
email: z.string().email(),
|
|
2147
2151
|
},
|
|
2148
|
-
}, async ({ email }) => {
|
|
2152
|
+
}, withApproval('remove_suppression', async ({ email }) => {
|
|
2149
2153
|
const { data, error } = await db()
|
|
2150
2154
|
.from('suppressions')
|
|
2151
2155
|
.delete()
|
|
@@ -2158,7 +2162,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2158
2162
|
if (!data)
|
|
2159
2163
|
return txt(`No suppression found for ${email}.`);
|
|
2160
2164
|
return j({ removed: true, email, previous_reason: data.reason });
|
|
2161
|
-
});
|
|
2165
|
+
}));
|
|
2162
2166
|
server.registerTool('find_duplicates', {
|
|
2163
2167
|
title: 'Find Duplicates',
|
|
2164
2168
|
annotations: {
|
|
@@ -2496,8 +2500,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2496
2500
|
title: 'Delete Custom Field',
|
|
2497
2501
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
2498
2502
|
description: 'Delete a custom field definition without deleting existing contact values stored under that key.',
|
|
2499
|
-
inputSchema: { id: z.string().uuid() },
|
|
2500
|
-
}, async ({ id }) => {
|
|
2503
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), id: z.string().uuid() },
|
|
2504
|
+
}, withApproval('delete_custom_field', async ({ id }) => {
|
|
2501
2505
|
const projectId = getProjectId();
|
|
2502
2506
|
const { data, error } = await db()
|
|
2503
2507
|
.from('custom_field_definitions')
|
|
@@ -2517,7 +2521,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2517
2521
|
details: { key: data.key },
|
|
2518
2522
|
});
|
|
2519
2523
|
return j({ deleted: true, id: data.id, key: data.key });
|
|
2520
|
-
});
|
|
2524
|
+
}));
|
|
2521
2525
|
server.registerTool('get_contact_neighborhood', {
|
|
2522
2526
|
title: 'Get Contact Neighborhood',
|
|
2523
2527
|
annotations: {
|
package/build/tools/content.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { adminDb, db } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -234,13 +235,13 @@ export function registerContentTools(server) {
|
|
|
234
235
|
openWorldHint: false,
|
|
235
236
|
},
|
|
236
237
|
description: 'Delete a reusable content block.',
|
|
237
|
-
inputSchema: { block_id: z.string().uuid() },
|
|
238
|
-
}, async ({ block_id }) => {
|
|
238
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), block_id: z.string().uuid() },
|
|
239
|
+
}, withApproval('delete_content_block', async ({ block_id }) => {
|
|
239
240
|
const { error } = await db().from('content_blocks').delete().eq('project_id', getProjectId()).eq('id', block_id);
|
|
240
241
|
if (error)
|
|
241
242
|
return j({ error: error.message });
|
|
242
243
|
return j({ deleted: true });
|
|
243
|
-
});
|
|
244
|
+
}));
|
|
244
245
|
server.registerTool('list_content_sources', {
|
|
245
246
|
title: 'List Content Sources',
|
|
246
247
|
annotations: {
|
|
@@ -294,6 +295,7 @@ export function registerContentTools(server) {
|
|
|
294
295
|
},
|
|
295
296
|
description: 'Ingest a blog post (written by your AI/agent) into Sendinel. By default it lands as a draft in the review queue. For a trusted, hands-off flow, set auto_publish=true with publish_to (a destination name or id from list_blog_destinations) to skip review and publish to the user\'s own site — now, or at scheduled_at. Only title is required; everything else is optional.',
|
|
296
297
|
inputSchema: {
|
|
298
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
297
299
|
title: z.string().min(1).max(300),
|
|
298
300
|
body: z.string().optional(),
|
|
299
301
|
excerpt: z.string().max(1000).optional(),
|
|
@@ -308,7 +310,7 @@ export function registerContentTools(server) {
|
|
|
308
310
|
auto_publish: z.boolean().optional(),
|
|
309
311
|
publish_to: z.string().max(200).optional(),
|
|
310
312
|
},
|
|
311
|
-
}, async (args) => {
|
|
313
|
+
}, withApproval('create_blog_post', async (args) => {
|
|
312
314
|
const { data: proj, error: projErr } = await adminDb().from('projects').select('org_id').eq('id', getProjectId()).single();
|
|
313
315
|
if (projErr || !proj?.org_id)
|
|
314
316
|
return j({ error: 'Organization not found for project' });
|
|
@@ -363,7 +365,7 @@ export function registerContentTools(server) {
|
|
|
363
365
|
if (error)
|
|
364
366
|
return j({ error: error.message, code: error.code });
|
|
365
367
|
return j({ blog_post: data, ...(auto_publish ? { note: scheduled_at ? 'Scheduled — the publish worker will send it at scheduled_at.' : 'Queued for immediate publish — the worker publishes it within a few minutes.' } : {}) });
|
|
366
|
-
});
|
|
368
|
+
}));
|
|
367
369
|
server.registerTool('list_blog_posts', {
|
|
368
370
|
title: 'List Blog Posts',
|
|
369
371
|
annotations: {
|
package/build/tools/data.js
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { adminDb, db } from '../db.js';
|
|
5
5
|
import { decrypt, encrypt } from '../crypto.js';
|
|
6
6
|
import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
|
|
7
|
-
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
9
|
import { logAudit } from '../audit.js';
|
|
10
10
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
@@ -562,11 +562,12 @@ export function registerDataTools(server) {
|
|
|
562
562
|
},
|
|
563
563
|
description: 'Disconnect an external platform connection for the current project organization and remove stored credentials.',
|
|
564
564
|
inputSchema: {
|
|
565
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
565
566
|
org_id: z.string().uuid().optional(),
|
|
566
567
|
platform: z.enum(CONNECTED_PLATFORMS).optional(),
|
|
567
568
|
connection_id: z.string().uuid().optional(),
|
|
568
569
|
},
|
|
569
|
-
}, async ({ org_id, platform, connection_id }) => {
|
|
570
|
+
}, withApproval('disconnect_platform', async ({ org_id, platform, connection_id }) => {
|
|
570
571
|
try {
|
|
571
572
|
if (!platform && !connection_id)
|
|
572
573
|
return j({ error: 'platform or connection_id is required' });
|
|
@@ -596,7 +597,7 @@ export function registerDataTools(server) {
|
|
|
596
597
|
catch (error) {
|
|
597
598
|
return j({ error: error instanceof Error ? error.message : 'Failed to disconnect platform' });
|
|
598
599
|
}
|
|
599
|
-
});
|
|
600
|
+
}));
|
|
600
601
|
server.registerTool('list_platform_events', {
|
|
601
602
|
title: 'List Platform Events',
|
|
602
603
|
annotations: {
|
|
@@ -978,11 +979,12 @@ export function registerDataTools(server) {
|
|
|
978
979
|
},
|
|
979
980
|
description: 'Create an API key and return the secret once.',
|
|
980
981
|
inputSchema: {
|
|
982
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
981
983
|
name: z.string().min(1),
|
|
982
984
|
slug: z.string().optional(),
|
|
983
985
|
scopes: z.array(z.string()).optional().default(['read']),
|
|
984
986
|
},
|
|
985
|
-
}, async ({ name, slug, scopes = ['read'] }) => {
|
|
987
|
+
}, withApproval('create_api_key', async ({ name, slug, scopes = ['read'] }) => {
|
|
986
988
|
// MCP API-key sessions can be unbound to a user, so they cannot prove an
|
|
987
989
|
// acting org role. Admin keys must be created through the dashboard.
|
|
988
990
|
if (scopes.includes('admin')) {
|
|
@@ -997,7 +999,7 @@ export function registerDataTools(server) {
|
|
|
997
999
|
if (error)
|
|
998
1000
|
return j({ error: error.message });
|
|
999
1001
|
return j({ key_id: data.id, name: data.name, slug: data.slug, secret });
|
|
1000
|
-
});
|
|
1002
|
+
}));
|
|
1001
1003
|
server.registerTool('revoke_api_key', {
|
|
1002
1004
|
title: 'Revoke API Key',
|
|
1003
1005
|
annotations: {
|
|
@@ -1007,13 +1009,13 @@ export function registerDataTools(server) {
|
|
|
1007
1009
|
openWorldHint: false,
|
|
1008
1010
|
},
|
|
1009
1011
|
description: 'Revoke an API key.',
|
|
1010
|
-
inputSchema: { key_id: z.string().uuid() },
|
|
1011
|
-
}, async ({ key_id }) => {
|
|
1012
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), key_id: z.string().uuid() },
|
|
1013
|
+
}, withApproval('revoke_api_key', async ({ key_id }) => {
|
|
1012
1014
|
const { error } = await db().from('api_keys').update({ revoked_at: new Date().toISOString() }).eq('project_id', getProjectId()).eq('id', key_id);
|
|
1013
1015
|
if (error)
|
|
1014
1016
|
return j({ error: error.message });
|
|
1015
1017
|
return j({ revoked: true });
|
|
1016
|
-
});
|
|
1018
|
+
}));
|
|
1017
1019
|
server.registerTool('migrate_campaigns_from', {
|
|
1018
1020
|
title: 'Migrate Campaigns From',
|
|
1019
1021
|
annotations: {
|
|
@@ -1195,10 +1197,11 @@ export function registerDataTools(server) {
|
|
|
1195
1197
|
},
|
|
1196
1198
|
description: 'Export all tenant data classes for portability. Returns inline data for small exports or a signed archive URL for large exports.',
|
|
1197
1199
|
inputSchema: {
|
|
1200
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
1198
1201
|
siteId: z.string().uuid(),
|
|
1199
1202
|
format: z.enum(['json', 'csv']).optional().default('json'),
|
|
1200
1203
|
},
|
|
1201
|
-
}, async ({ siteId, format = 'json' }) => {
|
|
1204
|
+
}, withApproval('tenant_export_full', async ({ siteId, format = 'json' }) => {
|
|
1202
1205
|
const projectId = getProjectId();
|
|
1203
1206
|
const since = new Date(Date.now() - 90 * 24 * 3600 * 1000).toISOString();
|
|
1204
1207
|
const client = db();
|
|
@@ -1284,5 +1287,5 @@ export function registerDataTools(server) {
|
|
|
1284
1287
|
expires_at: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
|
1285
1288
|
record_counts: recordCounts,
|
|
1286
1289
|
});
|
|
1287
|
-
});
|
|
1290
|
+
}));
|
|
1288
1291
|
}
|
|
@@ -3,7 +3,7 @@ import dns from 'node:dns/promises';
|
|
|
3
3
|
import { cancelSendQueueRows, findReplayableEnrollmentDlqRows, listSendQueueRows, replayEnrollmentDlqRows, retrySendQueueRows, } from '@sendinel/domain';
|
|
4
4
|
import { logAudit } from '../audit.js';
|
|
5
5
|
import { adminDb, db } from '../db.js';
|
|
6
|
-
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
6
|
+
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
@@ -507,13 +507,14 @@ export function registerDeliveryOpsTools(server) {
|
|
|
507
507
|
},
|
|
508
508
|
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.',
|
|
509
509
|
inputSchema: {
|
|
510
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
510
511
|
site_id: z.string().uuid(),
|
|
511
512
|
max_count: z.number().int().min(1).max(1000).default(100).optional(),
|
|
512
513
|
failed_after: z.string().datetime().optional(),
|
|
513
514
|
confirmed: z.boolean().optional().default(false)
|
|
514
515
|
.describe('Two-call confirmation. First call previews the matched rows; confirmed=true replays them.'),
|
|
515
516
|
},
|
|
516
|
-
}, async ({ site_id, max_count = 100, failed_after, confirmed = false }) => {
|
|
517
|
+
}, withApproval('replay_enrollment_dlq_rows', async ({ site_id, max_count = 100, failed_after, confirmed = false }) => {
|
|
517
518
|
const projectId = getProjectId();
|
|
518
519
|
if (!confirmed) {
|
|
519
520
|
try {
|
|
@@ -561,7 +562,7 @@ export function registerDeliveryOpsTools(server) {
|
|
|
561
562
|
catch (error) {
|
|
562
563
|
return j({ error: error instanceof Error ? error.message : 'Replay failed' });
|
|
563
564
|
}
|
|
564
|
-
});
|
|
565
|
+
}));
|
|
565
566
|
server.registerTool('get_scoring_rules', {
|
|
566
567
|
title: 'Get Scoring Rules',
|
|
567
568
|
annotations: {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { db } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -118,6 +119,7 @@ export function registerLandingPageTools(server) {
|
|
|
118
119
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
119
120
|
description: 'Update a landing page. Setting status to published stamps published_at; pass site_id or form_id as null to clear it.',
|
|
120
121
|
inputSchema: {
|
|
122
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
121
123
|
page_id: z.string().uuid(),
|
|
122
124
|
title: z.string().trim().min(1).max(200).optional(),
|
|
123
125
|
slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
|
|
@@ -132,7 +134,7 @@ export function registerLandingPageTools(server) {
|
|
|
132
134
|
accent_color: z.string().optional(),
|
|
133
135
|
custom_css: z.string().optional(),
|
|
134
136
|
},
|
|
135
|
-
}, async ({ page_id, title, slug, status, site_id, form_id, blocks, meta_title, meta_description, conversion_event, background_color, accent_color, custom_css }) => {
|
|
137
|
+
}, withApproval('update_landing_page', async ({ page_id, title, slug, status, site_id, form_id, blocks, meta_title, meta_description, conversion_event, background_color, accent_color, custom_css }) => {
|
|
136
138
|
const projectId = getProjectId();
|
|
137
139
|
const updates = { updated_at: new Date().toISOString() };
|
|
138
140
|
if (title !== undefined)
|
|
@@ -181,13 +183,13 @@ export function registerLandingPageTools(server) {
|
|
|
181
183
|
details: { title: data.title, slug: data.slug, status: data.status },
|
|
182
184
|
});
|
|
183
185
|
return j(data);
|
|
184
|
-
});
|
|
186
|
+
}));
|
|
185
187
|
server.registerTool('delete_landing_page', {
|
|
186
188
|
title: 'Delete Landing Page',
|
|
187
189
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
188
190
|
description: 'Permanently delete a landing page. Set status to archived instead to unpublish without deleting.',
|
|
189
|
-
inputSchema: { page_id: z.string().uuid() },
|
|
190
|
-
}, async ({ page_id }) => {
|
|
191
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), page_id: z.string().uuid() },
|
|
192
|
+
}, withApproval('delete_landing_page', async ({ page_id }) => {
|
|
191
193
|
const projectId = getProjectId();
|
|
192
194
|
const { error } = await db()
|
|
193
195
|
.from('landing_pages')
|
|
@@ -198,5 +200,5 @@ export function registerLandingPageTools(server) {
|
|
|
198
200
|
return j({ error: error.message });
|
|
199
201
|
await logAudit({ action: 'landing_page.deleted', resourceType: 'landing_page', resourceId: page_id });
|
|
200
202
|
return j({ deleted: true });
|
|
201
|
-
});
|
|
203
|
+
}));
|
|
202
204
|
}
|
package/build/tools/lists.js
CHANGED
|
@@ -3,7 +3,7 @@ import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normali
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { logAudit } from '../audit.js';
|
|
6
|
-
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, } from '../lib/action-approvals.js';
|
|
6
|
+
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval, } from '../lib/action-approvals.js';
|
|
7
7
|
const LIST_MEMBER_BATCH_SIZE = 500;
|
|
8
8
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
9
9
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
@@ -181,8 +181,8 @@ export function registerListTools(server) {
|
|
|
181
181
|
title: 'Delete Contact List',
|
|
182
182
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
183
183
|
description: 'Permanently delete a static contact list and its memberships.',
|
|
184
|
-
inputSchema: { list_id: z.string().uuid() },
|
|
185
|
-
}, async ({ list_id }) => {
|
|
184
|
+
inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), list_id: z.string().uuid() },
|
|
185
|
+
}, withApproval('delete_list', async ({ list_id }) => {
|
|
186
186
|
const projectId = getProjectId();
|
|
187
187
|
const { error } = await db()
|
|
188
188
|
.from('contact_lists')
|
|
@@ -193,7 +193,7 @@ export function registerListTools(server) {
|
|
|
193
193
|
return txt(`Error: ${error.message}`);
|
|
194
194
|
await logAudit({ action: 'list.deleted', resourceType: 'list', resourceId: list_id });
|
|
195
195
|
return j({ deleted: true, list_id });
|
|
196
|
-
});
|
|
196
|
+
}));
|
|
197
197
|
server.registerTool('add_list_members', {
|
|
198
198
|
title: 'Add List Members',
|
|
199
199
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { db } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
@@ -354,11 +355,12 @@ export function registerNewsletterTools(server) {
|
|
|
354
355
|
},
|
|
355
356
|
description: 'Schedule a newsletter issue through the campaign send pipeline.',
|
|
356
357
|
inputSchema: {
|
|
358
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
357
359
|
newsletter_id: z.string().uuid(),
|
|
358
360
|
issue_id: z.string().uuid(),
|
|
359
361
|
scheduled_at: z.string(),
|
|
360
362
|
},
|
|
361
|
-
}, async ({ newsletter_id, issue_id, scheduled_at }) => {
|
|
363
|
+
}, withApproval('schedule_newsletter_issue', async ({ newsletter_id, issue_id, scheduled_at }) => {
|
|
362
364
|
const parsed = parseFutureDate(scheduled_at);
|
|
363
365
|
if ('error' in parsed)
|
|
364
366
|
return j({ error: parsed.error, code: 'INVALID_FIELD' });
|
|
@@ -381,7 +383,7 @@ export function registerNewsletterTools(server) {
|
|
|
381
383
|
if (error)
|
|
382
384
|
return j({ error: error.message });
|
|
383
385
|
return j({ issue: data, campaign_id: campaign.campaignId, enrolled: campaign.enrolled });
|
|
384
|
-
});
|
|
386
|
+
}));
|
|
385
387
|
server.registerTool('send_newsletter_issue', {
|
|
386
388
|
title: 'Send Newsletter Issue',
|
|
387
389
|
annotations: {
|
|
@@ -390,12 +392,13 @@ export function registerNewsletterTools(server) {
|
|
|
390
392
|
idempotentHint: false,
|
|
391
393
|
openWorldHint: true,
|
|
392
394
|
},
|
|
393
|
-
description: 'Send a newsletter issue now through the campaign send pipeline and create archive metadata.',
|
|
395
|
+
description: 'Send a newsletter issue now through the campaign send pipeline and create archive metadata. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
|
|
394
396
|
inputSchema: {
|
|
397
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
395
398
|
newsletter_id: z.string().uuid(),
|
|
396
399
|
issue_id: z.string().uuid(),
|
|
397
400
|
},
|
|
398
|
-
}, async ({ newsletter_id, issue_id }) => {
|
|
401
|
+
}, withApproval('send_newsletter_issue', async ({ newsletter_id, issue_id }) => {
|
|
399
402
|
const projectId = getProjectId();
|
|
400
403
|
const loaded = await loadSendableIssue(newsletter_id, issue_id, projectId);
|
|
401
404
|
if ('error' in loaded)
|
|
@@ -436,7 +439,7 @@ export function registerNewsletterTools(server) {
|
|
|
436
439
|
if (error)
|
|
437
440
|
return j({ error: error.message });
|
|
438
441
|
return j({ issue: data, campaign_id: campaign.campaignId, enrolled: campaign.enrolled, blog_post_id: post?.id ?? null, archive_url: post?.external_url ?? null });
|
|
439
|
-
});
|
|
442
|
+
}));
|
|
440
443
|
server.registerTool('subscribe_to_newsletter', {
|
|
441
444
|
title: 'Subscribe To Newsletter',
|
|
442
445
|
annotations: {
|