@sendinel/mcp-server 1.0.153 → 1.0.155

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.
@@ -23,7 +23,6 @@ export const APPROVAL_EXEMPT = {
23
23
  approve_approval: "is the approval step itself: only a person-role OAuth connection can decide its own pending request, bound to that token and project, audited; sends nothing",
24
24
  approve_data_proposal: "turns a proposal into an AI draft for review; sends nothing",
25
25
  approve_draft: "marks a draft ready; sending still requires a gated send or activation",
26
- cancel_approval: "only moves an unused approval to rejected (never grants anything); reduces what can run",
27
26
  cancel_send_queue_rows: "cancels queued mail before it sends; reduces sending",
28
27
  clone_campaign: "creates a draft copy of a campaign; nothing sends until activated (gated)",
29
28
  clone_sequence_template: "creates a draft campaign from a template; nothing sends until activated (gated)",
@@ -22,8 +22,10 @@ async function clientName(fallback) {
22
22
  }
23
23
  export async function cancelApproval(approvalId, transportClientName) {
24
24
  const actor = requestContext();
25
- if (!actor?.scopes?.some(scope => scope === 'write' || scope === 'admin')) {
26
- return refuse('approval_forbidden', 'Cancelling an approval requires write scope.');
25
+ const canCancelProject = actor?.scopes?.some(scope => scope === 'write' || scope === 'admin');
26
+ const ownTokenId = actor?.authKind === 'oauth' && actor.scopes?.includes('read') ? actor.oauthTokenId : null;
27
+ if (!actor || (!canCancelProject && !ownTokenId)) {
28
+ return refuse('approval_forbidden', 'Cancellation requires write scope or the requesting OAuth token.');
27
29
  }
28
30
  try {
29
31
  const projectId = getProjectId();
@@ -31,13 +33,17 @@ export async function cancelApproval(approvalId, transportClientName) {
31
33
  const now = new Date().toISOString();
32
34
  // A single conditional write arbitrates cancellation versus approval/consumption.
33
35
  // Preserve the original decision provenance; cancellation never grants authority.
34
- const { data, error } = await store().from('action_approvals')
36
+ const cancellation = store().from('action_approvals')
35
37
  .update({ status: 'rejected' })
36
38
  .eq('id', approvalId).eq('project_id', projectId)
37
- .in('status', ['pending', 'approved']).is('used_at', null).gt('expires_at', now)
38
- .select('id').maybeSingle();
39
+ .in('status', ['pending', 'approved']).is('used_at', null).gt('expires_at', now);
40
+ // Ownership is part of the write, so concurrent changes cannot cross tokens.
41
+ const bound = canCancelProject ? cancellation : cancellation.eq('oauth_token_id', ownTokenId);
42
+ const { data, error } = await bound.select('id').maybeSingle();
39
43
  if (error)
40
44
  return refuse('approval_update_failed', 'Unable to cancel the approval. Check its status before retrying.');
45
+ if (!data && !canCancelProject)
46
+ return refuse('approval_forbidden', 'No cancellable approval for this OAuth token in this project.');
41
47
  if (!data)
42
48
  return refuse('approval_not_cancellable', 'No unexpired, unused pending or approved approval found in this project.');
43
49
  const { error: auditError } = await store().from('audit_log').insert({
@@ -10,9 +10,9 @@ import { TESTER_SITE_FIELDS } from './tester-policy.js';
10
10
  const text = (message) => ({ content: [{ type: 'text', text: message }] });
11
11
  /** Only installed for an authenticated tester connection, never selected by tool arguments. */
12
12
  export function testerRequestTool(action, config, handler) {
13
- const original = z.object(config.inputSchema);
13
+ const original = config.inputSchema instanceof z.ZodObject ? config.inputSchema : z.object(config.inputSchema);
14
14
  // Wrapping before optional prevents Zod defaults from populating an approval-id-only retry.
15
- const inputSchema = Object.fromEntries(Object.entries(config.inputSchema).map(([key, schema]) => [key, z.preprocess(value => value, schema).optional()]));
15
+ const inputSchema = Object.fromEntries(Object.entries(original.shape).map(([key, schema]) => [key, z.preprocess(value => value, schema).optional()]));
16
16
  inputSchema.approval_id = z.string().uuid().optional();
17
17
  return {
18
18
  config: { ...config, inputSchema },
package/build/server.js CHANGED
@@ -88,7 +88,7 @@ export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
88
88
  // ── Scope enforcement ─────────────────────────────────────────────────────
89
89
  // Maps tool names to required scope. Mirrors dashboard's WRITE_TOOLS/ADMIN_TOOLS.
90
90
  const WRITE_TOOLS = new Set([
91
- 'cancel_approval', 'approve_approval',
91
+ 'approve_approval',
92
92
  'recheck_domain_dns',
93
93
  'mark_agent_message', 'draft_agent_reply', 'create_agent_mailbox',
94
94
  'add_subscriber', 'identify_subscriber', 'emit_contact_event', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
@@ -188,7 +188,6 @@ export function testerConnectionAllowsTool(name) {
188
188
  // Retain the existing campaign activation path. A globally gated tool is not by itself
189
189
  // permission for a tester: sends, deletes and access/infrastructure writes stay refused.
190
190
  return getRequiredScope(name) === 'read'
191
- || name === 'cancel_approval'
192
191
  || TESTER_REQUEST_TOOLS.has(name)
193
192
  || name === 'activate_campaigns'
194
193
  || name === 'update_campaign_status';
@@ -324,7 +323,7 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
324
323
  const required = getRequiredScope(name);
325
324
  const tester = isTesterConnection(scopes);
326
325
  const agent = isAgentConnection(scopes);
327
- const allowed = () => name === 'cancel_approval' ? scopeAllows(scopes, 'write') : agent ? agentConnectionAllowsTool(name) : tester
326
+ const allowed = () => agent ? agentConnectionAllowsTool(name) : tester
328
327
  ? testerConnectionAllowsTool(name)
329
328
  : scopeAllows(scopes, required) || (scopeAllows(scopes, 'read') && isHumanRequestableAdminTool(name));
330
329
  if (!allowed()) {
@@ -11,7 +11,7 @@ const j = (data) => txt(JSON.stringify(data, null, 2));
11
11
  export function registerApprovalTools(server, options) {
12
12
  server.registerTool('cancel_approval', {
13
13
  title: 'Cancel Approval',
14
- description: 'Cancel an unexpired, unused pending or approved action approval in this project. Requires write scope. Sets it to rejected; cannot approve or undo cancellation.',
14
+ 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.',
15
15
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
16
16
  inputSchema: { approval_id: z.string().uuid() },
17
17
  }, async ({ approval_id }) => {
@@ -1,4 +1,4 @@
1
- import { validateStepMultiEventRules, stepMultiEventRulesView } from "@sendinel/domain";
1
+ import { validateStepMultiEventRules, stepMultiEventRulesView, validateStepAbsenceRule, stepAbsenceRuleView } from "@sendinel/domain";
2
2
  import { readCampaignMetricGroups, SENT_STATUSES } from "@sendinel/domain";
3
3
  import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
4
4
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
@@ -47,6 +47,12 @@ const multiEventFields = {
47
47
  send_only_if_events: z.array(z.string().trim().min(1)).min(1).max(10).nullable().optional().describe('Required events at send time, ALL by default; null clears. ANDed with legacy gates.'),
48
48
  send_only_if_events_mode: z.enum(['all', 'any']).nullable().optional().describe('Required-event list mode; omitted/null defaults to all on new steps. Omission preserves the mode on updates.'),
49
49
  };
50
+ const absenceFields = {
51
+ send_only_if_no_event: z.string().trim().min(1).nullable().optional().describe('Send only if this event has NOT occurred in the last send_only_if_no_event_days days at send time. Supply both fields together; two nulls clear.'),
52
+ send_only_if_no_event_days: z.number().int().min(1).max(365).nullable().optional().describe('Rolling lookback in days (1–365), independent of enrollment/cycle bounds. Supply with send_only_if_no_event; two nulls clear.'),
53
+ };
54
+ const absencePairValid = (input) => validateStepAbsenceRule(input).ok;
55
+ const absencePairError = 'send_only_if_no_event and send_only_if_no_event_days must both be set or both null';
50
56
  function stripHtml(value) {
51
57
  return value
52
58
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
@@ -884,7 +890,7 @@ export function registerCampaignTools(server) {
884
890
  openWorldHint: false,
885
891
  },
886
892
  description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
887
- inputSchema: {
893
+ inputSchema: z.object({
888
894
  campaign_id: z.string().uuid(),
889
895
  step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
890
896
  subject: z.string().min(1),
@@ -899,11 +905,15 @@ export function registerCampaignTools(server) {
899
905
  skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
900
906
  send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
901
907
  ...multiEventFields,
908
+ ...absenceFields,
902
909
  skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
903
910
  send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
904
- },
905
- }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property }) => {
911
+ }).refine(absencePairValid, { message: absencePairError }),
912
+ }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property }) => {
906
913
  try {
914
+ const absence = validateStepAbsenceRule({ send_only_if_no_event, send_only_if_no_event_days });
915
+ if (!absence.ok)
916
+ return j({ error: absence.error, code: 'INVALID_INPUT' });
907
917
  const events = validateStepMultiEventRules({ skip_if_events, send_only_if_events, send_only_if_events_mode });
908
918
  if (!events.ok)
909
919
  return j({ error: events.error, code: 'INVALID_INPUT' });
@@ -943,7 +953,7 @@ export function registerCampaignTools(server) {
943
953
  const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
944
954
  const { data, error } = await db()
945
955
  .from('campaign_steps')
946
- .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...properties.rules })
956
+ .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...absence.rules, ...properties.rules })
947
957
  .select()
948
958
  .single();
949
959
  if (error)
@@ -3125,6 +3135,7 @@ Return ONLY valid JSON with this exact shape:
3125
3135
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists with optional where filter, or date_field/contact_event configuration.'),
3126
3136
  steps: z.array(z.object({
3127
3137
  ...multiEventFields,
3138
+ ...absenceFields,
3128
3139
  skip_if_event: z.string().trim().min(1).nullable().optional(),
3129
3140
  send_only_if_event: z.string().trim().min(1).nullable().optional(),
3130
3141
  subject: z.string().min(1).describe('Email subject line'),
@@ -3133,7 +3144,7 @@ Return ONLY valid JSON with this exact shape:
3133
3144
  blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
3134
3145
  delay_days: z.number().int().min(0).optional().default(0),
3135
3146
  delay_hours: z.number().int().min(0).max(23).optional().default(0),
3136
- })).min(1).describe('Email steps with pre-built content'),
3147
+ }).refine(absencePairValid, { message: absencePairError })).min(1).describe('Email steps with pre-built content'),
3137
3148
  segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. Optional for triggered campaigns; future trigger matches enroll contacts.'),
3138
3149
  sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
3139
3150
  activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id or a configured triggered campaign.'),
@@ -3142,10 +3153,13 @@ Return ONLY valid JSON with this exact shape:
3142
3153
  },
3143
3154
  }, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, unsub_footer_template, reentry_mode, period_key }) => {
3144
3155
  for (const step of steps) {
3156
+ const absence = validateStepAbsenceRule(step);
3157
+ if (!absence.ok)
3158
+ return j({ error: absence.error, code: 'INVALID_INPUT' });
3145
3159
  const events = validateStepMultiEventRules(step);
3146
3160
  if (!events.ok)
3147
3161
  return j({ error: events.error, code: 'INVALID_INPUT' });
3148
- Object.assign(step, events.rules);
3162
+ Object.assign(step, events.rules, absence.rules);
3149
3163
  }
3150
3164
  const footerTemplate = unsub_footer_template?.trim() ?? null;
3151
3165
  if (footerTemplate !== null) {
@@ -3226,6 +3240,7 @@ Return ONLY valid JSON with this exact shape:
3226
3240
  skip_if_event: s.skip_if_event ?? null,
3227
3241
  send_only_if_event: s.send_only_if_event ?? null,
3228
3242
  ...stepMultiEventRulesView(s),
3243
+ ...stepAbsenceRuleView(s),
3229
3244
  })),
3230
3245
  segment_id: segment_id ?? null,
3231
3246
  sends_per_hour: sends_per_hour ?? null,
@@ -3267,6 +3282,7 @@ Return ONLY valid JSON with this exact shape:
3267
3282
  skip_if_event: s.skip_if_event ?? null,
3268
3283
  send_only_if_event: s.send_only_if_event ?? null,
3269
3284
  ...stepMultiEventRulesView(s),
3285
+ ...stepAbsenceRuleView(s),
3270
3286
  }));
3271
3287
  const { error: stepErr } = await db()
3272
3288
  .from('campaign_steps')
@@ -3364,7 +3380,7 @@ Return ONLY valid JSON with this exact shape:
3364
3380
  return j({ error: (campaignErr ? dbToolError(campaignErr).error : undefined) ?? 'Campaign not found', code: 'NOT_FOUND' });
3365
3381
  const { data, error } = await db()
3366
3382
  .from('campaign_steps')
3367
- .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, created_at, updated_at')
3383
+ .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property, created_at, updated_at')
3368
3384
  .eq('campaign_id', campaign_id)
3369
3385
  .order('step_order', { ascending: true });
3370
3386
  if (error)
@@ -3380,7 +3396,7 @@ Return ONLY valid JSON with this exact shape:
3380
3396
  openWorldHint: false,
3381
3397
  },
