@sendinel/mcp-server 1.0.63 → 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.
@@ -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: {
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { logAudit } from '../audit.js';
4
4
  import { adminDb, db } from '../db.js';
5
5
  import { getProjectId } from '../project.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 { readAllRows } from '@sendinel/domain';
9
9
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
@@ -175,8 +175,8 @@ export function registerOrgTools(server) {
175
175
  openWorldHint: false,
176
176
  },
177
177
  description: 'Create a team invitation for the current project organization.',
178
- inputSchema: { email: z.string().email(), role: z.enum(['owner', 'admin', 'member']).default('member') },
179
- }, async ({ email, role }) => {
178
+ 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.'), email: z.string().email(), role: z.enum(['owner', 'admin', 'member']).default('member') },
179
+ }, withApproval('invite_team_member', async ({ email, role }) => {
180
180
  const { data: project } = await db().from('projects').select('org_id').eq('id', getProjectId()).maybeSingle();
181
181
  if (!project?.org_id)
182
182
  return j({ error: 'Project has no organization', code: 'NO_ORG' });
@@ -189,7 +189,7 @@ export function registerOrgTools(server) {
189
189
  if (error)
190
190
  return j({ error: error.message });
191
191
  return j({ invitation_id: data.id, expires_at: data.expires_at });
192
- });
192
+ }));
193
193
  server.registerTool('remove_team_member', {
194
194
  title: 'Remove Team Member',
195
195
  annotations: {
@@ -348,11 +348,12 @@ export function registerOrgTools(server) {
348
348
  },
349
349
  description: "Replace a team member's per-project access within the org (full replace, not a merge). Org owners have implicit access to all projects and cannot be scoped this way.",
350
350
  inputSchema: {
351
+ 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.'),
351
352
  user_id: z.string().uuid(),
352
353
  projects: z.array(memberProjectAssignmentSchema),
353
354
  org_id: z.string().uuid().optional(),
354
355
  },
355
- }, async ({ user_id, projects, org_id }) => {
356
+ }, withApproval('set_member_project_access', async ({ user_id, projects, org_id }) => {
356
357
  const context = await resolveCurrentOrgId(org_id);
357
358
  if (!context.ok)
358
359
  return j({ error: context.error, code: context.code });
@@ -409,7 +410,7 @@ export function registerOrgTools(server) {
409
410
  details: { org_id: context.orgId, project_ids: requestedIds },
410
411
  }).catch(() => { });
411
412
  return j({ ok: true, user_id, project_ids: requestedIds });
412
- });
413
+ }));
413
414
  server.registerTool('list_invitations', {
414
415
  title: 'List Invitations',
415
416
  annotations: {
@@ -495,10 +496,11 @@ export function registerOrgTools(server) {
495
496
  },
496
497
  description: 'Revoke a pending org invitation before it is accepted.',
497
498
  inputSchema: {
499
+ 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.'),
498
500
  invitation_id: z.string().uuid(),
499
501
  org_id: z.string().uuid().optional(),
500
502
  },
501
- }, async ({ invitation_id, org_id }) => {
503
+ }, withApproval('revoke_invitation', async ({ invitation_id, org_id }) => {
502
504
  const context = await resolveCurrentOrgId(org_id);
503
505
  if (!context.ok)
504
506
  return j({ error: context.error, code: context.code });
@@ -526,7 +528,7 @@ export function registerOrgTools(server) {
526
528
  details: { org_id: context.orgId, email: invitation.email, role: invitation.role },
527
529
  }).catch(() => { });
528
530
  return j({ ok: true });
529
- });
531
+ }));
530
532
  server.registerTool('get_plan_usage', {
531
533
  title: 'Get Plan Usage',
532
534
  annotations: {
@@ -910,16 +912,17 @@ export function registerOrgTools(server) {
910
912
  },
911
913
  description: 'Start moving a sending domain between supported providers using the existing provider-switch flow.',
912
914
  inputSchema: {
915
+ 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.'),
913
916
  site_id: z.string().uuid(),
914
917
  to_provider: z.enum(PROVIDER_SWITCH_V1_PROVIDERS),
915
918
  },
916
- }, async ({ site_id, to_provider }) => {
919
+ }, withApproval('start_provider_switch', async ({ site_id, to_provider }) => {
917
920
  const result = await callProviderSwitchApi('/api/dashboard/provider-switch/start', {
918
921
  method: 'POST',
919
922
  body: { site_id, to_provider },
920
923
  });
921
924
  return result.ok ? j(result.data) : j({ error: result.error });
922
- });
925
+ }));
923
926
  server.registerTool('get_provider_switch_status', {
924
927
  title: 'Get Provider Switch Status',
925
928
  annotations: {
@@ -1,3 +1,4 @@
1
+ import { withApproval } from '../lib/action-approvals.js';
1
2
  import { z } from 'zod';
2
3
  import Anthropic from '@anthropic-ai/sdk';
3
4
  import { db } from '../db.js';
@@ -152,9 +153,10 @@ export function registerSegmentTools(server) {
152
153
  },
153
154
  description: 'Delete a saved segment. Campaigns using this segment will lose targeting.',
154
155
  inputSchema: {
156
+ 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.'),
155
157
  segment_id: z.string().uuid(),
156
158
  },
157
- }, async ({ segment_id }) => {
159
+ }, withApproval('delete_segment', async ({ segment_id }) => {
158
160
  const { error } = await db()
159
161
  .from('segments')
160
162
  .delete()
@@ -163,7 +165,7 @@ export function registerSegmentTools(server) {
163
165
  if (error)
164
166
  return txt(`Error: ${error.message}`);
165
167
  return j({ deleted: true, segment_id });
166
- });
168
+ }));
167
169
  server.registerTool('preview_segment', {
168
170
  title: 'Preview Segment',
169
171
  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';
@@ -137,8 +138,8 @@ export function registerSignatureTools(server) {
137
138
  title: 'Delete Signature',
138
139
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
139
140
  description: 'Permanently delete a project-scoped sending signature block.',
140
- inputSchema: { id: z.string().uuid() },
141
- }, async ({ id }) => {
141
+ 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() },
142
+ }, withApproval('delete_signature', async ({ id }) => {
142
143
  const projectId = getProjectId();
143
144
  const { data: existing, error: existingError } = await db()
144
145
  .from('signature_blocks')
@@ -164,5 +165,5 @@ export function registerSignatureTools(server) {
164
165
  details: { name: existing.name, site_id: existing.site_id, via: 'mcp' },
165
166
  });
166
167
  return j({ ok: true, deleted: id });
167
- });
168
+ }));
168
169
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'crypto';
2
+ import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { decrypt } from '../crypto.js';
4
5
  import { db } from '../db.js';
