@sendinel/mcp-server 1.0.215 → 1.0.217

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.
@@ -1,4 +1,5 @@
1
1
  import type { DecisionEvidence } from './approval-elicitation-evidence.js';
2
+ import { type AutoApproveUnavailableReason } from './sandbox-approvals.js';
2
3
  export { isApprovalGatedAction } from './approval-actions.js';
3
4
  /**
4
5
  * Action approval tiers:
@@ -35,8 +36,9 @@ export declare function assertChannelCanExecute(action: string, params: Record<s
35
36
  /** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
36
37
  export declare function currentChannelContext(): ChannelExecutionContext;
37
38
  /**
38
- * Display-only facts for the approval summary (segment name, count, subject, excerpt). Never
39
- * stored in params, so it can't change the params an approval is checked against.
39
+ * Display-only facts for the approval summary (segment name, count, subject, excerpt).
40
+ * merge_variable_summary is frozen in __approval_snapshot.display; the other facts
41
+ * do not change the params an approval is checked against.
40
42
  */
41
43
  export type ApprovalDisplay = Record<string, string | number | null | undefined>;
42
44
  /** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
@@ -45,6 +47,7 @@ export declare function approvalExcerpt(html: unknown, max?: number): string;
45
47
  export declare function buildApprovalSummary(action: string, params: Record<string, unknown>, display?: ApprovalDisplay): string;
46
48
  export interface ApprovalResult {
47
49
  requires_approval: true;
50
+ auto_approve_unavailable_reason?: AutoApproveUnavailableReason;
48
51
  approval_id: string;
49
52
  tier: ApprovalTier;
50
53
  action: string;
@@ -1,5 +1,5 @@
1
1
  import { effectiveApproval, auditApprovalLevel } from './approval-classes.js';
2
- import { sandboxApprovalEligible, trySandboxApproval } from './sandbox-approvals.js';
2
+ import { sandboxApprovalEligible, trySandboxApprovalWithReason } from './sandbox-approvals.js';
3
3
  import { humanApprovalEligible } from './approval-human-eligibility.js';
4
4
  import { AsyncLocalStorage } from 'node:async_hooks';
5
5
  import { z } from 'zod';
@@ -252,8 +252,10 @@ export function buildApprovalSummary(action, params, display = {}) {
252
252
  remove_suppression: (p) => `Remove ${p.email} from the suppression list — mail to this address will resume`,
253
253
  };
254
254
  const fn = summaries[action];
255
- if (fn)
256
- return fn(params);
255
+ if (fn) {
256
+ const line = params.__approval_snapshot?.display?.merge_variable_summary;
257
+ return fn(params) + (line ? `\n${line}` : '');
258
+ }
257
259
  const payload = JSON.stringify(normalizeApprovalParams(params));
258
260
  return `Execute ${action} with: ${payload.length > 400 ? `${payload.slice(0, 399)}…` : payload}`;
259
261
  }
@@ -356,6 +358,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
356
358
  const cleanParams = withoutApprovalSnapshot(params);
357
359
  const snapshot = { ...await captureSnapshot(action, cleanParams), ...(effective.applied ? { approval_class: effective.class, approval_channel_rule: effective.channelRule } : {}),
358
360
  ...(display && 'recipient_count' in display ? { display_recipient_count: display.recipient_count } : {}) };
361
+ if (typeof display?.merge_variable_summary === 'string') {
362
+ snapshot.display = { ...snapshot.display, merge_variable_summary: display.merge_variable_summary };
363
+ }
359
364
  const payload = cleanParams.payload;
360
365
  if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
361
366
  !approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
@@ -392,7 +397,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
392
397
  throw new Error(`Failed to create approval request: ${error.message}`);
393
398
  let projectName;
394
399
  let sandboxDisplayRead = false;
395
- const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || (effective.class === 'F' && !action.startsWith('delete_')))) && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; }, frozenParams);
400
+ const sandboxPolicyBlocked = effective.applied && (effective.level === 'ask_every_time' || (effective.class === 'F' && !action.startsWith('delete_')));
401
+ const sandboxAttempt = sandboxPolicyBlocked ? { approved: false, reason: 'approval_policy' } : await trySandboxApprovalWithReason(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; }, frozenParams);
396
402
  if (isTesterRequest(action))
397
403
  await logTesterRequestAudit('tester_request', action, data.id, null);
398
404
  fireWebhookEvent('approval.requested', {
@@ -414,16 +420,18 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
414
420
  catch { /* Missing display data omits the project line. */ }
415
421
  const approveUrl = approvalPageUrl(data.id);
416
422
  const previewText = buildApprovalPreview({ action, params: frozenParams, summary: detailSummary, projectName, ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) }).plain_summary;
