@sendinel/mcp-server 1.0.103 → 1.0.105

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.
@@ -46,9 +46,12 @@ export interface ApprovalResult {
46
46
  approve_via: string;
47
47
  /** One-tap approval page; works on a phone and needs no project switching (#3661). */
48
48
  approve_url: string;
49
+ preview_text: string;
49
50
  message: string;
50
51
  }
51
52
  export declare function approvalPageUrl(approvalId: string): string;
53
+ /** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
54
+ export declare function approvalPayloadHash(action: string, params: unknown): string;
52
55
  /**
53
56
  * Request human approval for an action. Creates a pending approval token
54
57
  * and returns instructions for the agent to wait.
@@ -1,6 +1,8 @@
1
- import { decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
1
+ import { createHash } from 'node:crypto';
2
+ import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
2
3
  import { db, requestContext } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
5
+ import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
4
6
  import { fireWebhookEvent } from './webhook-events.js';
5
7
  /**
6
8
  * Action approval tiers:
@@ -28,6 +30,7 @@ const GATED_ACTIONS = new Set([
28
30
  'import_subscribers', // bulk data ingestion
29
31
  'subscribe_webhook', // outbound data channel
30
32
  'send_transactional', // sends a real email
33
+ 'update_campaign', // live trigger edits only; the domain rule gates callers
31
34
  'set_campaign_trigger', // changes who starts an active or paused campaign
32
35
  'update_campaign_status', // activation sends real emails (see special case below)
33
36
  'add_list_members', // bulk contact-list membership write
@@ -230,6 +233,8 @@ function launchSummary(p, d) {
230
233
  }
231
234
  /** Build a human-readable summary of the action for the approval screen */