@@ -308,13 +309,14 @@ export function registerSmsTools(server) {
308
309
  },
309
310
  description: 'Manually record an opt-in or opt-out for a contact (e.g., from a paper form or phone conversation).',
310
311
  inputSchema: {
312
+ 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.'),
311
313
  contact_id: z.string().uuid(),
312
314
  status: z.enum(['opt_in', 'opt_out']),
313
315
  source: z.string().optional(),
314
316
  consent_text: z.string().optional(),
315
317
  reason: z.string().optional(),
316
318
  },
317
- }, async ({ contact_id, status, source, consent_text, reason }) => {
319
+ }, withApproval('set_contact_sms_consent', async ({ contact_id, status, source, consent_text, reason }) => {
318
320
  const projectId = getProjectId();
319
321
  const orgId = await getProjectOrgId(projectId);
320
322
  if (!orgId)
@@ -366,7 +368,7 @@ export function registerSmsTools(server) {
366
368
  return j({ error: suppressionError.message });
367
369
  }
368
370
  return j({ ok: true, contact_id, phone_e164: contact.phone_e164, status });
369
- });
371
+ }));
370
372
  server.registerTool('preview_sms_cost', {
371
373
  title: 'Preview SMS Cost',
372
374
  annotations: {
@@ -403,6 +405,7 @@ export function registerSmsTools(server) {
403
405
  },
404
406
  description: "Launch an SMS campaign or broadcast to a segment. Same shape as email campaigns but with channel='sms'.",
405
407
  inputSchema: {
408
+ 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.'),
406
409
  site_id: z.string().uuid(),
407
410
  name: z.string().min(1).max(200),
408
411
  body: z.string().min(1),
@@ -411,7 +414,7 @@ export function registerSmsTools(server) {
411
414
  scheduled_at: z.string().optional(),
412
415
  activate: z.boolean().optional().default(false),
413
416
  },
414
- }, async ({ site_id, name, body, type, segment_id, scheduled_at, activate }) => {
417
+ }, withApproval('send_sms_campaign', async ({ site_id, name, body, type, segment_id, scheduled_at, activate }) => {
415
418
  const projectId = getProjectId();
416
419
  const { data: campaign, error: campaignError } = await db()
417
420
  .from('campaigns')
@@ -452,7 +455,7 @@ export function registerSmsTools(server) {
452
455
  segment_id: segment_id ?? null,
453
456
  note: segment_id ? 'Campaign created. Enroll the target segment through the campaign workflow to start delivery.' : 'Campaign created as SMS-ready.',
454
457
  });
455
- });
458
+ }));
456
459
  server.registerTool('send_sms', {
457
460
  title: 'Send SMS',
458
461
  annotations: {
@@ -461,15 +464,16 @@ export function registerSmsTools(server) {
461
464
  idempotentHint: false,
462
465
  openWorldHint: true,
463
466
  },
464
- description: 'Send a single SMS through the active SMS provider to a contact or raw phone number. Validates consent and quiet hours before sending.',
467
+ description: 'Send a single SMS through the active SMS provider to a contact or raw phone number. Validates consent and quiet hours before sending. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
465
468
  inputSchema: {
469
+ 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.'),
466
470
  body: z.string().min(1),
467
471
  to_number: z.string().optional(),
468
472
  contact_id: z.string().uuid().optional(),
469
473
  site_id: z.string().uuid().optional(),
470
474
  classification: z.enum(['marketing', 'transactional', 'conversational']).optional().default('marketing'),
471
475
  },
472
- }, async ({ body, to_number, contact_id, site_id, classification }) => {
476
+ }, withApproval('send_sms', async ({ body, to_number, contact_id, site_id, classification }) => {
473
477
  const projectId = getProjectId();
474
478
  const orgId = await getProjectOrgId(projectId);
475
479
  if (!orgId)
@@ -534,5 +538,5 @@ export function registerSmsTools(server) {
534
538
  encoding: sendResult.encoding,
535
539
  estimated_cost_cents: sendResult.estimated_cost_cents,
536
540
  });
537
- });
541
+ }));
538
542
  }