@sendinel/mcp-server 1.0.62 → 1.0.64

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.
@@ -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: {
@@ -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: {
@@ -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: {
@@ -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
  }
@@ -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: {