417
- if (sandboxApproved) {
423
+ if (sandboxAttempt.approved) {
418
424
  return { requires_approval: true, approval_id: data.id, tier, action, summary, expires_at: expiresAt,
419
425
  approve_via: 'sandbox', decided_via: 'sandbox', decided_by: null, approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
420
426
  message: approvalNext(action, { approve_via: 'sandbox', decision_status: 'approved' }) };
421
427
  }
428
+ const autoApproveUnavailableReason = sandboxAttempt.reason;
422
429
  const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
423
430
  ? `\nAsk the user in plain chat: "${previewText.split('\n')[0].trim()}". Only after they say yes, call confirm_approval with approval_id, action, echo (the first line of preview_text) and user_reply (their exact words), then ${approvalRetryInstruction(action)}`
424
431
  : '';
425
432
  const result = await elicitApproval({
426
433
  requires_approval: true,
434
+ auto_approve_unavailable_reason: autoApproveUnavailableReason,
427
435
  ...(effective.channelRule === 'human_only' ? {} : await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
428
436
  payload_hash: approvalPayloadHash(action, frozenParams) })),
429
437
  approval_id: data.id,
@@ -1,4 +1,4 @@
1
- import { type ContentIssue } from '@sendinel/domain';
1
+ import { type MergeVariableWarning, type ContentIssue } from '@sendinel/domain';
2
2
  /** Data keys seen on an event in the project's history, or null when the lookup failed. */
3
3
  export declare function eventKeysFromHistory(projectId: string, eventName: string): Promise<string[] | null>;
4
4
  /**
@@ -32,7 +32,9 @@ export type CampaignContentFinding = ContentIssue & {
32
32
  * every email of the given campaigns (#3853). Placeholder text and sending-domain links are
33
33
  * blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
34
34
  */
35
- export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[]): Promise<{
35
+ export declare function checkCampaignContent(projectId: string, campaigns: ContentCampaign[], steps: ContentStep[], options?: {
36
+ mergeVariables?: boolean;
37
+ }): Promise<{
36
38
  blocking: CampaignContentFinding[];
37
39
  warnings: CampaignContentFinding[];
38
40
  lookup_errors: string[];
@@ -59,4 +61,22 @@ export declare function summarizeContentWarnings(warnings: Array<{
59
61
  email?: number;
60
62
  warning: string;
61
63
  }>): string;
64
+ export type CampaignMergeVariableWarning = MergeVariableWarning & {
65
+ step_order: number;
66
+ checked_at: string;
67
+ warning: string;
68
+ };
69
+ /** Uses the shared domain rules; unavailable sources stay unknown and never stop activation. */
70
+ export declare function checkCampaignMergeVariables(projectId: string, campaign: {
71
+ trigger_event_name: string | null;
72
+ }, steps: Array<{
73
+ step_order: number;
74
+ subject?: string | null;
75
+ body_html?: string | null;
76
+ body_text?: string | null;
77
+ }>): Promise<CampaignMergeVariableWarning[]>;
78
+ /** One extra line on the approval, including explicit uncertainty for unknown sources. */
79
+ export declare function summarizeMergeVariableWarnings(warnings: CampaignMergeVariableWarning[]): string | undefined;
80
+ /** Best-effort note. Compare-and-swap preserves concurrent metadata edits; never gate activation. */
81
+ export declare function persistMergeVariableWarnings(projectId: string, campaignId: string, warnings: CampaignMergeVariableWarning[]): Promise<void>;
62
82
  export {};
@@ -1,4 +1,4 @@
1
- import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf } from '@sendinel/domain';
1
+ import { BLOCKING_CONTENT_ISSUE_KINDS, findContentIssues, sendingHostOf, findUnprovidedMergeTags } from '@sendinel/domain';
2
2
  import { db } from '../db.js';
3
3
  /** Data keys seen on an event in the project's history, or null when the lookup failed. */
4
4
  export async function eventKeysFromHistory(projectId, eventName) {
@@ -50,7 +50,7 @@ export async function contactPropertyKeys(projectId) {
50
50
  * every email of the given campaigns (#3853). Placeholder text and sending-domain links are
51
51
  * blocking; merge-tag findings are warnings. A failed lookup is reported as a warning, never as a pass.
52
52
  */
53
- export async function checkCampaignContent(projectId, campaigns, steps) {
53
+ export async function checkCampaignContent(projectId, campaigns, steps, options = {}) {
54
54
  const lookupErrors = [];
55
55
  const sendingHostsBySite = new Map();
56
56
  const allHosts = new Set();
@@ -63,11 +63,11 @@ export async function checkCampaignContent(projectId, campaigns, steps) {
63
63
  for (const host of hosts)
64
64
  allHosts.add(host);
65
65
  }
66
- const contactProps = await contactPropertyKeys(projectId);
67
- if (contactProps === null)
66
+ const contactProps = options.mergeVariables === false ? null : await contactPropertyKeys(projectId);
67
+ if (options.mergeVariables !== false && contactProps === null)
68
68
  lookupErrors.push('Could not read your contacts, so merge tags that use contact properties were not confirmed.');
69
69
  const eventKeys = new Map();
70
- for (const eventName of new Set(campaigns.map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
70
+ for (const eventName of new Set((options.mergeVariables === false ? [] : campaigns).map((c) => c.trigger_event_name).filter((e) => Boolean(e)))) {
71
71
  const keys = await eventKeysFromHistory(projectId, eventName);
72
72
  eventKeys.set(eventName, keys);
73
73
  if (keys === null)
@@ -82,7 +82,7 @@ export async function checkCampaignContent(projectId, campaigns, steps) {
82
82
  : [...events, ...contactProps.keys.map((key) => `custom.${key}`)];
83
83
  const hosts = (campaign.site_id ? sendingHostsBySite.get(campaign.site_id) : undefined) ?? [...allHosts];
84
84
  for (const step of steps.filter((s) => s.campaign_id === campaign.id)) {
85
- for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys })) {
85
+ for (const issue of findContentIssues(step, { sendingHosts: hosts, sourcedKeys: options.mergeVariables === false ? undefined : sourcedKeys })) {
86
86
  const finding = { ...issue, campaign: campaign.name, email: step.step_order + 1 };
87
87
  (BLOCKING_CONTENT_ISSUE_KINDS.has(issue.kind) ? blocking : warnings).push(finding);
88
88
  }
@@ -109,3 +109,60 @@ export function summarizeContentWarnings(warnings) {
109
109
  const first = warnings.slice(0, 3).map((w) => (w.campaign ? `${w.campaign}, email ${w.email}: ${w.warning}` : w.warning));
110
110
  return `${warnings.length} warning${warnings.length === 1 ? '' : 's'}. ${first.join(' | ')}${warnings.length > 3 ? ` | and ${warnings.length - 3} more` : ''}`;
111
111
  }
112
+ /** Uses the shared domain rules; unavailable sources stay unknown and never stop activation. */
113
+ export async function checkCampaignMergeVariables(projectId, campaign, steps) {
114
+ const safe = async (load) => {
115
+ try {
116
+ return await load();
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ };
122
+ const contacts = await safe(() => contactPropertyKeys(projectId));
123
+ const events = campaign.trigger_event_name
124
+ ? await safe(() => eventKeysFromHistory(projectId, campaign.trigger_event_name)) : [];
125
+ const knowledge = {
126
+ eventKeys: events === null ? null : new Set(events),
127
+ contactPropertyKeys: contacts === null ? null : { keys: new Set(contacts.keys), complete: contacts.complete },
128
+ };
129
+ const checked_at = new Date().toISOString();
130
+ return steps.flatMap(step => findUnprovidedMergeTags({ subject: step.subject, html: step.body_html, text: step.body_text }, knowledge).map(finding => ({ ...finding, step_order: step.step_order, checked_at,
131
+ warning: finding.kind === 'unknown_event'
132
+ ? `Can't tell whether ${finding.tag} can be filled: event history is empty or couldn't be checked.`
133
+ : finding.kind === 'unknown_contacts'
134
+ ? `Couldn't check whether ${finding.tag} can be filled: the contact scan failed or is incomplete.`
135
+ : `${finding.tag} may print blank: ${finding.kind === 'no_event_data' ? 'no observed trigger event data provides it' : 'no contact has this property'}.`,
136
+ })));
137
+ }
138
+ /** One extra line on the approval, including explicit uncertainty for unknown sources. */
139
+ export function summarizeMergeVariableWarnings(warnings) {
140
+ if (!warnings.length)
141
+ return undefined;
142
+ return `${warnings.length} merge variables may print blank: ${warnings.map(w => `${w.tag} (email ${w.step_order + 1}${w.kind.startsWith('unknown_') ? ": can't tell / couldn't check" : ''})`).join(', ')}`;
143
+ }
144
+ /** Best-effort note. Compare-and-swap preserves concurrent metadata edits; never gate activation. */
145
+ export async function persistMergeVariableWarnings(projectId, campaignId, warnings) {
146
+ try {
147
+ const source = db().schema('email');
148
+ for (let attempt = 0; attempt < 3; attempt++) {
149
+ const { data: current, error: readError } = await source.from('campaigns').select('metadata')
150
+ .eq('id', campaignId).eq('project_id', projectId).maybeSingle();
151
+ if (readError || !current)
152
+ break;
153
+ const metadata = current.metadata;
154
+ const next = { ...(metadata ?? {}), merge_variable_warnings: warnings
155
+ .filter(w => w.kind === 'no_event_data' || w.kind === 'no_contact_has_it')
156
+ .map(({ tag, step_order, kind, checked_at }) => ({ tag, step_order, kind, checked_at })) };
157
+ let update = source.from('campaigns').update({ metadata: next }).eq('id', campaignId).eq('project_id', projectId);
158
+ update = metadata == null ? update.is('metadata', null) : update.eq('metadata', JSON.stringify(metadata));
159
+ const { data, error } = await update.select('id');
160
+ if (error)
161
+ break;
162
+ if (data?.length)
163
+ return;
164
+ }
165
+ }
166
+ catch { /* A failed note write must not prevent activation. */ }
167
+ console.error('[merge-variable-warnings] Could not save campaign warning note.');
168
+ }
@@ -8,6 +8,18 @@ const projectRule = 'On hosted /mcp, pass project_id on each call. It is require
8
8
  const previewRule = 'preview_email uses the real send renderer with sample contact data. send_test_email with dry_run:true uses the real renderer without sending or needing approval, including in standard mode. Stdio requires SENDINEL_API_KEY.';
9
9
  const sender = { n: 1, tool: 'get_sites', args_example: {}, note: 'Choose the intended sender from the returned array; do not assume the first sender is correct. Replace example content and addresses with the user’s own choices.' };
10
10
  export const HOW_TO_RECIPES = [
11
+ {
12
+ id: 'clean-up-test-objects', title: 'Clean up sandbox test contacts and objects',
13
+ when_to_use: 'Remove throwaway contacts or objects created by this credential after a sandbox test.',
14
+ synonyms: TOOL_ALIASES.delete_subscriber_data,
15
+ requires: { scopes: ['admin'], tester_ok: false },
16
+ steps: [
17
+ { n: 1, tool: 'delete_subscriber_data', args_example: { contact_id: '11111111-1111-4111-8111-111111111111', reason: 'admin' }, approval, note: 'Replace contact_id with the saved ID of a test contact you created. This is irreversible GDPR erasure, including related data. In a sandbox project, same-credential provenance permits automatic approval; inspect decision_status and auto_approve_unavailable_reason. If pending, show approve_url and wait for human approval.' },
18
+ { n: 2, tool: 'delete_subscriber_data', args_example: { approval_id: from(1, 'approval_id') }, approval, note: 'Only after decision_status is approved, re-call with ONLY approval_id to execute. Repeat for each intended test contact.' },
19
+ ],
20
+ gotchas: [projectRule, 'is_test:true is only a contact classification, never delete authority. Non-sandbox projects keep human approval. Unknown or foreign provenance stays gated. Campaign cleanup uses delete_campaign with campaign_id; every cascading step must also belong to this credential. Templates and segments use delete_template with id and delete_segment with segment_id, with the same approval_id flow.'],
21
+ done_when: 'Each intended cleanup execution succeeds; automatic approval alone has not deleted anything.',
22
+ },
11
23
  {
12
24
  id: 'brand-kit-signature', title: 'Preview and save a brand-kit email signature',
13
25
  when_to_use: 'Set up or edit a signature footer for future AI-generated drafts using stored brand details.',
@@ -25,4 +25,7 @@ export declare function withCreatedItems<T extends {
25
25
  error: any;
26
26
  }>(entityType: CreatedItemType, query: PromiseLike<T>): Promise<T>;
27
27
  /** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
28
+ export type DeleteOwnershipUnavailableReason = 'not_created_by_this_credential' | 'unknown_provenance';
29
+ export declare function deleteOwnershipUnavailableReason(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<DeleteOwnershipUnavailableReason | null>;
30
+ /** Preserve the boolean authority API; diagnostics never supply another approval path. */
28
31
  export declare function ownsDeleteTargets(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<boolean>;
@@ -54,11 +54,10 @@ export async function withCreatedItems(entityType, query) {
54
54
  return result;
55
55
  }
56
56
  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
57
- /** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
58
- export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
57
+ export async function deleteOwnershipUnavailableReason(projectId, action, params, creatorKey) {
59
58
  const target = DELETE_ITEM_TARGETS[action];
60
59
  if (!creatorKey || !target)
61
- return false;
60
+ return 'unknown_provenance';
62
61
  const [entityType, single, bulk] = target;
63
62
  const source = adminDb().schema('email');
64
63
  let ids = [];
@@ -66,36 +65,46 @@ export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
66
65
  ids.push(params[single]);
67
66
  if (params[bulk] !== undefined) {
68
67
  if (!Array.isArray(params[bulk]) || !params[bulk].length)
69
- return false;
68
+ return 'unknown_provenance';
70
69
  ids.push(...params[bulk]);
71
70
  }
72
71
  // GDPR snapshots carry both ID and email. Verify the address names that same target.
73
72
  if (action === 'delete_subscriber_data' && params.email !== undefined) {
74
73
  if (typeof params.email !== 'string' || !params.email.trim())
75
- return false;
74
+ return 'unknown_provenance';
76
75
  const { data, error } = await source.from('subscribers').select('id').eq('project_id', projectId)
77
76
  .eq('email', params.email.trim().toLowerCase()).maybeSingle();
78
77
  if (error || !data || (ids.length && (ids.length !== 1 || ids[0] !== data.id)))
79
- return false;
78
+ return 'unknown_provenance';
80
79
  ids.push(data.id);
81
80
  }
82
81
  if (!ids.length || ids.some(id => typeof id !== 'string' || !uuid.test(id)))
83
- return false;
82
+ return 'unknown_provenance';
84
83
  ids = [...new Set(ids)];
85
84
  const owns = async (type, targets) => {
86
- const { data, error } = await source.from('mcp_created_items').select('entity_id')
87
- .eq('project_id', projectId).eq('entity_type', type).eq('creator_key', creatorKey).in('entity_id', targets);
88
- return !error && Array.isArray(data) && targets.every(id => data.some(row => row.entity_id === id));
85
+ const { data, error } = await source.from('mcp_created_items').select('entity_id, creator_key')
86
+ .eq('project_id', projectId).eq('entity_type', type).in('entity_id', targets);
87
+ if (error || !Array.isArray(data))
88
+ return 'unknown_provenance';
89
+ if (targets.some(id => !data.some(row => row.entity_id === id && typeof row.creator_key === 'string')))
90
+ return 'unknown_provenance';
91
+ return targets.every(id => data.some(row => row.entity_id === id && row.creator_key === creatorKey))
92
+ ? null : 'not_created_by_this_credential';
89
93
  };
90
- if (!await owns(entityType, ids))
91
- return false;
94
+ const reason = await owns(entityType, ids);
95
+ if (reason)
96
+ return reason;
92
97
  // Deleting a campaign also deletes its steps; another client may have added one.
93
98
  if (action === 'delete_campaign') {
94
99
  const { data, error } = await source.from('campaign_steps').select('id').in('campaign_id', ids);
95
100
  if (error || !Array.isArray(data))
96
- return false;
97
- if (data.length && !await owns('campaign_steps', data.map(row => row.id)))
98
- return false;
101
+ return 'unknown_provenance';
102
+ if (data.length)
103
+ return await owns('campaign_steps', data.map(row => row.id));
99
104
  }
100
- return true;
105
+ return null;
106
+ }
107
+ /** Preserve the boolean authority API; diagnostics never supply another approval path. */
108
+ export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
109
+ return await deleteOwnershipUnavailableReason(projectId, action, params, creatorKey) === null;
101
110
  }
@@ -26,6 +26,14 @@ export declare const SANDBOX_ADMIN_SCOPE: {
26
26
  readonly set_team_member_role: 'org';
27
27
  readonly tenant_export_full: 'project';
28
28
  };
29
+ export type AutoApproveUnavailableReason = 'not_sandbox' | 'not_created_by_this_credential' | 'unknown_provenance' | 'action_not_eligible' | 'org_not_all_sandbox' | 'approval_policy' | 'auto_approval_failed';
29
30
  /** Never consult token claims, BYOD clients or cached display data for this authority. */
31
+ export declare function sandboxAutoApproveUnavailableReason(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<AutoApproveUnavailableReason | null>;
32
+ /** Existing callers keep their boolean contract and the same fail-closed conditions. */
30
33
  export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
34
+ /** Capture the refusal from the same attempt, without a second mutable authority read. */
35
+ export declare function trySandboxApprovalWithReason(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<{
36
+ approved: boolean;
37
+ reason?: AutoApproveUnavailableReason;
38
+ }>;
31
39
  export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
@@ -1,5 +1,5 @@
1
1
  import { adminDb } from '../db.js';
2
- import { DELETE_ITEM_TARGETS, ownsDeleteTargets, requestingCreatorKey } from './mcp-created-items.js';
2
+ import { DELETE_ITEM_TARGETS, deleteOwnershipUnavailableReason, requestingCreatorKey } from './mcp-created-items.js';
3
3
  import { approvalClass } from '@sendinel/domain';
4
4
  import { PROTECTED_ACTIONS } from './approval-actions.js';
5
5
  import { isHumanRequestableAdminTool } from './admin-tools.js';
@@ -16,48 +16,63 @@ export const SANDBOX_ADMIN_SCOPE = {
16
16
  tenant_export_full: 'project',
17
17
  };
18
18
  /** Never consult token claims, BYOD clients or cached display data for this authority. */
19
- export async function sandboxApprovalEligible(projectId, action, onProjectRead, params = {}) {
19
+ export async function sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params = {}) {
20
20
  const deletion = approvalClass(action) === 'D' || action.startsWith('delete_');
21
21
  // No exception for shared org credentials, unknown delete actions or other protected effects.
22
- if (deletion && (!(action in DELETE_ITEM_TARGETS) || !requestingCreatorKey()))
23
- return false;
22
+ if (deletion && !(action in DELETE_ITEM_TARGETS))
23
+ return 'action_not_eligible';
24
+ if (deletion && !requestingCreatorKey())
25
+ return 'unknown_provenance';
24
26
  if (action === 'delete_provider_credential' || (!deletion && PROTECTED_ACTIONS.has(action)))
25
- return false;
27
+ return 'action_not_eligible';
26
28
  try {
27
29
  const source = adminDb().schema('email');
28
30
  const { data, error } = await source.from('projects').select('id, org_id, sandbox_mode, name')
29
31
  .eq('id', projectId).maybeSingle();
30
32
  onProjectRead?.(!error && typeof data?.name === 'string' ? data.name : undefined);
31
- if (error || data?.id !== projectId || data.sandbox_mode !== true)
32
- return false;
33
+ if (error || data?.id !== projectId)
34
+ return 'unknown_provenance';
35
+ if (data.sandbox_mode !== true)
36
+ return 'not_sandbox';
33
37
  if (deletion)
34
- return await ownsDeleteTargets(projectId, action, params, requestingCreatorKey());
38
+ return await deleteOwnershipUnavailableReason(projectId, action, params, requestingCreatorKey());
35
39
  if (!isHumanRequestableAdminTool(action))
36
- return true;
40
+ return null;
37
41
  const scope = SANDBOX_ADMIN_SCOPE[action];
38
42
  if (scope === 'project')
39
- return true;
43
+ return null;
40
44
  if (scope !== 'org' || typeof data.org_id !== 'string')
41
- return false;
45
+ return 'action_not_eligible';
42
46
  const others = await source.from('projects').select('id').eq('org_id', data.org_id)
43
47
  .eq('sandbox_mode', false).limit(1);
44
- return !others.error && Array.isArray(others.data) && others.data.length === 0;
48
+ if (others.error || !Array.isArray(others.data))
49
+ return 'unknown_provenance';
50
+ return others.data.length === 0 ? null : 'org_not_all_sandbox';
45
51
  }
46
52
  catch {
47
- return false;
53
+ return 'unknown_provenance';
48
54
  }
49
55
  }
50
- export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead, params = {}) {
51
- if (!await sandboxApprovalEligible(projectId, action, onProjectRead, params))
52
- return false;
56
+ /** Existing callers keep their boolean contract and the same fail-closed conditions. */
57
+ export async function sandboxApprovalEligible(projectId, action, onProjectRead, params = {}) {
58
+ return await sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params) === null;
59
+ }
60
+ /** Capture the refusal from the same attempt, without a second mutable authority read. */
61
+ export async function trySandboxApprovalWithReason(id, projectId, action, payloadHash, onProjectRead, params = {}) {
62
+ const reason = await sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params);
63
+ if (reason)
64
+ return { approved: false, reason };
53
65
  try {
54
66
  // The RPC performs one conditional UPDATE + audit INSERT in one transaction.
55
67
  const { data, error } = await adminDb().schema('email').rpc('approve_sandbox_action', {
56
68
  p_approval_id: id, p_project_id: projectId, p_action: action, p_payload_hash: payloadHash,
57
69
  });
58
- return !error && data === true;
59
- }
60
- catch {
61
- return false;
70
+ if (!error && data === true)
71
+ return { approved: true };
62
72
  }
73
+ catch { /* An RPC failure never grants authority. */ }
74
+ return { approved: false, reason: 'auto_approval_failed' };
75
+ }
76
+ export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead, params = {}) {
77
+ return (await trySandboxApprovalWithReason(id, projectId, action, payloadHash, onProjectRead, params)).approved;
63
78
  }
@@ -11,7 +11,7 @@ import { generateEmailFromBrief } from '../lib/email-generation.js';
11
11
  import { fireWebhookEvent } from '../lib/webhook-events.js';
12
12
  import { logAudit } from '../audit.js';
13
13
  import { applyCampaignTargetingFields, campaignTriggerKind } from './campaigns.js';
14
- import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
14
+ import { checkCampaignMergeVariables, persistMergeVariableWarnings, summarizeMergeVariableWarnings, checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
15
15
  import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
16
16
  import { dbToolError } from '../lib/db-error.js';
17
17
  /**
@@ -291,9 +291,11 @@ export function registerCampaignSetTools(server) {
291
291
  const projectId = getProjectId();
292
292
  const { approval_id } = args;
293
293
  async function applyCampaigns(set, ids, approval_id, decidedBy) {
294
+ const mergeVariableWarnings = [];
294
295
  const activated = [];
295
296
  const failed = [];
296
297
  for (const c of set.campaigns) {
298
+ const warnings = await checkCampaignMergeVariables(projectId, c, set.steps.filter(s => s.campaign_id === c.id));
297
299
  const { error } = await db()
298
300
  .from('campaigns')
299
301
  .update({ status: 'active', updated_at: new Date().toISOString() })
@@ -305,12 +307,15 @@ export function registerCampaignSetTools(server) {
305
307
  continue;
306
308
  }
307
309
  activated.push(c.id);
310
+ await persistMergeVariableWarnings(projectId, c.id, warnings);
311
+ mergeVariableWarnings.push(...warnings.map(w => ({ ...w, campaign_id: c.id })));
308
312
  fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
309
313
  }
310
314
  await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: decidedBy ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
311
315
  return j({
312
316
  activated,
313
317
  failed,
318
+ merge_variable_warnings: mergeVariableWarnings,
314
319
  ...(set.campaigns.some(c => c.type === 'broadcast') ? {
315
320
  campaigns: previewOf(set.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), set.steps),
316
321
  broadcast_activation: BROADCAST_ACTIVATION,
@@ -348,7 +353,7 @@ export function registerCampaignSetTools(server) {
348
353
  return j({ error: 'this campaign has no trigger, so it would never fire', code: 'validation_failed' });
349
354
  }
350
355
  // Same content gate as the request, so a bad email can't ride through on an old approval.
351
- const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps);
356
+ const readiness = await checkCampaignContent(projectId, current.campaigns, current.steps, { mergeVariables: false });
352
357
  if (readiness.blocking.length)
353
358
  return j(contentNotReadyResponse(readiness.blocking));
354
359
  const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
@@ -396,10 +401,12 @@ export function registerCampaignSetTools(server) {
396
401
  });
397
402
  }
398
403
  // Placeholder text and links to the sending domain never go out (#3853). Merge tags with no data source are warnings.
399
- const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps);
404
+ const readiness = await checkCampaignContent(projectId, set.campaigns, set.steps, { mergeVariables: false });
400
405
  if (readiness.blocking.length)
401
406
  return j(contentNotReadyResponse(readiness.blocking));
402
407
  const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
408
+ const mergeVariableWarnings = (await Promise.all(set.campaigns.map(async (c) => (await checkCampaignMergeVariables(projectId, c, set.steps.filter(s => s.campaign_id === c.id)))
409
+ .map(w => ({ ...w, campaign_id: c.id }))))).flat();
403
410
  const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
404
411
  const channelContext = currentChannelContext();
405
412
  const decision = await assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
@@ -409,6 +416,7 @@ export function registerCampaignSetTools(server) {
409
416
  return applyCampaigns(set, ids);
410
417
  const names = set.campaigns.map((c) => c.name);
411
418
  const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
419
+ merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings),
412
420
  campaign_count: set.campaigns.length,
413
421
  email_count: set.steps.length,
414
422
  campaign_names: names.slice(0, 10).join(', ') + (names.length > 10 ? `, and ${names.length - 10} more` : ''),
@@ -417,6 +425,7 @@ export function registerCampaignSetTools(server) {
417
425
  return j({
418
426
  ...approval,
419
427
  campaigns: previewOf(set.campaigns, set.steps),
428
+ merge_variable_warnings: mergeVariableWarnings,
420
429
  ...(contentWarnings.length ? { content_warnings: contentWarnings } : {}),
421
430
  next: approvalNext('activate_campaigns', approval),
422
431
  });
@@ -32,7 +32,7 @@ import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } fro
32
32
  import { sendEmailForSite } from '../lib/email-provider.js';
33
33
  import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
34
34
  import { logAudit } from '../audit.js';
35
- import { checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
35
+ import { checkCampaignMergeVariables, persistMergeVariableWarnings, summarizeMergeVariableWarnings, checkCampaignContent, contactPropertyKeys, eventKeysFromHistory, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
36
36
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
37
37
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
38
38
  import { callSendinelV1 } from '../lib/v1-client.js';
@@ -1189,6 +1189,7 @@ export function registerCampaignTools(server) {
1189
1189
  approval_id: z.string().uuid().optional().describe('Required when activating. Get this by calling once without it, then have a human approve.'),
1190
1190
  },
1191
1191
  }, async ({ campaign_id, status, approval_id }) => {
1192
+ let mergeVariableWarnings = [];
1192
1193
  // Human approval required for activation
1193
1194
  if (status === 'active') {
1194
1195
  // An imported email whose wait wasn't in the source has no delay; it would never send (#3689).
@@ -1227,10 +1228,11 @@ export function registerCampaignTools(server) {
1227
1228
  .eq('campaign_id', campaign_id);
1228
1229
  if (contentErr)
1229
1230
  return j({ error: `Could not check the campaign's emails: ${dbToolError(contentErr).error}`, code: 'STEP_LOOKUP_FAILED' });
1230
- const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? []);
1231
+ const readiness = await checkCampaignContent(getProjectId(), [campRow], contentSteps ?? [], { mergeVariables: false });
1231
1232
  if (readiness.blocking.length)
1232
1233
  return j(contentNotReadyResponse(readiness.blocking));
1233
1234
  contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
1235
+ mergeVariableWarnings = await checkCampaignMergeVariables(getProjectId(), campRow, contentSteps ?? []);
1234
1236
  }
1235
1237
  const approvalParams = { campaign_id, status };
1236
1238
  if (!approval_id) {
@@ -1246,12 +1248,13 @@ export function registerCampaignTools(server) {
1246
1248
  .eq('project_id', getProjectId())
1247
1249
  .maybeSingle();
1248
1250
  const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext, {
1251
+ merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings),
1249
1252
  campaign_name: camp?.name ?? null,
1250
1253
  enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
1251
1254
  trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
1252
1255
  ...(contentWarnings.length ? { content_warnings: summarizeContentWarnings(contentWarnings) } : {}),
1253
1256
  });
1254
- return j(contentWarnings.length ? { ...approval, content_warnings: contentWarnings } : approval);
1257
+ return j({ ...approval, merge_variable_warnings: mergeVariableWarnings, ...(contentWarnings.length ? { content_warnings: contentWarnings } : {}) });
1255
1258
  }
1256
1259
  }
1257
1260
  else {
@@ -1269,6 +1272,11 @@ export function registerCampaignTools(server) {
1269
1272
  .single();
1270
1273
  if (error)
1271
1274
  return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1275
+ if (status === 'active') {
1276
+ await persistMergeVariableWarnings(getProjectId(), campaign_id, mergeVariableWarnings);
1277
+ await logAudit({ action: 'campaign.status_changed', resourceType: 'campaign', resourceId: campaign_id,
1278
+ details: { status, merge_variable_warnings: mergeVariableWarnings } }).catch(() => { });
1279
+ }
1272
1280
  // Fire webhook for status changes
1273
1281
  const eventMap = { active: 'campaign.activated', completed: 'campaign.completed', paused: 'campaign.paused' };
1274
1282
  if (eventMap[status]) {
@@ -1278,7 +1286,7 @@ export function registerCampaignTools(server) {
1278
1286
  status,
1279
1287
  }).catch(() => { });
1280
1288
  }
1281
- return { content: [{ type: 'text', text: JSON.stringify(campaignMetricsView(data), null, 2) }] };
1289
+ return { content: [{ type: 'text', text: JSON.stringify({ ...campaignMetricsView(data), ...(status === 'active' ? { merge_variable_warnings: mergeVariableWarnings } : {}) }, null, 2) }] };
1282
1290
  });
1283
1291
  server.registerTool('enroll_contact', {
1284
1292
  title: 'Enroll Contact',
@@ -3411,6 +3419,7 @@ Return ONLY valid JSON with this exact shape:
3411
3419
  if (reentry.period_key !== undefined)
3412
3420
  targeting.period_key = reentry.period_key;
3413
3421
  const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
3422
+ let mergeVariableWarnings = activate && !approval_id ? await checkCampaignMergeVariables(getProjectId(), { trigger_event_name: typeof targeting.trigger_event_name === 'string' ? targeting.trigger_event_name : null }, steps.map((step, step_order) => ({ ...step, step_order }))) : [];
3414
3423
  // Human-in-the-loop gate for activation
3415
3424
  if (activate && confirmed) {
3416
3425
  const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, reentry_mode, period_key, ...triggerInput };
@@ -3421,8 +3430,8 @@ Return ONLY valid JSON with this exact shape:
3421
3430
  if (decision.tier !== 'autonomous') {
3422
3431
  if (!approval_id) {
3423
3432
  // No approval token — request one
3424
- const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext);
3425
- return j(approval);
3433
+ const approval = await requestApproval('create_campaign_with_content', approvalParams, undefined, channelContext, { merge_variable_summary: summarizeMergeVariableWarnings(mergeVariableWarnings) });
3434
+ return j({ ...approval, merge_variable_warnings: mergeVariableWarnings });
3426
3435
  }
3427
3436
  // Have a token — verify it's approved
3428
3437
  const status = await checkApproval(approval_id, { consume: true, action: 'create_campaign_with_content', params: approvalParams });
@@ -3459,6 +3468,7 @@ Return ONLY valid JSON with this exact shape:
3459
3468
  if (!confirmed) {
3460
3469
  return j({
3461
3470
  confirmation_required: true,
3471
+ ...(activate ? { merge_variable_warnings: mergeVariableWarnings } : {}),
3462
3472
  campaign: { name, type: campaignType, site: site.name, send_class: sendClass, unsub_footer_template: footerTemplate, ...targeting },
3463
3473
  steps: steps.map((s, i) => ({
3464
3474
  step: i + 1,
@@ -3559,6 +3569,8 @@ Return ONLY valid JSON with this exact shape:
3559
3569
  // 4. Activate if requested
3560
3570
  const willActivate = Boolean(activate && ((segment_id && enrolled > 0) || canActivateOnTrigger));
3561
3571
  if (willActivate) {
3572
+ mergeVariableWarnings = await checkCampaignMergeVariables(getProjectId(), { trigger_event_name: typeof targeting.trigger_event_name === 'string' ? targeting.trigger_event_name : null }, stepInserts);
3573
+ await persistMergeVariableWarnings(getProjectId(), campaign.id, mergeVariableWarnings);
3562
3574
  await db()
3563
3575
  .from('campaigns')
3564
3576
  .update({ status: 'active' })
@@ -3586,6 +3598,7 @@ Return ONLY valid JSON with this exact shape:
3586
3598
  steps_created: steps.length,
3587
3599
  contacts_enrolled: enrolled,
3588
3600
  status: willActivate ? 'active' : 'draft',
3601
+ ...(willActivate ? { merge_variable_warnings: mergeVariableWarnings } : {}),
3589
3602
  message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${willActivate ? 'Campaign is now active — emails will begin sending on the next cron cycle.' : 'Campaign saved as draft.'}`,
3590
3603
  });
3591
3604
  });
@@ -4089,6 +4102,7 @@ Return ONLY valid JSON with this exact shape:
4089
4102
  const unresolvedCount = dlqUnresolvedCount ?? 0;
4090
4103
  return j({
4091
4104
  ...campaignMetricsView(campaign),
4105
+ merge_variable_warnings: campaign.metadata?.merge_variable_warnings ?? [],
4092
4106
  reentry_mode: campaign.period_key ? "per_event_period" : "once_ever",
4093
4107
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
4094
4108
  ...await campaignTriggerOutput(campaign),
@@ -1315,6 +1315,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1315
1315
  company_name: z.string().optional(),
1316
1316
  company_domain: z.string().optional(),
1317
1317
  tags: z.array(z.string()).optional().describe('Initial tags for new subscribers; existing contacts keep their stored tags.'),
1318
+ is_test: z.boolean().optional().describe('Mark a NEW contact as a test contact: excluded from headline counts and default audiences. Ignored for existing contacts.'),
1318
1319
  source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
1319
1320
  utm_source: z.string().optional(),
1320
1321
  utm_medium: z.string().optional(),
@@ -1322,7 +1323,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1322
1323
  utm_content: z.string().optional(),
1323
1324
  utm_term: z.string().optional(),
1324
1325
  },
1325
- }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
1326
+ }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, is_test, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
1326
1327
  try {
1327
1328
  const normalizedTags = normalizeTagInput(tags ?? []);
1328
1329
  const warnings = tagInputWarnings(normalizedTags);
@@ -1370,6 +1371,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1370
1371
  let contact;
1371
1372
  if (!existingContact) {
1372
1373
  const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1374
+ if (is_test === true)
1375
+ row.is_test = true;
1373
1376
  if (tags !== undefined)
1374
1377
  row.tags = normalizedTags.tags;
1375
1378
  if (customProperties)
@@ -1447,11 +1450,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1447
1450
  if (needsConfirm) {
1448
1451
  // Create opt-in token (confirmation email must be sent externally in MCP context)
1449
1452
  await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1450
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
1453
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}), ...(!existing && is_test === true ? { is_test: contact.is_test } : {}) });
1451
1454
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1452
1455
  return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1453
1456
  }
1454
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
1457
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}), ...(!existing && is_test === true ? { is_test: contact.is_test } : {}) });
1455
1458
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1456
1459
  if (!existing)
1457
1460
  logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
@@ -87,7 +87,7 @@ export function registerGdprTools(server) {
87
87
  idempotentHint: false,
88
88
  openWorldHint: false,
89
89
  },
90
- description: 'GDPR Article 17: Permanently delete a subscriber and all their data. Anonymizes email_log, hashes suppressions, cascades to subscriptions/enrollments/tokens. This action is IRREVERSIBLE. Requires human approval via dashboard.',
90
+ description: 'GDPR Article 17: Permanently delete a subscriber and all their data. Anonymizes email_log, hashes suppressions, cascades to subscriptions/enrollments/tokens. This action is IRREVERSIBLE. Also use for test contact cleanup (reason: admin). In a sandbox project, contacts created by the same credential are erased without a human click via the approval_id retry flow. Other cases require human approval via dashboard.',
91
91
  inputSchema: {
92
92
  contact_id: z.string().uuid().describe('UUID of the subscriber to delete'),
93
93
  reason: z.enum(['gdpr_request', 'user_request', 'admin']).optional().default('gdpr_request'),
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
12
12
  * Only tools that exist in the registry belong here.
13
13
  */
14
14
  export const TOOL_ALIASES = {
15
+ delete_subscriber_data: ['delete contact', 'delete contacts', 'remove subscriber', 'remove subscribers', 'clean up test contacts', 'clean up test objects', 'sandbox cleanup'],
15
16
  list_custom_fields: ['custom fields', 'field definitions', 'contact schema', 'unused fields'],
16
17
  update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
17
18
  set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'payment failed', 'trial ending', 'event trigger', 'billing'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.215",
3
+ "version": "1.0.217",
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",