232
235
  export function buildApprovalSummary(action, params, display = {}) {
236
+ if ((action === 'update_campaign' || action === 'set_campaign_trigger') && params.previous_campaign && params.updates)
237
+ return triggerChangeSummary(String(params.previous_campaign.name ?? params.campaign_id), params.previous_campaign, params.updates);
233
238
  const summaries = {
234
239
  launch_campaign: (p) => launchSummary(p, display) ?? `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
235
240
  install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
@@ -300,11 +305,15 @@ function normalizeApprovalParams(value) {
300
305
  if (value && typeof value === 'object') {
301
306
  return Object.fromEntries(Object.entries(value)
302
307
  .filter(([, entryValue]) => entryValue !== undefined)
303
- .sort(([a], [b]) => a.localeCompare(b))
308
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
304
309
  .map(([key, entryValue]) => [key, normalizeApprovalParams(entryValue)]));
305
310
  }
306
311
  return value;
307
312
  }
313
+ /** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
314
+ export function approvalPayloadHash(action, params) {
315
+ return createHash('sha256').update(JSON.stringify(normalizeApprovalParams({ action, params }))).digest('hex');
316
+ }
308
317
  function approvalParamsEqual(expected, actual) {
309
318
  return JSON.stringify(normalizeApprovalParams(expected)) === JSON.stringify(normalizeApprovalParams(actual));
310
319
  }
@@ -315,9 +324,18 @@ function approvalParamsEqual(expected, actual) {
315
324
  export async function requestApproval(action, params, apiKeyId, channelContext, display) {
316
325
  const tier = getApprovalTier(action, params);
317
326
  const summary = buildApprovalSummary(action, params, display);
318
- const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
327
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
319
328
  const { channel, verified } = channelContext ?? currentChannelContext();
320
329
  const actor = requestContext();
330
+ // Ignore any model-supplied reserved field; freeze facts read by the server.
331
+ const cleanParams = withoutApprovalSnapshot(params);
332
+ const snapshot = await captureApprovalSnapshot(action, cleanParams, getProjectId());
333
+ const payload = cleanParams.payload;
334
+ if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
335
+ !approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
336
+ throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
337
+ }
338
+ const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
321
339
  const { data, error } = await db()
322
340
  .from('action_approvals')
323
341
  .insert({
@@ -327,7 +345,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
327
345
  agent_label: actor?.agentLabel ?? null,
328
346
  action,
329
347
  tier,
330
- params,
348
+ params: frozenParams,
349
+ payload_hash: approvalPayloadHash(action, frozenParams),
331
350
  summary,
332
351
  expires_at: expiresAt,
333
352
  channel,
@@ -352,7 +371,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
352
371
  expires_at: expiresAt,
353
372
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
354
373
  approve_url: approvalPageUrl(data.id),
355
- message: `This action requires human approval. A notification has been sent to your dashboard. Approval ID: ${data.id}. Check status with: poll this approval_id, or wait for the webhook callback (event: approval.decided). Token expires in 30 minutes.`,
374
+ preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
375
+ message: `This action requires human approval. A notification has been sent to your dashboard. Approval ID: ${data.id}. Check status with: poll this approval_id, or wait for the webhook callback (event: approval.decided). Token expires in 24 hours.`,
356
376
  };
357
377
  }
358
378
  /**
@@ -362,7 +382,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
362
382
  export async function checkApproval(approvalId, options = {}) {
363
383
  const { data, error } = await db()
364
384
  .from('action_approvals')
365
- .select('action, status, decided_by, decided_via, expires_at, used_at, params')
385
+ .select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
366
386
  .eq('id', approvalId)
367
387
  .eq('project_id', getProjectId())
368
388
  .maybeSingle();
@@ -371,17 +391,29 @@ export async function checkApproval(approvalId, options = {}) {
371
391
  if (options.action && data.action !== options.action) {
372
392
  return { approved: false, status: 'action_mismatch' };
373
393
  }
374
- if (options.params && !approvalParamsEqual(options.params, data.params)) {
394
+ if (options.consume && data.payload_hash) {
395
+ if (!options.action || !options.params)
396
+ return { approved: false, status: 'payload_required' };
397
+ if (approvalPayloadHash(options.action, { ...withoutApprovalSnapshot(options.params), ...(data.params?.__approval_snapshot ? { __approval_snapshot: data.params.__approval_snapshot } : {}) }) !== data.payload_hash ||
398
+ approvalPayloadHash(data.action, data.params) !== data.payload_hash) {
399
+ return { approved: false, status: 'payload_changed_after_approval' };
400
+ }
401
+ }
402
+ if (options.params && !approvalParamsEqual(withoutApprovalSnapshot(options.params), withoutApprovalSnapshot(data.params))) {
375
403
  return { approved: false, status: 'params_mismatch' };
376
404
  }
377
- // Check expiry
378
- if (data.status === 'pending' && new Date(data.expires_at) < new Date()) {
379
- await db()
380
- .from('action_approvals')
381
- .update({ status: 'expired' })
382
- .eq('id', approvalId);
405
+ // Approved tokens expire too. Invalid timestamps fail closed. Older rows may have a
406
+ // shorter lifetime; no row can authorize an apply more than 24 hours after its request.
407
+ const requested = data.requested_at == null ? null : Date.parse(data.requested_at);
408
+ const expiry = Math.min(Date.parse(data.expires_at), requested == null ? Infinity : requested + 24 * 60 * 60 * 1000);
409
+ if (!Number.isFinite(expiry) || (requested != null && !Number.isFinite(requested)) || expiry <= Date.now()) {
383
410
  return { approved: false, status: 'expired' };
384
411
  }
412
+ if (data.used_at || data.status === 'used')
413
+ return { approved: false, status: 'consumed' };
414
+ if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)) {
415
+ return { approved: false, status: 'human_decision_required' };
416
+ }
385
417
  if (data.status !== 'approved' || !options.consume) {
386
418
  if (data.used_at)
387
419
  return { approved: false, status: 'consumed' };
@@ -391,15 +423,33 @@ export async function checkApproval(approvalId, options = {}) {
391
423
  decided_by: data.decided_by,
392
424
  };
393
425
  }
394
- const { data: claimed, error: claimError } = await db()
426
+ if (data.params?.__approval_snapshot) {
427
+ try {
428
+ const current = await captureApprovalSnapshot(data.action, withoutApprovalSnapshot(data.params), getProjectId());
429
+ if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
430
+ return { approved: false, status: 'target_changed_after_approval' };
431
+ }
432
+ }
433
+ catch {
434
+ return { approved: false, status: 'target_revalidation_failed' };
435
+ }
436
+ }
437
+ if (expiry <= Date.now())
438
+ return { approved: false, status: 'expired' };
439
+ const claimQuery = db()
395
440
  .from('action_approvals')
396
441
  .update({ status: 'used', used_at: new Date().toISOString() })
397
442
  .eq('id', approvalId)
398
443
  .eq('project_id', getProjectId())
399
444
  .eq('status', 'approved')
400
445
  .is('used_at', null)
401
- .select('decided_by')
402
- .maybeSingle();
446
+ .eq('expires_at', data.expires_at)
447
+ .gt('expires_at', new Date().toISOString())
448
+ .eq('decided_via', data.decided_via)
449
+ .eq('params', JSON.stringify(data.params))
450
+ .gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
451
+ const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
452
+ const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
403
453
  if (claimError || !claimed) {
404
454
  return { approved: false, status: 'consumed' };
405
455
  }
@@ -0,0 +1,17 @@
1
+ export declare const MCP_APP_EXTENSION = "io.modelcontextprotocol/ui";
2
+ export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
3
+ export type ApprovalClientCapabilities = Readonly<{
4
+ elicitation_form: boolean;
5
+ elicitation_url: boolean;
6
+ mcp_app: boolean;
7
+ }>;
8
+ export type ApprovalChannelAvailable = 'mcp_app' | 'mcp_elicitation_form' | 'mcp_elicitation_url' | 'plain_text';
9
+ /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
10
+ export declare function probeApprovalCapabilities(declared: unknown): ApprovalClientCapabilities;
11
+ export declare function rememberApprovalCapabilities(session: object, declared: unknown): ApprovalClientCapabilities;
12
+ export declare function approval_channel_available(session: object): ApprovalChannelAvailable;
13
+ /** Each SDK Server is a session boundary, including stateless HTTP request instances. */
14
+ export declare function installApprovalCapabilityProbe(session: {
15
+ oninitialized?: () => void;
16
+ getClientCapabilities(): unknown;
17
+ }): void;
@@ -0,0 +1,40 @@
1
+ export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
2
+ export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
3
+ const sessions = new WeakMap();
4
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
5
+ /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
6
+ export function probeApprovalCapabilities(declared) {
7
+ const capabilities = object(declared);
8
+ const elicitation = object(capabilities?.elicitation);
9
+ const extensions = object(capabilities?.extensions);
10
+ const app = object(extensions?.[MCP_APP_EXTENSION]);
11
+ return Object.freeze({
12
+ // The MCP spec defines elicitation:{} as form support for older clients.
13
+ elicitation_form: !!elicitation && (Object.keys(elicitation).length === 0 || !!object(elicitation.form)),
14
+ elicitation_url: !!object(elicitation?.url),
15
+ mcp_app: !!app && Array.isArray(app.mimeTypes) && app.mimeTypes.includes(MCP_APP_MIME_TYPE),
16
+ });
17
+ }
18
+ export function rememberApprovalCapabilities(session, declared) {
19
+ const capabilities = probeApprovalCapabilities(declared);
20
+ sessions.set(session, capabilities);
21
+ return capabilities;
22
+ }
23
+ export function approval_channel_available(session) {
24
+ const capabilities = sessions.get(session);
25
+ if (capabilities?.mcp_app)
26
+ return 'mcp_app';
27
+ if (capabilities?.elicitation_form)
28
+ return 'mcp_elicitation_form';
29
+ if (capabilities?.elicitation_url)
30
+ return 'mcp_elicitation_url';
31
+ return 'plain_text';
32
+ }
33
+ /** Each SDK Server is a session boundary, including stateless HTTP request instances. */
34
+ export function installApprovalCapabilityProbe(session) {
35
+ const previous = session.oninitialized;
36
+ session.oninitialized = () => {
37
+ rememberApprovalCapabilities(session, session.getClientCapabilities());
38
+ previous?.call(session);
39
+ };
40
+ }
@@ -116,7 +116,6 @@ export const APPROVAL_EXEMPT = {
116
116
  trigger_automation: 'generates a draft into the approval queue (or returns it with dry_run); nothing is sent until a person approves the draft',
117
117
  unsubscribe_subscriber: "only stops mail to a contact; reduces sending",
118
118
  update_brand_voice: "edits brand voice text on the caller own project; internal",
119
- update_campaign: "edits campaign settings; status changes go through update_campaign_status (gated). FOLLOW-UP: same live-edit caveat as update_campaign_step",
120
119
  update_campaign_step: "edits email copy; sends only through an already-gated activation. FOLLOW-UP: editing a live campaign changes what goes out (#4005 summary)",
121
120
  update_custom_field: "renames or edits a field definition; values are untouched",
122
121
  update_hygiene_config: "changes hygiene settings; suppression runs are gated (identify_inactive) or only reduce sending",
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Server integration only. The transport creates this closure for one authenticated human
3
+ * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
4
+ * argument. Front ends must call complete only from the matching elicitation/app response.
5
+ */
6
+ export declare function createHumanApprovalCallback(binding: {
7
+ approvalId: string;
8
+ projectId: string;
9
+ connectionId: string;
10
+ userId: string;
11
+ channel: 'mcp_elicitation' | 'mcp_app';
12
+ }): {
13
+ interactionId: `${string}-${string}-${string}-${string}-${string}`;
14
+ complete(response: {
15
+ interactionId: string;
16
+ connectionId: string;
17
+ decision: 'approve' | 'reject';
18
+ }): Promise<boolean>;
19
+ };
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { db } from '../db.js';
3
+ /**
4
+ * Server integration only. The transport creates this closure for one authenticated human
5
+ * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
6
+ * argument. Front ends must call complete only from the matching elicitation/app response.
7
+ */
8
+ export function createHumanApprovalCallback(binding) {
9
+ const interactionId = randomUUID();
10
+ let completed = false;
11
+ return {
12
+ interactionId,
13
+ async complete(response) {
14
+ if (completed || response.interactionId !== interactionId || response.connectionId !== binding.connectionId)
15
+ return false;
16
+ if (response.decision !== 'approve' && response.decision !== 'reject')
17
+ return false;
18
+ completed = true;
19
+ const source = db();
20
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
21
+ const now = new Date().toISOString();
22
+ const { data, error } = await client.from('action_approvals')
23
+ .update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
24
+ decided_by: binding.userId, decided_at: now })
25
+ .eq('id', binding.approvalId).eq('project_id', binding.projectId)
26
+ .eq('status', 'pending').is('used_at', null).gt('expires_at', now)
27
+ .select('id, payload_hash').maybeSingle();
28
+ if (error || !data)
29
+ return false;
30
+ const { error: auditError } = await client.from('audit_log').insert({
31
+ project_id: binding.projectId, actor_type: 'user', actor_id: binding.userId,
32
+ action: 'approval.submitted', resource_type: 'approval', resource_id: binding.approvalId,
33
+ details: { status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
34
+ connection_id: binding.connectionId, interaction_id: interactionId, payload_hash: data.payload_hash },
35
+ });
36
+ if (auditError)
37
+ throw new Error('Approval recorded, but decision audit failed');
38
+ return true;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,5 @@
1
+ type Params = Record<string, any>;
2
+ /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
3
+ export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
4
+ export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
5
+ export {};
@@ -0,0 +1,106 @@
1
+ import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
2
+ import { db } from '../db.js';
3
+ import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
4
+ const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
5
+ 'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
6
+ 'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
7
+ /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
8
+ export async function captureApprovalSnapshot(action, params, projectId) {
9
+ const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
10
+ if (!TARGET_ACTIONS.has(action))
11
+ return snapshot;
12
+ const source = db();
13
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
14
+ const body = params.payload ?? params;
15
+ const ids = [...new Set((Array.isArray(params.campaign_ids) ? params.campaign_ids :
16
+ typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
17
+ if (ids.length) {
18
+ snapshot.campaigns = await readAllRows(client.from('campaigns')
19
+ .select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
20
+ .eq('project_id', projectId).in('id', ids));
21
+ if (snapshot.campaigns.length !== ids.length)
22
+ throw new Error('Approval target not found');
23
+ snapshot.steps = await readAllRows(client.from('campaign_steps')
24
+ .select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
25
+ .in('campaign_id', ids));
26
+ let count = 0;
27
+ for (const id of ids) {
28
+ const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', id);
29
+ if (result.error || result.count == null)
30
+ throw new Error('Approval audience count unavailable');
31
+ count += result.count;
32
+ }
33
+ snapshot.recipient_count = count;
34
+ }
35
+ if (typeof params.issue_id === 'string') {
36
+ const issue = await client.from('newsletter_issues').select('id, newsletter_id, subject, content, preheader, status, scheduled_at')
37
+ .eq('id', params.issue_id).eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
38
+ const newsletter = await client.from('newsletters').select('id, name, site_id, status, sender_name, sender_email')
39
+ .eq('id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
40
+ if (issue.error || !issue.data || newsletter.error || !newsletter.data)
41
+ throw new Error('Approval newsletter unavailable');
42
+ snapshot.issue = issue.data;
43
+ snapshot.newsletter = newsletter.data;
44
+ snapshot.subscriptions = await readAllRows(client.from('newsletter_subscriptions').select('id, subscriber_id')
45
+ .eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).eq('status', 'active').not('subscriber_id', 'is', null));
46
+ snapshot.subscriptions.sort((a, b) => String(a.id).localeCompare(String(b.id)));
47
+ snapshot.recipient_count = snapshot.subscriptions.length;
48
+ }
49
+ const siteIds = [...new Set([body.site_id, snapshot.newsletter?.site_id, ...snapshot.campaigns.map((c) => c.site_id)]
50
+ .filter((id) => typeof id === 'string' && id.length > 0))].sort();
51
+ for (const id of siteIds) {
52
+ const { data, error } = await client.from('sites')
53
+ .select('id, name, from_name, from_email, sending_domain, physical_address, unsub_link_enabled, unsub_footer_template')
54
+ .eq('id', id).eq('project_id', projectId).maybeSingle();
55
+ if (error || !data)
56
+ throw new Error('Approval sender/footer unavailable');
57
+ snapshot.sites.push(data);
58
+ }
59
+ async function readFrozenContacts(contacts) {
60
+ const found = [];
61
+ for (const chunk of chunkIds([...new Set(contacts.map(String))])) {
62
+ const values = await readAllRows(client.from('subscribers').select('id')
63
+ .eq('project_id', projectId).in('id', chunk));
64
+ found.push(...values.map(value => value.id));
65
+ }
66
+ if (found.length !== new Set(contacts).size)
67
+ throw new Error('Approval recipients changed or unavailable');
68
+ return found.sort();
69
+ }
70
+ if (typeof body.segment_id === 'string') {
71
+ const { data, error } = await client.from('segments').select('id, name, rules, site_id')
72
+ .eq('id', body.segment_id).eq('project_id', projectId).maybeSingle();
73
+ if (error || !data)
74
+ throw new Error('Approval segment unavailable');
75
+ snapshot.segment = data;
76
+ // launch_campaign executes a frozen list, so new segment matches cannot expand its audience.
77
+ // The saved segment definition is still compared; deleted original recipients invalidate it.
78
+ const rules = scopeSegmentRulesToSite(data.rules, data.site_id);
79
+ snapshot.contact_ids = action === 'launch_campaign' && Array.isArray(params.generated?.contact_ids)
80
+ ? await readFrozenContacts(params.generated.contact_ids)
81
+ : typeof body.site_id === 'string'
82
+ ? (await resolveSendAudience(projectId, body.site_id, rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort()
83
+ : (await resolveSegmentContactIds(projectId, rules)).sort();
84
+ snapshot.recipient_count = snapshot.contact_ids.length;
85
+ }
86
+ else if (body.rules && typeof body.site_id === 'string') {
87
+ snapshot.contact_ids = (await resolveSendAudience(projectId, body.site_id, body.rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort();
88
+ snapshot.recipient_count = snapshot.contact_ids.length;
89
+ }
90
+ else {
91
+ const contacts = body.contact_ids ?? params.generated?.contact_ids;
92
+ if (Array.isArray(contacts)) {
93
+ snapshot.contact_ids = await readFrozenContacts(contacts);
94
+ snapshot.recipient_count = snapshot.contact_ids.length;
95
+ }
96
+ else if (typeof body.to === 'string')
97
+ snapshot.recipient_count = 1;
98
+ }
99
+ snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
100
+ snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
101
+ return snapshot;
102
+ }
103
+ export function withoutApprovalSnapshot(params) {
104
+ const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
105
+ return rest;
106
+ }
@@ -0,0 +1,10 @@
1
+ type Row = Record<string, any>;
2
+ export declare const TRIGGER_EDIT_SELECT = "id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at";
3
+ export declare const campaignEditError: (error: string, code: string) => {
4
+ error: string;
5
+ code: string;
6
+ };
7
+ export declare function finishCampaignEdit(action: string, input: Row, existing: Row, updates: Row, request: (params: Row) => Promise<Row>): Promise<any>;
8
+ /** The second call may carry only approval_id. Never rebuild a frozen edit from fresh arguments. */
9
+ export declare function resumeCampaignEdit(action: string, approvalId: string, input: Row): Promise<any>;
10
+ export {};
@@ -0,0 +1,66 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { logAudit } from '../audit.js';
6
+ import { assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
7
+ export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at';
8
+ export const campaignEditError = (error, code) => ({ error, code });
9
+ /** All writes include the revision used for the approval decision, including ungated drafts. */
10
+ async function writeEdit(campaignId, previous, updates) {
11
+ let query = db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
12
+ .eq('id', campaignId).eq('project_id', getProjectId()).eq('status', previous.status);
13
+ if (previous.updated_at)
14
+ query = query.eq('updated_at', previous.updated_at);
15
+ const { data, error } = await query.select().single();
16
+ if (error || !data)
17
+ return campaignEditError(error?.message ?? 'Campaign changed; read it and request a new approval', 'CAMPAIGN_CHANGED');
18
+ await logAudit({ action: 'campaign.updated', resourceType: 'campaign', resourceId: campaignId, details: { previous, updates } });
19
+ return data;
20
+ }
21
+ export async function finishCampaignEdit(action, input, existing, updates, request) {
22
+ if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
23
+ return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
24
+ }
25
+ if (!triggerChangeNeedsApproval(existing, updates))
26
+ return writeEdit(input.campaign_id, existing, updates);
27
+ const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
28
+ const context = currentChannelContext();
29
+ const decision = assertChannelCanExecute(action, params, context);
30
+ if (!decision.allowed)
31
+ return campaignEditError(decision.reason ?? 'Channel cannot request approval', 'FORBIDDEN');
32
+ // This trigger policy requires a human decision even if the integration uses autonomous mode.
33
+ return request(params);
34
+ }
35
+ /** The second call may carry only approval_id. Never rebuild a frozen edit from fresh arguments. */
36
+ export async function resumeCampaignEdit(action, approvalId, input) {
37
+ const { data: approval, error } = await db().from('action_approvals').select('*')
38
+ .eq('id', approvalId).eq('project_id', getProjectId()).maybeSingle();
39
+ if (error || !approval || approval.action !== action)
40
+ return campaignEditError('Approval not found for this action', 'APPROVAL_MISMATCH');
41
+ if (approval.used_at || approval.status === 'used')
42
+ return campaignEditError('Approval consumed', 'APPROVAL_CONSUMED');
43
+ const params = approval.params;
44
+ if (!params?.previous_campaign || !params.updates || !params.campaign_id)
45
+ return campaignEditError('Approval has no frozen campaign edit', 'APPROVAL_MISMATCH');
46
+ for (const [key, value] of Object.entries(input)) {
47
+ if (value !== undefined && !isDeepStrictEqual(value, params.input?.[key]))
48
+ return campaignEditError('params_mismatch: request differs from the frozen edit', 'APPROVAL_MISMATCH');
49
+ }
50
+ const { data: existing, error: loadError } = await db().from('campaigns').select(TRIGGER_EDIT_SELECT)
51
+ .eq('id', params.campaign_id).eq('project_id', getProjectId()).maybeSingle();
52
+ if (loadError || !existing)
53
+ return campaignEditError('Campaign not found', 'NOT_FOUND');
54
+ if (campaignTriggerRevision(existing) !== campaignTriggerRevision(params.previous_campaign)) {
55
+ return campaignEditError('Campaign changed after approval was requested; request a new approval', 'CAMPAIGN_CHANGED');
56
+ }
57
+ if (!Number.isFinite(Date.parse(approval.expires_at)) || Date.parse(approval.expires_at) <= Date.now())
58
+ return campaignEditError('Approval expired', 'APPROVAL_EXPIRED');
59
+ const decision = assertChannelCanExecute(action, params, currentChannelContext());
60
+ if (!decision.allowed)
61
+ return campaignEditError(decision.reason ?? 'Channel cannot use approval', 'FORBIDDEN');
62
+ const check = await checkApproval(approvalId, { consume: true, action, params });
63
+ if (!check.approved)
64
+ return campaignEditError(`Approval ${approvalId} is ${check.status}`, 'APPROVAL_REQUIRED');
65
+ return writeEdit(params.campaign_id, existing, params.updates);
66
+ }
package/build/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
1
2
  import { runWithToolContext } from './db.js';
2
3
  import { isApprovalGatedAction } from './lib/action-approvals.js';
3
4
  import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
@@ -377,6 +378,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
377
378
  'On the hosted connection, any tool call (and invoke_tool) also accepts an optional project_id to act on one project for that call only; it is checked each time, and approval-gated tools require it when the connection was switched to another project. In gateway mode, find tools with search_tools (plain words work, for example "send email" or "last campaign"), check arguments with describe_tool, then call invoke_tool.',
378
379
  ].join(' ');
379
380
  export function registerAllTools(server, options) {
381
+ if (server.server)
382
+ installApprovalCapabilityProbe(server.server);
380
383
  const rawScopes = options?.scopes ?? [];
381
384
  if (rawScopes.includes('gateway')) {
382
385
  const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
@@ -9,6 +9,7 @@ import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLO
9
9
  // bundling problem this subpath was introduced to fix. See the comment in
10
10
  // packages/domain/src/index.ts.
11
11
  import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/domain/reply-actions';
12
+ import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
12
13
  import { adminDb, db, requestContext } from '../db.js';
13
14
  import { getProjectId } from '../project.js';
14
15
  import { checkPlanLimit } from '../plan-limits.js';
@@ -3345,7 +3346,7 @@ Return ONLY valid JSON with this exact shape:
3345
3346
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3346
3347
  description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Use for dunning on invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
3347
3348
  inputSchema: {
3348
- campaign_id: z.string().uuid(),
3349
+ campaign_id: z.string().uuid().optional(),
3349
3350
  trigger_event_name: z.string().nullable().optional(),
3350
3351
  trigger_tag: z.string().nullable().optional(),
3351
3352
  trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
@@ -3354,9 +3355,15 @@ Return ONLY valid JSON with this exact shape:
3354
3355
  approval_id: z.string().uuid().optional(),
3355
3356
  },
3356
3357
  }, async ({ approval_id, ...args }) => {
3358
+ if (approval_id) {
3359
+ const result = await resumeCampaignEdit('set_campaign_trigger', approval_id, args);
3360
+ return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3361
+ }
3362
+ if (!args.campaign_id)
3363
+ return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
3357
3364
  const projectId = getProjectId();
3358
3365
  const { data: existing, error: loadError } = await db().from('campaigns')
3359
- .select('id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at')
3366
+ .select(TRIGGER_EDIT_SELECT)
3360
3367
  .eq('id', args.campaign_id).eq('project_id', projectId).maybeSingle();
3361
3368
  if (loadError)
3362
3369
  return j({ error: loadError.message });
@@ -3381,36 +3388,8 @@ Return ONLY valid JSON with this exact shape:
3381
3388
  && (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
3382
3389
  updates.type = 'triggered';
3383
3390
  }
3384
- const execute = async () => {
3385
- // A draft activated during this call must never bypass approval. The timestamp also
3386
- // guards against overwriting edits made while an approved call is executing.
3387
- let query = db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
3388
- .eq('id', args.campaign_id).eq('project_id', projectId).eq('status', existing.status);
3389
- if (existing.updated_at)
3390
- query = query.eq('updated_at', existing.updated_at);
3391
- const { data, error } = await query.select().maybeSingle();
3392
- if (error)
3393
- return j({ error: error.message });
3394
- if (!data)
3395
- return j({ error: 'Campaign changed; read it and retry', code: 'CAMPAIGN_CHANGED' });
3396
- await logAudit({ action: 'campaign.trigger_updated', resourceType: 'campaign', resourceId: args.campaign_id, details: { previous: existing, updates } });
3397
- return j({ ...data, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) });
3398
- };
3399
- if (existing.status === 'draft')
3400
- return execute();
3401
- // The saved state is internal, never caller-supplied. Approval is for this exact before/after.
3402
- return withApproval('set_campaign_trigger', async (_params) => execute(), {
3403
- display: async () => {
3404
- const { count, error } = await db().from('subscribers').select('id', { count: 'exact', head: true })
3405
- .eq('project_id', projectId).eq('global_unsubscribed', false);
3406
- return {
3407
- campaign_name: existing.name,
3408
- from_trigger: `${buildCampaignFiresOn(existing)} Config: ${JSON.stringify(existing.trigger_config)}`,
3409
- to_trigger: `${buildCampaignFiresOn({ ...existing, ...updates })} Config: ${JSON.stringify(updates.trigger_config === undefined ? existing.trigger_config : updates.trigger_config)}`,
3410
- recipient_count: error ? null : count,
3411
- };
3412
- },
3413
- })({ ...args, previous_trigger: existing, approval_id });
3391
+ const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
3392
+ return j(result.error || result.requires_approval ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3414
3393
  });
3415
3394
  server.registerTool('update_campaign', {
3416
3395
  title: 'Update Campaign',
@@ -3420,9 +3399,10 @@ Return ONLY valid JSON with this exact shape:
3420
3399
  idempotentHint: true,
3421
3400
  openWorldHint: false,
3422
3401
  },
3423
- description: 'Update a campaign name, site, segment, send time, topic, trigger event name/tag/config (including where filters for dunning), or send class (broadcast vs product). trigger_type null (or manual) clears a date_field or contact_event trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on, and trigger_diagnostics naming any trigger problems. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
3402
+ description: 'Update a campaign name, site, segment, send time, topic, trigger event name/tag/config (including where filters for dunning), or send class (broadcast vs product). trigger_type null (or manual) clears a date_field or contact_event trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on, and trigger_diagnostics naming any trigger problems. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated. Changed triggers on active/paused campaigns return an approval request; retry with only approval_id to apply the frozen edit.',
3424
3403
  inputSchema: {
3425
- campaign_id: z.string().uuid(),
3404
+ campaign_id: z.string().uuid().optional(),
3405
+ approval_id: z.string().uuid().optional(),
3426
3406
  name: z.string().min(1).max(200).optional(),
3427
3407
  site_id: z.string().uuid().optional(),
3428
3408
  segment_id: z.string().uuid().nullable().optional(),
@@ -3437,12 +3417,19 @@ Return ONLY valid JSON with this exact shape:
3437
3417
  send_in_contact_timezone: z.boolean().optional(),
3438
3418
  send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
3439
3419
  },
3440
- }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class }) => {
3420
+ }, async ({ approval_id, ...input }) => {
3421
+ if (approval_id) {
3422
+ const result = await resumeCampaignEdit('update_campaign', approval_id, input);
3423
+ return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3424
+ }
3425
+ const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class } = input;
3426
+ if (!campaign_id)
3427
+ return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
3441
3428
  const projectId = getProjectId();
3442
3429
  let campaignType;
3443
3430
  const loadExisting = () => db()
3444
3431
  .from('campaigns')
3445
- .select('id, type, status, trigger_type, trigger_config, trigger_event_name, trigger_tag')
3432
+ .select(TRIGGER_EDIT_SELECT)
3446
3433
  .eq('id', campaign_id)
3447
3434
  .eq('project_id', projectId)
3448
3435
  .maybeSingle();
@@ -3486,7 +3473,7 @@ Return ONLY valid JSON with this exact shape:
3486
3473
  return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3487
3474
  existingCampaignForTargeting = existing;
3488
3475
  }
3489
- const updates = { updated_at: new Date().toISOString() };
3476
+ const updates = {};
3490
3477
  if (name !== undefined)
3491
3478
  updates.name = name;
3492
3479
  if (site_id !== undefined)
@@ -3517,15 +3504,20 @@ Return ONLY valid JSON with this exact shape:
3517
3504
  updates.type = 'triggered';
3518
3505
  }
3519
3506
  }
3520
- const { data, error } = await db()
3521
- .from('campaigns')
3522
- .update(updates)
3523
- .eq('id', campaign_id)
3524
- .eq('project_id', projectId)
3525
- .select()
3526
- .single();
3527
- if (error)
3528
- return j({ error: error.message });
3507
+ let data;
3508
+ if (existingCampaignForTargeting) {
3509
+ data = await finishCampaignEdit('update_campaign', input, existingCampaignForTargeting, updates, params => requestApproval('update_campaign', params));
3510
+ if (data.error || data.requires_approval)
3511
+ return j(data);
3512
+ }
3513
+ else {
3514
+ const result = await db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
3515
+ .eq('id', campaign_id).eq('project_id', projectId).select().single();
3516
+ if (result.error)
3517
+ return j({ error: result.error.message });
3518
+ data = result.data;
3519
+ await logAudit({ action: 'campaign.updated', resourceType: 'campaign', resourceId: campaign_id, details: { updates } });
3520
+ }
3529
3521
  const diagnostics = buildCampaignTriggerDiagnostics(data);
3530
3522
  const warning = diagnostics.find((d) => d.includes('never fire')) || (diagnostics.length ? diagnostics[0] : null);
3531
3523
  return j({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.103",
3
+ "version": "1.0.105",
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",