@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.
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
+ import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { db } from '../db.js';
4
5
  import { getProjectId } from '../project.js';
@@ -558,8 +559,8 @@ export function registerSocialPostTools(server) {
558
559
  openWorldHint: true,
559
560
  },
560
561
  description: 'Archive a social post so it disappears from active planner and list views.',
561
- inputSchema: { post_id: z.string().uuid() },
562
- }, async ({ post_id }) => {
562
+ 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.'), post_id: z.string().uuid() },
563
+ }, withApproval('delete_social_post', async ({ post_id }) => {
563
564
  const { data, error } = await db()
564
565
  .from('social_posts')
565
566
  .update({ status: 'archived', updated_at: new Date().toISOString() })
@@ -571,5 +572,5 @@ export function registerSocialPostTools(server) {
571
572
  return txt(`Error: ${error.message}`);
572
573
  fireWebhookEvent('social.post.archived', { social_post_id: data.id, title: data.title }, getProjectId()).catch(() => { });
573
574
  return j(data);
574
- });
575
+ }));
575
576
  }
@@ -1,4 +1,5 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
+ import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { parseLinkedInCommentUrl, parseLinkedInPostUrl, parseLinkedInTargetUrl } from '@sendinel/domain';
4
5
  import { db, requestContext } from '../db.js';
@@ -144,10 +145,10 @@ export function registerSocialReplyTools(server) {
144
145
  inputSchema: { post_url_or_urn: z.string().min(1), text: replyText, target_member_id: z.string().uuid().optional() },
145
146
  }, async ({ post_url_or_urn, text, target_member_id }) => proposePersonal('post_comment', post_url_or_urn, text, target_member_id));