3382
3398
  description: 'Update a campaign step — change subject, body, delay, or step_order.',
3383
- inputSchema: {
3399
+ inputSchema: z.object({
3384
3400
  step_id: z.string().uuid(),
3385
3401
  subject: z.string().optional(),
3386
3402
  body_html: z.string().optional(),
@@ -3393,6 +3409,7 @@ Return ONLY valid JSON with this exact shape:
3393
3409
  skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
3394
3410
  send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
3395
3411
  ...multiEventFields,
3412
+ ...absenceFields,
3396
3413
  skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
3397
3414
  send_only_if_event: z.string().trim().min(1).nullable().optional().describe('Event required for this step to send; null clears the rule.'),
3398
3415
  link_actions: z.array(z.discriminatedUnion('action', [
@@ -3405,10 +3422,13 @@ Return ONLY valid JSON with this exact shape:
3405
3422
  continue_url: z.string().url().max(500).optional().describe('Optional http(s) link shown on the thank-you page.'),
3406
3423
  }),
3407
3424
  ])).max(10).optional().describe('Link actions for this step. set_property links show a Confirm page; only the Confirm click writes the property (last click wins). Replaces the whole list.'),
3408
- },
3425
+ }).refine(absencePairValid, { message: absencePairError }),
3409
3426
  }, async ({ step_id, ...updates }) => {
3410
3427
  const projectId = getProjectId();
3411
3428
  const { survey_id, ...stepUpdates } = updates;
3429
+ const absence = validateStepAbsenceRule(stepUpdates);
3430
+ if (!absence.ok)
3431
+ return j({ error: absence.error, code: 'INVALID_INPUT' });
3412
3432
  const events = validateStepMultiEventRules(stepUpdates);
3413
3433
  if (!events.ok)
3414
3434
  return j({ error: events.error, code: 'INVALID_INPUT' });
@@ -3441,6 +3461,7 @@ Return ONLY valid JSON with this exact shape:
3441
3461
  const renderedUpdates = {
3442
3462
  ...stepUpdates,
3443
3463
  ...events.rules,
3464
+ ...absence.rules,
3444
3465
  ...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
3445
3466
  ...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
3446
3467
  ...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
@@ -3452,7 +3473,7 @@ Return ONLY valid JSON with this exact shape:
3452
3473
  .from('campaign_steps')
3453
3474
  .update({ ...renderedUpdates, updated_at: new Date().toISOString() })
3454
3475
  .eq('id', step_id)
3455
- .select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, updated_at')
3476
+ .select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property, updated_at')
3456
3477
  .single();
3457
3478
  if (error)
3458
3479
  return j(dbToolError(error));
@@ -3830,7 +3851,7 @@ Return ONLY valid JSON with this exact shape:
3830
3851
  total_sent: periods?.totals?.sent ?? sentCount ?? 0,
3831
3852
  ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
3832
3853
  ...campaignPeriodFields(periods, period),
3833
- steps: (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
3854
+ steps: (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), ...stepAbsenceRuleView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
3834
3855
  active_enrollments: enrollCount ?? 0,
3835
3856
  send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
3836
3857
  queued_email_count: queuedCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.153",
3
+ "version": "1.0.155",
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",