@sendinel/mcp-server 1.0.163 → 1.0.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
18
18
  [event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
19
19
  inputs, idempotency, subscription requirements, and conditional step examples.
20
20
 
21
- Sendinel currently exposes 35 tool groups and 348 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
21
+ Sendinel currently exposes 35 tool groups and 349 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
22
22
 
23
23
  | Group | Count | Purpose |
24
24
  |-------|-------|---------|
@@ -27,7 +27,7 @@ Sendinel currently exposes 35 tool groups and 348 tools (gateway mode exposes 4
27
27
  | `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
28
28
  | `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
29
29
  | `analytics` | 15 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
30
- | `approvals` | 5 | Poll/list approvals, person OAuth approval, write-scoped cancellation, opted-in chat confirmation |
30
+ | `approvals` | 6 | Poll/list approvals, person OAuth approval, write-scoped cancellation, opted-in chat confirmation |
31
31
  | `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
32
32
  | `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
33
33
  | `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
@@ -31,7 +31,7 @@ export interface ExecutionDecision {
31
31
  * from getApprovalTier(), which only decides the tier itself. Call this before
32
32
  * requestApproval() at every gated/protected call site.
33
33
  */
34
- export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): ExecutionDecision;
34
+ export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): Promise<ExecutionDecision>;
35
35
  /** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
36
36
  export declare function currentChannelContext(): ChannelExecutionContext;
37
37
  /**
@@ -71,7 +71,8 @@ type AdminApprovalAuthority = {
71
71
  id?: string;
72
72
  action: string;
73
73
  projectId: string;
74
- phase: 'preview' | 'approved';
74
+ phase: 'preview' | 'approved' | 'level';
75
+ levelUserId?: string;
75
76
  decidedBy?: string;
76
77
  decisionChannel?: 'sandbox';
77
78
  };
@@ -105,6 +106,12 @@ type ToolResult = {
105
106
  text: string;
106
107
  }>;
107
108
  };
109
+ export declare function approvalSatisfied(action: string): boolean;
110
+ /** Catch preview/draft branches that historically returned before their hand-written gate.
111
+ * Existing gated execution paths retain their frozen-payload approval flow. */
112
+ export declare function withPreviewApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R): (args: A & {
113
+ approval_id?: string;
114
+ }, extra?: unknown) => Promise<R | ToolResult>;
108
115
  export declare function markNotExecuted<R extends object>(result: R): R;
109
116
  /** HTTP statuses that mean the request was refused before it changed anything. */
110
117
  export declare function isPreExecutionStatus(status: number | undefined): boolean;
@@ -1,3 +1,4 @@
1
+ import { effectiveApproval, auditApprovalLevel } from './approval-classes.js';
1
2
  import { sandboxApprovalEligible, trySandboxApproval } from './sandbox-approvals.js';
2
3
  import { humanApprovalEligible } from './approval-human-eligibility.js';
3
4
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -92,8 +93,16 @@ const TIER2_CHANNELS = new Set([
92
93
  * from getApprovalTier(), which only decides the tier itself. Call this before
93
94
  * requestApproval() at every gated/protected call site.
94
95
  */
95
- export function assertChannelCanExecute(action, params, context) {
96
- const tier = getApprovalTier(action, params);
96
+ export async function assertChannelCanExecute(action, params, context) {
97
+ if (approvalSatisfied(action))
98
+ return { allowed: true, tier: 'autonomous' };
99
+ const effective = await effectiveApproval(action, params);
100
+ const originalTier = getApprovalTier(action, params);
101
+ const tier = effective.prompt ? (originalTier === 'autonomous' ? 'gated' : originalTier) : 'autonomous';
102
+ // An unverified caller never gains an exemption through a level.
103
+ if (effective.applied && !context.verified)
104
+ return { allowed: false, tier, reason: 'Verified channel required.' };
105
+ await auditApprovalLevel(action, effective);
97
106
  if (tier === 'autonomous')
98
107
  return { allowed: true, tier };
99
108
  if (!context.verified) {
@@ -267,20 +276,26 @@ function approvalParamsEqual(expected, actual) {
267
276
  * and returns instructions for the agent to wait.
268
277
  */
269
278
  export async function requestApproval(action, params, apiKeyId, channelContext, display) {
270
- const tier = getApprovalTier(action, params);
279
+ const effective = await effectiveApproval(action, params);
280
+ const originalTier = getApprovalTier(action, params);
281
+ const tier = originalTier === 'autonomous' ? 'gated' : originalTier;
271
282
  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
272
283
  const { channel, verified } = channelContext ?? currentChannelContext();
273
284
  const actor = requestContext();
274
285
  // Ignore any model-supplied reserved field; freeze facts read by the server.
275
286
  const cleanParams = withoutApprovalSnapshot(params);
276
- const snapshot = await captureSnapshot(action, cleanParams);
287
+ const snapshot = { ...await captureSnapshot(action, cleanParams), ...(effective.applied ? { approval_class: effective.class, approval_channel_rule: effective.channelRule } : {}),
288
+ ...(display && 'recipient_count' in display ? { display_recipient_count: display.recipient_count } : {}) };
277
289
  const payload = cleanParams.payload;
278
290
  if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
279
291
  !approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
280
292
  throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
281
293
  }
282
294
  const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
283
- const summary = buildApprovalSummary(action, frozenParams, display);
295
+ const detailSummary = buildApprovalSummary(action, frozenParams, display);
296
+ const preview = buildApprovalPreview({ action, params: frozenParams, summary: detailSummary,
297
+ ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) });
298
+ const summary = `${preview.plain_summary.split('\n')[0]}\n${detailSummary}`;
284
299
  const { data, error } = await approvalStore()
285
300
  .from('action_approvals')
286
301
  .insert({
@@ -303,7 +318,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
303
318
  throw new Error(`Failed to create approval request: ${error.message}`);
304
319
  let projectName;
305
320
  let sandboxDisplayRead = false;
306
- const sandboxApproved = tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
321
+ const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || effective.class === 'F')) && tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
307
322
  if (isTesterRequest(action))
308
323
  await logTesterRequestAudit('tester_request', action, data.id, null);
309
324
  fireWebhookEvent('approval.requested', {
@@ -337,7 +352,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
337
352
  : '';
338
353
  const result = await elicitApproval({
339
354
  requires_approval: true,
340
- ...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
355
+ ...(effective.channelRule === 'human_only' ? {} : await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
341
356
  payload_hash: approvalPayloadHash(action, frozenParams) })),
342
357
  approval_id: data.id,
343
358
  tier,
@@ -347,8 +362,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
347
362
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
348
363
  approve_url: approveUrl,
349
364
  preview_text: previewText,
350
- message: `Tap to approve: ${approveUrl}\n${previewText}\nIt stays open for 24 hours. In a terminal with prompts you can also answer the on-screen Accept/Decline.${chatGuidance}`,
351
- }, { action, params: frozenParams, summary, projectName }, getProjectId());
365
+ message: `${previewText}\nTap to approve: ${approveUrl}\nIt stays open for 24 hours. ${effective.channelRule === 'human_only' ? 'Use the dashboard link or a typed chat reply.' : 'In a terminal with prompts you can also answer the on-screen Accept/Decline.'}${chatGuidance}`,
366
+ }, { action, params: frozenParams, summary, projectName, channelRule: effective.channelRule }, getProjectId());
352
367
  if (result.decision_status === 'approved') {
353
368
  return { ...result, message: 'Approved. Call this tool again with the same arguments plus approval_id to run it.' };
354
369
  }
@@ -444,6 +459,9 @@ async function checkApprovalState(approvalId, options = {}) {
444
459
  !await sandboxApprovalEligible(getProjectId(), data.action))) {
445
460
  return { approved: false, status: 'sandbox_decision_no_longer_eligible' };
446
461
  }
462
+ if (data.status === 'approved' && data.params?.__approval_snapshot?.approval_channel_rule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
463
+ return { approved: false, status: 'human_decision_required' };
464
+ }
447
465
  if (data.status === 'approved' && !sandboxDecision && (!isHumanApprovalChannel(data.decided_via) ||
448
466
  (isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
449
467
  (!['mcp_app', 'mcp_elicitation', 'chat_confirm', 'mcp_person'].includes(data.decided_via) ||
@@ -494,6 +512,9 @@ async function checkApprovalState(approvalId, options = {}) {
494
512
  if (claimError || !claimed) {
495
513
  return { approved: false, status: 'consumed' };
496
514
  }
515
+ if ((await effectiveApproval(data.action, withoutApprovalSnapshot(data.params))).channelRule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
516
+ return { approved: false, status: 'human_decision_required' };
517
+ }
497
518
  return {
498
519
  approved: true,
499
520
  status: 'approved',
@@ -505,6 +526,30 @@ async function checkApprovalState(approvalId, options = {}) {
505
526
  function toolText(value) {
506
527
  return { content: [{ type: 'text', text: value }] };
507
528
  }
529
+ const satisfiedApproval = new AsyncLocalStorage();
530
+ export function approvalSatisfied(action) {
531
+ const authority = registeredApproval.getStore();
532
+ return satisfiedApproval.getStore() === action || (authority?.phase === 'level' && authority.action === action && authority.projectId === getProjectId());
533
+ }
534
+ /** Catch preview/draft branches that historically returned before their hand-written gate.
535
+ * Existing gated execution paths retain their frozen-payload approval flow. */
536
+ export function withPreviewApproval(action, handler) {
537
+ return async (args, extra) => {
538
+ const params = args;
539
+ const previewBranch = (['launch_campaign', 'create_campaign_with_content'].includes(action) && params.confirmed !== true) ||
540
+ ['update_campaign', 'set_campaign_trigger'].includes(action);
541
+ if (!previewBranch && getApprovalTier(action, params) !== 'autonomous')
542
+ return handler(args, extra);
543
+ const decision = await effectiveApproval(action, args);
544
+ if (!decision.applied || (previewBranch && !['launch_campaign', 'create_campaign_with_content'].includes(action) && decision.level !== 'ask_every_time'))
545
+ return handler(args, extra);
546
+ if (!decision.prompt) {
547
+ await auditApprovalLevel(action, decision);
548
+ return satisfiedApproval.run(action, () => handler(args, extra));
549
+ }
550
+ return withApproval(action, (params, innerExtra) => satisfiedApproval.run(action, () => handler(params, innerExtra)))(args, extra);
551
+ };
552
+ }
508
553
  /**
509
554
  * Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
510
555
  * (no approval_id) records the exact arguments and returns the approval request without running
@@ -530,13 +575,13 @@ export function withApproval(action, handler, options = {}) {
530
575
  return async (args, extra) => {
531
576
  const { approval_id, ...params } = args;
532
577
  const channelContext = currentChannelContext();
533
- const decision = assertChannelCanExecute(action, params, channelContext);
578
+ const decision = await assertChannelCanExecute(action, params, channelContext);
534
579
  if (!decision.allowed)
535
580
  return toolText(decision.reason ?? 'This channel cannot request this action.');
536
581
  let decidedBy;
537
582
  let decisionChannel;
538
583
  let consumedAt;
539
- if (options.registeredRequestOnly || decision.tier !== 'autonomous') {
584
+ if (decision.tier !== 'autonomous') {
540
585
  if (!approval_id) {
541
586
  const display = options.display ? await options.display(params) : undefined;
542
587
  return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
@@ -550,7 +595,8 @@ export function withApproval(action, handler, options = {}) {
550
595
  }
551
596
  }
552
597
  if (options.registeredRequestOnly) {
553
- return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: 'approved', decidedBy, decisionChannel }, () => handler(args, extra));
598
+ return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: decision.tier === 'autonomous' ? 'level' : 'approved',
599
+ ...(decision.tier === 'autonomous' ? { levelUserId: requestContext()?.actorId ?? undefined } : {}), decidedBy, decisionChannel }, () => handler(args, extra));
554
600
  }
555
601
  const result = await handler(params, extra);
556
602
  // Keep the atomic claim across the outbound hop. Only a definitive pre-acceptance
@@ -0,0 +1,17 @@
1
+ import { type ApprovalClass, type ApprovalLevel } from '@sendinel/domain';
2
+ import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
3
+ export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
4
+ export type { ApprovalClass, ApprovalLevel } from '@sendinel/domain';
5
+ export type EffectiveApproval = {
6
+ prompt: boolean;
7
+ channelRule: 'any' | 'human_only';
8
+ class: ApprovalClass | undefined;
9
+ level: ApprovalLevel;
10
+ applied: boolean;
11
+ };
12
+ /** No transport-supplied role or preference grants authority: verify the live person grant. */
13
+ export declare function personApprovalLevel(ctx?: HumanDecisionIdentity | undefined, action?: string): Promise<ApprovalLevel | null>;
14
+ /** One decision function. Lookup errors preserve today's tier, including preview exceptions. */
15
+ export declare function effectiveApproval(action: string, params?: Record<string, unknown>, ctx?: HumanDecisionIdentity | undefined): Promise<EffectiveApproval>;
16
+ /** Audit must succeed before a level can authorize execution. Never turn an audit failure into a send. */
17
+ export declare function auditApprovalLevel(action: string, decision: EffectiveApproval): Promise<void>;
@@ -0,0 +1,107 @@
1
+ import { approvalClass, isApprovalLevel, matchSendAllowlist, readContactTestStatus } from '@sendinel/domain';
2
+ import { adminDb, requestContext } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { humanApprovalEligible } from './approval-human-eligibility.js';
5
+ import { getApprovalTier } from './action-approvals.js';
6
+ export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
7
+ /** No transport-supplied role or preference grants authority: verify the live person grant. */
8
+ export async function personApprovalLevel(ctx = requestContext(), action = 'get_approval_level') {
9
+ if (ctx?.scopes?.some(scope => scope === 'service' || scope.startsWith('service:')))
10
+ return null;
11
+ try {
12
+ if (!await humanApprovalEligible(action, getProjectId(), ctx, false, () => adminDb().schema('email')))
13
+ return null;
14
+ const { data, error } = await adminDb().schema('email').from('approval_preferences')
15
+ .select('level').eq('user_id', ctx.actorId).maybeSingle();
16
+ if (error || (data && !isApprovalLevel(data.level)))
17
+ return null;
18
+ return data?.level ?? 'default';
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /** Explicit mailbox allowlists only: a NULL policy means unrestricted, never test-safe. */
25
+ async function safeAddresses(addresses, ctx) {
26
+ if (!addresses.length || !ctx.actorId)
27
+ return false;
28
+ const client = adminDb();
29
+ const { data, error } = await client.auth.admin.getUserById(ctx.actorId);
30
+ if (error || !data?.user?.email)
31
+ return false;
32
+ const remaining = addresses.filter(address => address.trim().toLowerCase() !== data.user.email.toLowerCase());
33
+ if (!remaining.length)
34
+ return true;
35
+ const project = await client.schema('email').from('projects')
36
+ .select('send_allowlist, org_id, org:organizations(send_allowlist)').eq('id', getProjectId()).single();
37
+ if (project.error || !project.data)
38
+ return false;
39
+ const lists = [project.data.send_allowlist, project.data.org?.send_allowlist].filter(Array.isArray);
40
+ return remaining.every(address => lists.some(list => !matchSendAllowlist(list, { to: address })));
41
+ }
42
+ async function exemptTargets(action, params, ctx) {
43
+ try {
44
+ if (action === 'draft_agent_reply')
45
+ return true;
46
+ if (action === 'send_test_email') {
47
+ // Include every copied recipient defensively; never infer recipients from display text.
48
+ const copied = [params.cc, params.bcc].filter(value => value !== undefined);
49
+ if (copied.some(value => !Array.isArray(value) || value.some(address => typeof address !== 'string')))
50
+ return false;
51
+ return typeof params.to_email === 'string' && await safeAddresses([params.to_email, ...copied.flat()], ctx);
52
+ }
53
+ // Filters and segments are open audiences, never an exemption based on a sample.
54
+ if (['enroll_segment', 'add_list_members_by_filter'].includes(action))
55
+ return false;
56
+ const ids = Array.isArray(params.contact_ids) ? params.contact_ids : params.contact_id ? [params.contact_id] : [];
57
+ if (ids.length) {
58
+ if (ids.some(id => typeof id !== 'string'))
59
+ return false;
60
+ const contacts = await readContactTestStatus(adminDb(), getProjectId(), ids);
61
+ return (await Promise.all(contacts.map(contact => contact.is_test === true ||
62
+ (typeof contact.email === 'string' && safeAddresses([contact.email], ctx))))).every(Boolean);
63
+ }
64
+ // Never trust a model-supplied is_test flag. Read the saved record by its address.
65
+ const address = params.email;
66
+ if (typeof address !== 'string')
67
+ return false;
68
+ if (await safeAddresses([address], ctx))
69
+ return true;
70
+ const { data, error } = await adminDb().schema('email').from('subscribers')
71
+ .select('is_test').eq('project_id', getProjectId()).eq('email', address.trim().toLowerCase()).maybeSingle();
72
+ return !error && data?.is_test === true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /** One decision function. Lookup errors preserve today's tier, including preview exceptions. */
79
+ export async function effectiveApproval(action, params = {}, ctx = requestContext()) {
80
+ const baseline = getApprovalTier(action, params) !== 'autonomous';
81
+ let kind = approvalClass(action);
82
+ const level = kind ? await personApprovalLevel(ctx, action) : null;
83
+ if (!level)
84
+ return { prompt: baseline, channelRule: 'any', class: kind, level: 'default', applied: false };
85
+ const exempt = (kind === 'T' || kind === 'E') && await exemptTargets(action, params, ctx);
86
+ if (action === 'send_test_email' && !exempt)
87
+ kind = 'S';
88
+ // Live trigger edits have an additional domain gate even under the legacy autonomous mode.
89
+ const triggerGate = ['update_campaign', 'set_campaign_trigger'].includes(action) && !!params.previous_campaign && !!params.updates;
90
+ const prompt = level === 'ask_every_time' ? true : level === 'auto' ? (kind === 'F' || kind === 'D') : (baseline || triggerGate) && !exempt;
91
+ return { prompt, class: kind, level, applied: true,
92
+ channelRule: level === 'ask_every_time' && (kind === 'S' || kind === 'F' || (kind === 'E' && !exempt)) ? 'human_only' : 'any' };
93
+ }
94
+ /** Audit must succeed before a level can authorize execution. Never turn an audit failure into a send. */
95
+ export async function auditApprovalLevel(action, decision) {
96
+ if (decision.prompt || !decision.applied || (decision.level === 'default' && decision.class !== 'T' && decision.class !== 'E'))
97
+ return;
98
+ const ctx = requestContext();
99
+ const { error } = await adminDb().schema('email').from('audit_log').insert({
100
+ project_id: getProjectId(), actor_type: 'user', actor_id: ctx.actorId,
101
+ action: 'approval.level_applied', resource_type: 'approval',
102
+ details: { action, class: decision.class, level: decision.level, user_id: ctx.actorId,
103
+ connection_id: `oauth:${ctx.oauthTokenId}`, reason: 'level' },
104
+ });
105
+ if (error)
106
+ throw new Error('Could not record your approval level. Nothing was run.');
107
+ }
@@ -57,6 +57,7 @@ export declare function elicitApproval<T extends ApprovalFallback>(result: T, fr
57
57
  params: unknown;
58
58
  summary: string;
59
59
  projectName?: string;
60
+ channelRule?: 'any' | 'human_only';
60
61
  }, projectId: string): Promise<T & {
61
62
  decision_status: ElicitationDecision;
62
63
  }>;
@@ -29,6 +29,8 @@ export async function elicitApproval(result, frozen, projectId) {
29
29
  const pending = { ...result, decision_status: 'decision_pending' };
30
30
  const warn = (reason) => console.warn('[approval_wait]', { stage: 'elicitation', reason });
31
31
  const exit = (reason) => { warn(reason); return pending; };
32
+ if (frozen.channelRule === 'human_only')
33
+ return exit('human_only');
32
34
  const context = requests.getStore();
33
35
  if (!context)
34
36
  return exit('no_transport_context');
@@ -1,3 +1,4 @@
1
+ import { effectiveApproval } from './approval-classes.js';
1
2
  import { humanApprovalEligible } from './approval-human-eligibility.js';
2
3
  import { requestContext } from '../db.js';
3
4
  import { randomUUID } from 'node:crypto';
@@ -21,11 +22,14 @@ export function createHumanApprovalCallback(binding) {
21
22
  const source = approvalStore();
22
23
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
23
24
  const { data: approval, error: loadError } = await client.from('action_approvals')
24
- .select('action').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
25
+ .select('action, params').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
25
26
  if (loadError || typeof approval?.action !== 'string' || !approval.action ||
26
27
  requestContext()?.actorId !== binding.userId ||
27
28
  !await humanApprovalEligible(approval.action, binding.projectId))
28
29
  return false;
30
+ if (approval.params?.__approval_snapshot?.approval_channel_rule === 'human_only' ||
31
+ (await effectiveApproval(approval.action, approval.params)).channelRule === 'human_only')
32
+ return false;
29
33
  const now = new Date().toISOString();
30
34
  const { data, error } = await client.from('action_approvals')
31
35
  .update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
@@ -1,3 +1,4 @@
1
+ import { approvalStore } from './tester-requests-store.js';
1
2
  export type HumanDecisionIdentity = {
2
3
  projectId?: string;
3
4
  actorId?: string | null;
@@ -11,7 +12,7 @@ export type HumanDecisionIdentity = {
11
12
  * (for admin requests) the project's org membership on every prompt and every decision.
12
13
  * Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
13
14
  */
14
- export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined, requireChatConfirm?: boolean): Promise<boolean>;
15
+ export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined, requireChatConfirm?: boolean, store?: () => ReturnType<typeof approvalStore>): Promise<boolean>;
15
16
  /** Opt-in is a live grant fact as well as a transport snapshot; errors never enable it. */
16
17
  export declare function chatConfirmConnectionEnabled(identity?: {
17
18
  scopes?: readonly string[];
@@ -5,14 +5,14 @@ import { isHumanRequestableAdminTool } from './admin-tools.js';
5
5
  * (for admin requests) the project's org membership on every prompt and every decision.
6
6
  * Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
7
7
  */
8
- export async function humanApprovalEligible(action, projectId, identity = requestContext(), requireChatConfirm = false) {
8
+ export async function humanApprovalEligible(action, projectId, identity = requestContext(), requireChatConfirm = false, store) {
9
9
  if (!identity || identity.channelVerified !== true || identity.authKind !== 'oauth' ||
10
10
  (requireChatConfirm && identity.chatConfirmEnabled !== true) ||
11
11
  !identity.actorId || !identity.oauthTokenId || identity.projectId !== projectId || !projectId ||
12
12
  identity.scopes?.some(scope => scope === 'tester' || scope === 'agent'))
13
13
  return false;
14
14
  try {
15
- const source = approvalStore();
15
+ const source = store ? store() : approvalStore();
16
16
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
17
17
  const { data: grant, error } = await client.from('mcp_oauth_tokens')
18
18
  .select('id, user_id, connection_role, scopes, revoked_at, expires_at, chat_confirm_enabled')
@@ -23,6 +23,15 @@ export declare function captureApprovalDisplay(action: string, params: Params, p
23
23
  /** Exclude only optional display facts from revalidation, never from the payload hash. */
24
24
  export declare function withoutApprovalDisplay(snapshot: Params): Params;
25
25
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
26
- export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
26
+ export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<{
27
+ version: number;
28
+ invitation: Params;
29
+ recipient_count: number;
30
+ } | {
31
+ display?: {
32
+ target_name: string;
33
+ target_detail?: string | undefined;
34
+ } | undefined;
35
+ }>;
27
36
  export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
28
37
  export {};
@@ -1,6 +1,6 @@
1
1
  import { isTesterRequest } from './tester-policy.js';
2
2
  import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
3
- import { readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
3
+ import { campaignMatchesTrigger, normalizeTriggerList, readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
4
4
  import { callSendinelV1 } from './v1-client.js';
5
5
  import { adminDb, db } from '../db.js';
6
6
  import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
@@ -155,9 +155,45 @@ export async function captureApprovalDisplay(action, params, projectId) {
155
155
  }
156
156
  /** Exclude only optional display facts from revalidation, never from the payload hash. */
157
157
  export function withoutApprovalDisplay(snapshot) {
158
- const { display: _display, ...rest } = snapshot;
158
+ const { display: _display, approval_class: _class, approval_channel_rule: _rule, display_recipient_count: _count, ...rest } = snapshot;
159
159
  return rest;
160
160
  }
161
+ /** Potential sending campaigns, using the intake matchers. Unknown is never called no-send. */
162
+ async function captureEnrollmentConsequence(action, params, projectId) {
163
+ if (!['identify_subscriber', 'emit_contact_event', 'enroll_contact', 'enroll_segment'].includes(action))
164
+ return {};
165
+ const source = db();
166
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
167
+ if (action === 'enroll_contact' || action === 'enroll_segment') {
168
+ const { data, error } = await client.from('campaigns').select('id, name').eq('project_id', projectId).eq('id', params.campaign_id).maybeSingle();
169
+ if (error || !data)
170
+ throw new Error('Approval enrollment campaign unavailable');
171
+ let contact_email;
172
+ if (params.contact_id) {
173
+ const contact = await client.from('subscribers').select('email').eq('id', params.contact_id).eq('project_id', projectId).maybeSingle();
174
+ if (contact.error || !contact.data)
175
+ throw new Error('Approval contact unavailable');
176
+ contact_email = contact.data.email;
177
+ }
178
+ return { enrollment_campaigns: [data], ...(contact_email ? { contact_email } : {}) };
179
+ }
180
+ const campaigns = await readAllRows(client.from('campaigns')
181
+ .select('id, name, site_id, type, trigger_event_name, trigger_tag, trigger_config')
182
+ .eq('project_id', projectId).eq('status', 'active'));
183
+ let tags = Array.isArray(params.tags) ? params.tags : [];
184
+ if (action === 'emit_contact_event') {
185
+ const contact = await client.from('subscribers').select('tags').eq('project_id', projectId)
186
+ .eq('email', params.email).maybeSingle();
187
+ if (contact.error)
188
+ throw new Error('Approval event contact unavailable');
189
+ tags = normalizeTriggerList(contact.data?.tags ?? []);
190
+ }
191
+ const matches = campaigns.filter(campaign => action === 'identify_subscriber'
192
+ ? ((params.site_id && params.consent !== 'customer' && campaign.site_id === params.site_id && campaign.trigger_event_name === 'signup') ||
193
+ (campaign.type === 'triggered' && tags.some((tag) => campaignMatchesTrigger(campaign, { tags: [tag] }))))
194
+ : campaign.type === 'triggered' && campaignMatchesTrigger(campaign, { event: params.event, tags, data: params.data }));
195
+ return { enrollment_campaigns: matches.map(campaign => ({ id: campaign.id, name: campaign.name })).sort((a, b) => String(a.id).localeCompare(String(b.id))) };
196
+ }
161
197
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
162
198
  export async function captureApprovalSnapshot(action, params, projectId) {
163
199
  if (action === 'invite_team_member') {
@@ -169,7 +205,8 @@ export async function captureApprovalSnapshot(action, params, projectId) {
169
205
  return { version: 1, invitation: preview, recipient_count: 1 };
170
206
  }
171
207
  const display = await captureApprovalDisplay(action, params, projectId);
172
- const withDisplay = (value) => display ? { ...value, display } : value;
208
+ const enrollment = await captureEnrollmentConsequence(action, params, projectId).catch(() => ({}));
209
+ const withDisplay = (value) => ({ ...value, ...enrollment, ...(display ? { display } : {}) });
173
210
  const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
174
211
  const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
175
212
  if (!TARGET_ACTIONS.has(action))
@@ -1,3 +1,4 @@
1
+ import { effectiveApproval } from './approval-classes.js';
1
2
  import { requestContext } from '../db.js';
2
3
  import { getProjectId } from '../project.js';
3
4
  import { humanApprovalEligible } from './approval-human-eligibility.js';
@@ -81,6 +82,9 @@ export async function approveApproval(approvalId, transportClientName) {
81
82
  if (!await humanApprovalEligible(approval.action, projectId, actor)) {
82
83
  return refuse('approval_forbidden', 'This approval requires a person with the required project role.');
83
84
  }
85
+ if (approval.params?.__approval_snapshot?.approval_channel_rule === 'human_only' || (await effectiveApproval(approval.action, approval.params)).channelRule === 'human_only') {
86
+ return refuse('human_decision_required', 'Use the dashboard link or confirm with your typed reply in chat.');
87
+ }
84
88
  const name = await clientName(transportClientName);
85
89
  const now = new Date().toISOString();
86
90
  const decision = client.from('action_approvals')
@@ -1,10 +1,11 @@
1
+ import { effectiveApproval } from './approval-classes.js';
1
2
  import { isApplyingTesterRequest } from './tester-policy.js';
2
3
  import { isDeepStrictEqual } from 'node:util';
3
4
  import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
4
5
  import { db } from '../db.js';
5
6
  import { getProjectId } from '../project.js';
6
7
  import { logAudit } from '../audit.js';
7
- import { assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
8
+ import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
8
9
  export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, period_key, updated_at';
9
10
  export const campaignEditError = (error, code) => ({ error, code });
10
11
  /** All writes include the revision used for the approval decision, including ungated drafts. */
@@ -23,13 +24,16 @@ export async function finishCampaignEdit(action, input, existing, updates, reque
23
24
  if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
24
25
  return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
25
26
  }
26
- if (isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
27
+ if (approvalSatisfied(action) || isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
27
28
  return writeEdit(input.campaign_id, existing, updates);
28
29
  const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
29
30
  const context = currentChannelContext();
30
- const decision = assertChannelCanExecute(action, params, context);
31
+ const decision = await assertChannelCanExecute(action, params, context);
31
32
  if (!decision.allowed)
32
33
  return campaignEditError(decision.reason ?? 'Channel cannot request approval', 'FORBIDDEN');
34
+ const effective = await effectiveApproval(action, params);
35
+ if (effective.applied && effective.level === 'auto' && !effective.prompt)
36
+ return writeEdit(input.campaign_id, existing, updates);
33
37
  // This trigger policy requires a human decision even if the integration uses autonomous mode.
34
38
  return request(params);
35
39
  }
@@ -57,7 +61,7 @@ export async function resumeCampaignEdit(action, approvalId, input) {
57
61
  }
58
62
  if (!Number.isFinite(Date.parse(approval.expires_at)) || Date.parse(approval.expires_at) <= Date.now())
59
63
  return campaignEditError('Approval expired', 'APPROVAL_EXPIRED');
60
- const decision = assertChannelCanExecute(action, params, currentChannelContext());
64
+ const decision = await assertChannelCanExecute(action, params, currentChannelContext());
61
65
  if (!decision.allowed)
62
66
  return campaignEditError(decision.reason ?? 'Channel cannot use approval', 'FORBIDDEN');
63
67
  const check = await checkApproval(approvalId, { consume: true, action, params });
@@ -94,6 +94,7 @@ export const API_USAGE_ERROR_CODE_MAP = {
94
94
  'validation_failed': 'validation_failed',
95
95
  'approval_not_cancellable': 'conflict',
96
96
  'approval_not_pending': 'conflict',
97
+ 'human_decision_required': 'forbidden',
97
98
  '23505': 'conflict',
98
99
  'conflict': 'conflict',
99
100
  'content_changed_since_approval': 'conflict',
package/build/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isApprovalGatedAction } from './lib/approval-actions.js';
1
2
  import { withToolErrors, toolError, installToolErrorBoundary } from './lib/db-error.js';
2
3
  import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
3
4
  import { installApprovalApp } from './lib/approval-app.js';
@@ -7,7 +8,7 @@ import { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
7
8
  import { testerRequestTool } from './lib/tester-requests.js';
8
9
  export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
9
10
  import { ADMIN_TOOLS, isHumanRequestableAdminTool } from './lib/admin-tools.js';
10
- import { withApproval, isApprovalGatedAction } from './lib/action-approvals.js';
11
+ import { withPreviewApproval, withApproval } from './lib/action-approvals.js';
11
12
  import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
12
13
  import { registerAnalyticsTools } from './tools/analytics.js';
13
14
  import { registerCampaignTools } from './tools/campaigns.js';
@@ -318,6 +319,10 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
318
319
  if (wouldBlock)
319
320
  return; // omit from tool list entirely
320
321
  }
322
+ if (isApprovalGatedAction(name)) {
323
+ const originalHandler = handler;
324
+ handler = (args, extra) => withPreviewApproval(name, originalHandler)(args, extra);
325
+ }
321
326
  // ── Scope enforcement (only when scopes are provided) ─────────────
322
327
  if (scopes !== null) {
323
328
  const required = getRequiredScope(name);
@@ -1,3 +1,5 @@
1
+ import { personApprovalLevel } from '../lib/approval-classes.js';
2
+ import { APPROVAL_LEVEL_MEANING } from '@sendinel/domain';
1
3
  import { z } from 'zod';
2
4
  import { db } from '../db.js';
3
5
  import { getProjectId } from '../project.js';
@@ -9,6 +11,16 @@ import { approvalClientName } from '../lib/approval-capabilities.js';
9
11
  const txt = (text) => ({ content: [{ type: 'text', text }] });
10
12
  const j = (data) => txt(JSON.stringify(data, null, 2));
11
13
  export function registerApprovalTools(server, options) {
14
+ server.registerTool('get_approval_level', {
15
+ title: 'Get Approval Level',
16
+ description: 'Read your own approval setting. Only you can change it in dashboard settings; an AI cannot turn on Auto.',
17
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
18
+ inputSchema: {},
19
+ }, async () => {
20
+ const level = await personApprovalLevel();
21
+ return j({ level: level ?? 'default', applies_to_connection: level !== null,
22
+ meaning: level ? APPROVAL_LEVEL_MEANING[level] : 'Your connection keeps its usual approval checks.' });
23
+ });
12
24
  server.registerTool('cancel_approval', {
13
25
  title: 'Cancel Approval',
14
26
  description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected; cannot approve or undo cancellation.',
@@ -289,6 +289,38 @@ export function registerCampaignSetTools(server) {
289
289
  }, async (args) => {
290
290
  const projectId = getProjectId();
291
291
  const { approval_id } = args;
292
+ async function applyCampaigns(set, ids, approval_id, decidedBy) {
293
+ const activated = [];
294
+ const failed = [];
295
+ for (const c of set.campaigns) {
296
+ const { error } = await db()
297
+ .from('campaigns')
298
+ .update({ status: 'active', updated_at: new Date().toISOString() })
299
+ .eq('id', c.id)
300
+ .eq('project_id', projectId)
301
+ .in('status', ['draft', 'paused']);
302
+ if (error) {
303
+ failed.push({ campaign_id: c.id, ...dbToolError(error) });
304
+ continue;
305
+ }
306
+ activated.push(c.id);
307
+ fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
308
+ }
309
+ await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: decidedBy ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
310
+ return j({
311
+ activated,
312
+ failed,
313
+ ...(set.campaigns.some(c => c.type === 'broadcast') ? {
314
+ campaigns: previewOf(set.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), set.steps),
315
+ broadcast_activation: BROADCAST_ACTIVATION,
316
+ } : {}),
317
+ message: failed.length
318
+ ? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
319
+ : set.campaigns.some(c => c.type === 'broadcast')
320
+ ? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
321
+ : `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
322
+ });
323
+ }
292
324
  if (approval_id) {
293
325
  const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
294
326
  if (extras.length)
@@ -318,36 +350,7 @@ export function registerCampaignSetTools(server) {
318
350
  const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
319
351
  if (!check.approved)
320
352
  return j({ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`, code: 'validation_failed', approval_status: check.status });
321
- const activated = [];
322
- const failed = [];
323
- for (const c of current.campaigns) {
324
- const { error } = await db()
325
- .from('campaigns')
326
- .update({ status: 'active', updated_at: new Date().toISOString() })
327
- .eq('id', c.id)
328
- .eq('project_id', projectId)
329
- .in('status', ['draft', 'paused']);
330
- if (error) {
331
- failed.push({ campaign_id: c.id, ...dbToolError(error) });
332
- continue;
333
- }
334
- activated.push(c.id);
335
- fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
336
- }
337
- await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: check.decided_by ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
338
- return j({
339
- activated,
340
- failed,
341
- ...(current.campaigns.some(c => c.type === 'broadcast') ? {
342
- campaigns: previewOf(current.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), current.steps),
343
- broadcast_activation: BROADCAST_ACTIVATION,
344
- } : {}),
345
- message: failed.length
346
- ? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
347
- : current.campaigns.some(c => c.type === 'broadcast')
348
- ? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
349
- : `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
350
- });
353
+ return applyCampaigns(current, ids, approval_id, check.decided_by);
351
354
  }
352
355
  // Call 1: build one approval for the exact set.
353
356
  let ids = args.campaign_ids ?? [];
@@ -391,9 +394,11 @@ export function registerCampaignSetTools(server) {
391
394
  const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
392
395
  const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
393
396
  const channelContext = currentChannelContext();
394
- const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
397
+ const decision = await assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
395
398
  if (!decision.allowed)
396
399
  return j({ error: decision.reason ?? 'This channel cannot request this action.', code: 'forbidden' });
400
+ if (decision.tier === 'autonomous')
401
+ return applyCampaigns(set, ids);
397
402
  const names = set.campaigns.map((c) => c.name);
398
403
  const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
399
404
  campaign_count: set.campaigns.length,
@@ -1055,7 +1055,7 @@ export function registerCampaignTools(server) {
1055
1055
  const approvalParams = { campaign_id, status };
1056
1056
  if (!approval_id) {
1057
1057
  const channelContext = currentChannelContext();
1058
- const decision = assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
1058
+ const decision = await assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
1059
1059
  if (!decision.allowed)
1060
1060
  return txt(decision.reason ?? 'This channel cannot request this action.');
1061
1061
  if (decision.tier !== 'autonomous') {
@@ -1366,7 +1366,7 @@ export function registerCampaignTools(server) {
1366
1366
  });
1367
1367
  }
1368
1368
  const channelContext = currentChannelContext();
1369
- const decision = assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
1369
+ const decision = await assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
1370
1370
  if (!decision.allowed)
1371
1371
  return txt(decision.reason ?? 'This channel cannot request this action.');
1372
1372
  if (decision.tier !== 'autonomous') {
@@ -1462,29 +1462,7 @@ export function registerCampaignTools(server) {
1462
1462
  }, async (args) => {
1463
1463
  const projectId = getProjectId();
1464
1464
  const { approval_id } = args;
1465
- // ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
1466
- if (approval_id) {
1467
- const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
1468
- if (extras.length) {
1469
- return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
1470
- }
1471
- const { data: row, error: rowErr } = await db()
1472
- .from('action_approvals')
1473
- .select('params')
1474
- .eq('id', approval_id)
1475
- .eq('project_id', projectId)
1476
- .eq('action', 'send_broadcast')
1477
- .maybeSingle();
1478
- if (rowErr)
1479
- return j({ ...dbToolError(rowErr), code: 'APPROVAL_LOOKUP_FAILED' });
1480
- if (!row)
1481
- return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
1482
- const stored = (row.params ?? {});
1483
- const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
1484
- if (!check.approved) {
1485
- return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1486
- }
1487
- const payload = (stored.payload ?? {});
1465
+ async function applyBroadcast(payload, approval_id, decidedBy) {
1488
1466
  const contactIds = Array.isArray(payload.contact_ids) ? payload.contact_ids.map(String) : [];
1489
1467
  if (payload.send_at && new Date(payload.send_at).getTime() <= Date.now()) {
1490
1468
  // An approved future time that has now passed: send right away rather than silently skip.
@@ -1514,7 +1492,7 @@ export function registerCampaignTools(server) {
1514
1492
  action: 'campaign.send_broadcast',
1515
1493
  resourceType: 'campaign',
1516
1494
  resourceId: executed.campaignId,
1517
- details: { approval_id, decided_by: check.decided_by ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
1495
+ details: { approval_id, decided_by: decidedBy ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
1518
1496
  }).catch(() => { });
1519
1497
  fireWebhookEvent('campaign.activated', {
1520
1498
  campaign_id: executed.campaignId,
@@ -1535,6 +1513,30 @@ export function registerCampaignTools(server) {
1535
1513
  : `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
1536
1514
  });
1537
1515
  }
1516
+ // ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
1517
+ if (approval_id) {
1518
+ const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
1519
+ if (extras.length) {
1520
+ return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
1521
+ }
1522
+ const { data: row, error: rowErr } = await db()
1523
+ .from('action_approvals')
1524
+ .select('params')
1525
+ .eq('id', approval_id)
1526
+ .eq('project_id', projectId)
1527
+ .eq('action', 'send_broadcast')
1528
+ .maybeSingle();
1529
+ if (rowErr)
1530
+ return j({ ...dbToolError(rowErr), code: 'APPROVAL_LOOKUP_FAILED' });
1531
+ if (!row)
1532
+ return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
1533
+ const stored = (row.params ?? {});
1534
+ const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
1535
+ if (!check.approved) {
1536
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1537
+ }
1538
+ return applyBroadcast((stored.payload ?? {}), approval_id, check.decided_by);
1539
+ }
1538
1540
  // ── Call 1: resolve, render, count, request approval. Sends nothing. ──
1539
1541
  const { audience, segment_id, brief, html, subject, body_text, site_id, send_at, name, send_class, brand_asset_ids } = args;
1540
1542
  if (Boolean(audience) === Boolean(segment_id))
@@ -1641,9 +1643,11 @@ export function registerCampaignTools(server) {
1641
1643
  };
1642
1644
  const approvalParams = { payload };
1643
1645
  const channelContext = currentChannelContext();
1644
- const decision = assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
1646
+ const decision = await assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
1645
1647
  if (!decision.allowed)
1646
1648
  return txt(decision.reason ?? 'This channel cannot request this action.');
1649
+ if (decision.tier === 'autonomous')
1650
+ return applyBroadcast(payload);
1647
1651
  const approval = await requestApproval('send_broadcast', approvalParams, undefined, channelContext, {
1648
1652
  subject: content.subject,
1649
1653
  segment_name: segment_id ? segmentName : null,
@@ -2111,7 +2115,7 @@ Adapt your tone accordingly:
2111
2115
  const channelContext = currentChannelContext();
2112
2116
  let needsApproval = false;
2113
2117
  if (confirmed) {
2114
- const decision = assertChannelCanExecute('launch_campaign', requestParams, channelContext);
2118
+ const decision = await assertChannelCanExecute('launch_campaign', requestParams, channelContext);
2115
2119
  if (!decision.allowed)
2116
2120
  return txt(decision.reason ?? 'This channel cannot request this action.');
2117
2121
  needsApproval = decision.tier !== 'autonomous';
@@ -3205,7 +3209,7 @@ Return ONLY valid JSON with this exact shape:
3205
3209
  if (activate && confirmed) {
3206
3210
  const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, reentry_mode, period_key, ...triggerInput };
3207
3211
  const channelContext = currentChannelContext();
3208
- const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
3212
+ const decision = await assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
3209
3213
  if (!decision.allowed)
3210
3214
  return txt(decision.reason ?? 'This channel cannot request this action.');
3211
3215
  if (decision.tier !== 'autonomous') {
@@ -97,7 +97,7 @@ async function authorizeConnectorMutation(action, confirmed, approvalId, approva
97
97
  });
98
98
  }
99
99
  const channelContext = currentChannelContext();
100
- const decision = assertChannelCanExecute(action, approvalParams, channelContext);
100
+ const decision = await assertChannelCanExecute(action, approvalParams, channelContext);
101
101
  if (!decision.allowed)
102
102
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
103
103
  if (decision.tier === 'autonomous')
@@ -2471,7 +2471,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2471
2471
  const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
2472
2472
  if (!dry_run && inactive.length > 0) {
2473
2473
  const channelContext = currentChannelContext();
2474
- const decision = assertChannelCanExecute('identify_inactive', approvalParams, channelContext);
2474
+ const decision = await assertChannelCanExecute('identify_inactive', approvalParams, channelContext);
2475
2475
  if (!decision.allowed)
2476
2476
  return txt(decision.reason ?? 'This channel cannot request this action.');
2477
2477
  if (decision.tier !== 'autonomous') {
@@ -2686,7 +2686,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2686
2686
  };
2687
2687
  if (!approval_id) {
2688
2688
  const channelContext = currentChannelContext();
2689
- const decision = assertChannelCanExecute('merge_subscribers', { target_id, source_ids }, channelContext);
2689
+ const decision = await assertChannelCanExecute('merge_subscribers', { target_id, source_ids }, channelContext);
2690
2690
  if (!decision.allowed)
2691
2691
  return txt(decision.reason ?? 'This channel cannot request this action.');
2692
2692
  const approval = await requestApproval('merge_subscribers', approvalParams, undefined, channelContext);
@@ -421,7 +421,7 @@ export function registerDataTools(server) {
421
421
  });
422
422
  }
423
423
  const channelContext = currentChannelContext();
424
- const decision = assertChannelCanExecute('delete_provider_credential', approvalParams, channelContext);
424
+ const decision = await assertChannelCanExecute('delete_provider_credential', approvalParams, channelContext);
425
425
  if (!decision.allowed)
426
426
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
427
427
  if (decision.tier !== 'autonomous') {
@@ -868,7 +868,7 @@ export function registerDataTools(server) {
868
868
  }
869
869
  const approvalParams = { connection_id };
870
870
  const channelContext = currentChannelContext();
871
- const decision = assertChannelCanExecute('rotate_custom_webhook_secret', approvalParams, channelContext);
871
+ const decision = await assertChannelCanExecute('rotate_custom_webhook_secret', approvalParams, channelContext);
872
872
  if (!decision.allowed)
873
873
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
874
874
  if (decision.tier !== 'autonomous') {
@@ -1034,7 +1034,7 @@ export function registerDeliveryOpsTools(server) {
1034
1034
  });
1035
1035
  }
1036
1036
  const channelContext = currentChannelContext();
1037
- const decision = assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
1037
+ const decision = await assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
1038
1038
  if (!decision.allowed)
1039
1039
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
1040
1040
  if (decision.tier !== 'autonomous') {
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  import { logAudit } from '../audit.js';
5
- import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
5
+ import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
6
6
  import { dbToolError } from '../lib/db-error.js';
7
7
  const txt = (text) => ({ content: [{ type: 'text', text }] });
8
8
  const j = (data) => txt(JSON.stringify(data, null, 2));
@@ -35,7 +35,7 @@ export function registerGdprTools(server) {
35
35
  // Human approval required (always, even in autonomous mode — protected tier)
36
36
  if (!approval_id) {
37
37
  const channelContext = currentChannelContext();
38
- const decision = assertChannelCanExecute('delete_subscriber_data', approvalParams, channelContext);
38
+ const decision = await assertChannelCanExecute('delete_subscriber_data', approvalParams, channelContext);
39
39
  if (!decision.allowed)
40
40
  return txt(decision.reason ?? 'This channel cannot request this action.');
41
41
  const approval = await requestApproval('delete_subscriber_data', approvalParams, undefined, channelContext);
@@ -182,20 +182,22 @@ export function registerGdprTools(server) {
182
182
  request_type: request.request_type,
183
183
  email: request.email,
184
184
  };
185
- if (!approval_id) {
186
- const channelContext = currentChannelContext();
187
- const decision = assertChannelCanExecute('action_ccpa_request', approvalParams, channelContext);
188
- if (!decision.allowed)
189
- return txt(decision.reason ?? 'This channel cannot request this action.');
190
- return j(await requestApproval('action_ccpa_request', approvalParams, undefined, channelContext));
191
- }
192
- const check = await checkApproval(approval_id, {
193
- consume: true,
194
- action: 'action_ccpa_request',
195
- params: approvalParams,
196
- });
197
- if (!check.approved) {
198
- return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
185
+ if (!approvalSatisfied('action_ccpa_request')) {
186
+ if (!approval_id) {
187
+ const channelContext = currentChannelContext();
188
+ const decision = await assertChannelCanExecute('action_ccpa_request', approvalParams, channelContext);
189
+ if (!decision.allowed)
190
+ return txt(decision.reason ?? 'This channel cannot request this action.');
191
+ return j(await requestApproval('action_ccpa_request', approvalParams, undefined, channelContext));
192
+ }
193
+ const check = await checkApproval(approval_id, {
194
+ consume: true,
195
+ action: 'action_ccpa_request',
196
+ params: approvalParams,
197
+ });
198
+ if (!check.approved) {
199
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
200
+ }
199
201
  }
200
202
  const { data: contact, error: contactErr } = await db()
201
203
  .from('subscribers')
@@ -242,7 +242,7 @@ export function registerListTools(server) {
242
242
  });
243
243
  }
244
244
  const channelContext = currentChannelContext();
245
- const decision = assertChannelCanExecute('add_list_members', approvalParams, channelContext);
245
+ const decision = await assertChannelCanExecute('add_list_members', approvalParams, channelContext);
246
246
  if (!decision.allowed)
247
247
  return txt(decision.reason ?? 'This channel cannot request this action.');
248
248
  if (decision.tier !== 'autonomous') {
@@ -318,7 +318,7 @@ export function registerListTools(server) {
318
318
  });
319
319
  }
320
320
  const channelContext = currentChannelContext();
321
- const decision = assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
321
+ const decision = await assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
322
322
  if (!decision.allowed)
323
323
  return txt(decision.reason ?? 'This channel cannot request this action.');
324
324
  if (decision.tier !== 'autonomous') {
@@ -400,7 +400,7 @@ export function registerListTools(server) {
400
400
  });
401
401
  }
402
402
  const channelContext = currentChannelContext();
403
- const decision = assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
403
+ const decision = await assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
404
404
  if (!decision.allowed)
405
405
  return txt(decision.reason ?? 'This channel cannot request this action.');
406
406
  if (decision.tier !== 'autonomous') {
@@ -6,6 +6,7 @@ import { getProjectId } from '../project.js';
6
6
  import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { dbToolError } from '../lib/db-error.js';
9
+ import { checkPlanLimit } from '../plan-limits.js';
9
10
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
10
11
  const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
11
12
  async function callProviderSwitchApi(path, init) {
@@ -239,7 +240,7 @@ export function registerOrgTools(server) {
239
240
  return j({ error: 'Cannot remove the last owner', code: 'LAST_OWNER' });
240
241
  }
241
242
  const channelContext = currentChannelContext();
242
- const decision = assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
243
+ const decision = await assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
243
244
  if (!decision.allowed)
244
245
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
245
246
  if (decision.tier !== 'autonomous') {
@@ -308,7 +309,7 @@ export function registerOrgTools(server) {
308
309
  });
309
310
  }
310
311
  const channelContext = currentChannelContext();
311
- const decision = assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
312
+ const decision = await assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
312
313
  if (!decision.allowed)
313
314
  return j({ error: decision.reason ?? 'This channel cannot request this action.' });
314
315
  if (decision.tier !== 'autonomous') {
@@ -545,9 +546,13 @@ export function registerOrgTools(server) {
545
546
  const { data: project } = await db().from('projects').select('plan').eq('id', projectId).maybeSingle();
546
547
  const plan = project?.plan ?? 'free';
547
548
  const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.free ?? {};
548
- const [contacts, sends, { count: sites }] = await Promise.all([
549
+ const [contacts, sends, drafts, { count: sites }] = await Promise.all([
549
550
  readPlanUsage(db(), projectId, "contacts"),
550
551
  readPlanUsage(db(), projectId, "monthly_sends"),
552
+ checkPlanLimit('ai_drafts').then((result) => ({ data: result, error: null })).catch((error) => ({
553
+ data: null,
554
+ error: error instanceof Error ? error.message : 'Unable to read draft usage',
555
+ })),
551
556
  db().from('sites').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
552
557
  ]);
553
558
  if (contacts.error || sends.error || contacts.data?.error || sends.data?.error || !contacts.data || !sends.data) {
@@ -558,7 +563,9 @@ export function registerOrgTools(server) {
558
563
  contacts: { used: contacts.data.current, limit: contacts.data.limit },
559
564
  monthly_sends: { used: sends.data.current, limit: sends.data.limit },
560
565
  sites: { used: sites ?? 0, limit: limits.sites ?? null },
561
- ai_drafts: { used: 0, limit: limits.ai_drafts ?? null },
566
+ ai_drafts: typeof drafts.data?.current === 'number'
567
+ ? { used: drafts.data.current, limit: limits.ai_drafts ?? null }
568
+ : { used: null, limit: limits.ai_drafts ?? null, reason: 'Unable to read draft usage' },
562
569
  log_retention_days: limits.log_retention_days ?? null,
563
570
  });
564
571
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.163",
3
+ "version": "1.0.165",
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",