146
147
  server.registerTool('reply_to_comment', {
147
- title: 'Propose LinkedIn Comment Reply', description: 'Create a review card for a personal LinkedIn reply. Never posts.',
148
+ title: 'Propose LinkedIn Comment Reply', description: 'Create a review card for a personal LinkedIn reply. Never posts. Requires human approval: the first call returns an approve_url and does nothing; call again with the same arguments plus approval_id once approved.',
148
149
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
149
- inputSchema: { comment_url_or_urn: z.string().min(1), text: replyText, target_member_id: z.string().uuid().optional() },
150
- }, async ({ comment_url_or_urn, text, target_member_id }) => proposePersonal('comment_reply', comment_url_or_urn, text, target_member_id));
150
+ inputSchema: { comment_url_or_urn: z.string().min(1), text: replyText, target_member_id: z.string().uuid().optional(), 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.') },
151
+ }, withApproval('reply_to_comment', async ({ comment_url_or_urn, text, target_member_id }) => proposePersonal('comment_reply', comment_url_or_urn, text, target_member_id)));
151
152
  server.registerTool('request_social_reply', {
152
153
  title: 'Request LinkedIn Reply Proposal',
153
154
  description: 'Create a review card for a personal LinkedIn reply from an external trigger or email notification. Never posts; a human Accept posts it.',
@@ -1,3 +1,4 @@
1
+ import { withApproval } from '../lib/action-approvals.js';
1
2
  import { snapshotTemplateVersion, versionHasBodySnapshot } from '@sendinel/domain';
2
3
  import { z } from 'zod';
3
4
  import { logAudit } from '../audit.js';
@@ -377,8 +378,8 @@ export function registerTemplateTools(server) {
377
378
  openWorldHint: false,
378
379
  },
379
380
  description: 'Archive an email template. System templates cannot be deleted.',
380
- inputSchema: { id: z.string().uuid() },
381
- }, async ({ id }) => {
381
+ 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() },
382
+ }, withApproval('delete_template', async ({ id }) => {
382
383
  const projectId = getProjectId();
383
384
  const { data: existing, error: existingErr } = await db().from('campaign_templates')
384
385
  .select('id, project_id')
@@ -395,7 +396,7 @@ export function registerTemplateTools(server) {
395
396
  if (error)
396
397
  return j({ error: error.message });
397
398
  return j({ ok: true, deleted: id });
398
- });
399
+ }));
399
400
  server.registerTool('restore_template_version', {
400
401
  title: 'Restore Template Version',
401
402
  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';
@@ -34,11 +35,12 @@ export function registerWebhookTools(server) {
34
35
  },
35
36
  description: `Register a webhook URL to receive push notifications when events occur. Sendinel signs each payload with HMAC-SHA256 so you can verify authenticity. Valid events: ${VALID_EVENTS.join(', ')}`,
36
37
  inputSchema: {
38
+ 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.'),
37
39
  url: z.string().url().describe('HTTPS endpoint that will receive POST requests'),
38
40
  events: z.array(z.enum(VALID_EVENTS)).min(1).describe('Events to subscribe to'),
39
41
  description: z.string().optional().describe('Human-readable label, e.g. "Production orchestrator"'),
40
42
  },
41
- }, async ({ url, events, description }) => {
43
+ }, withApproval('subscribe_webhook', async ({ url, events, description }) => {
42
44
  // SSRF protection
43
45
  const validation = validateWebhookUrl(url);
44
46
  if (!validation.valid)
@@ -63,7 +65,7 @@ export function registerWebhookTools(server) {
63
65
  message: `Webhook registered. Store the secret — it won't be shown again. Sendinel will POST to ${url} with X-Sendinel-Signature header for verification.`,
64
66
  verification_example: `const expected = crypto.createHmac('sha256', secret).update(requestBody).digest('hex'); if (header === 'sha256=' + expected) { /* valid */ }`,
65
67
  });
66
- });
68
+ }));
67
69
  server.registerTool('list_webhook_subscriptions', {
68
70
  title: 'List Webhook Subscriptions',
69
71
  annotations: {
@@ -99,13 +101,14 @@ export function registerWebhookTools(server) {
99
101
  },
100
102
  description: 'Update a webhook subscription — change URL, events, or active status.',
101
103
  inputSchema: {
104
+ 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.'),
102
105
  subscription_id: z.string().uuid(),
103
106
  url: z.string().url().optional(),
104
107
  events: z.array(z.enum(VALID_EVENTS)).min(1).optional(),
105
108
  active: z.boolean().optional(),
106
109
  description: z.string().optional(),
107
110
  },
108
- }, async ({ subscription_id, url, events, active, description }) => {
111
+ }, withApproval('update_webhook_subscription', async ({ subscription_id, url, events, active, description }) => {
109
112
  if (url) {
110
113
  const validation = validateWebhookUrl(url);
111
114
  if (!validation.valid)
@@ -130,7 +133,7 @@ export function registerWebhookTools(server) {
130
133
  if (error)
131
134
  return txt(`Error: ${error.message}`);
132
135
  return j(data);
133
- });
136
+ }));
134
137
  server.registerTool('delete_webhook_subscription', {
135
138
  title: 'Delete Webhook Subscription',
136
139
  annotations: {
@@ -141,9 +144,10 @@ export function registerWebhookTools(server) {
141
144
  },
142
145
  description: 'Permanently delete a webhook subscription. This also deletes all delivery history for this subscription.',
143
146
  inputSchema: {
147
+ 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.'),
144
148
  subscription_id: z.string().uuid(),
145
149
  },
146
- }, async ({ subscription_id }) => {
150
+ }, withApproval('delete_webhook_subscription', async ({ subscription_id }) => {
147
151
  const { error } = await db()
148
152
  .from('webhook_subscriptions')
149
153
  .delete()
@@ -152,5 +156,5 @@ export function registerWebhookTools(server) {
152
156
  if (error)
153
157
  return txt(`Error: ${error.message}`);
154
158
  return txt(`Subscription ${subscription_id} deleted.`);
155
- });
159
+ }));
156
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",