@sendinel/mcp-server 1.0.96 → 1.0.98

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,7 +1,7 @@
1
- import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue } from "@sendinel/domain";
1
+ import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
2
2
  import { z } from 'zod';
3
3
  import Anthropic from '@anthropic-ai/sdk';
4
- import { assertSendAccepted, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
4
+ import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
5
5
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
6
6
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
7
7
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -764,6 +764,10 @@ export function registerCampaignTools(server) {
764
764
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
765
765
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
766
766
  });
767
+ const propertyConditions = z.array(z.union([
768
+ z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
769
+ z.object({ key: z.string(), op: z.literal('exists') }).strict(),
770
+ ])).min(1).max(10).refine(isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR).nullable().optional();
767
771
  server.registerTool('add_campaign_step', {
768
772
  title: 'Add Campaign Step',
769
773
  annotations: {
@@ -785,10 +789,15 @@ export function registerCampaignTools(server) {
785
789
  variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
786
790
  survey_id: z.string().uuid().optional(),
787
791
  brand_asset_ids: z.array(z.string().uuid()).optional().describe('Stable brand_media_assets ids explicitly referenced by this step.'),
792
+ skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
793
+ send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
788
794
  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.'),
789
795
  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.'),
790
796
  },
791
- }, 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 }) => {
797
+ }, 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_property, send_only_if_property }) => {
798
+ const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
799
+ if (!properties.ok)
800
+ return j({ error: properties.error, code: "INVALID_INPUT" });
792
801
  const renderVars = {};
793
802
  if (survey_id) {
794
803
  const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
@@ -803,7 +812,7 @@ export function registerCampaignTools(server) {
803
812
  const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
804
813
  const { data, error } = await db()
805
814
  .from('campaign_steps')
806
- .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 })
815
+ .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, ...properties.rules })
807
816
  .select()
808
817
  .single();
809
818
  if (error)
@@ -818,6 +827,7 @@ export function registerCampaignTools(server) {
818
827
  }).catch((err) => {
819
828
  console.warn('Failed to record brand asset usage for MCP campaign step:', err);
820
829
  });
830
+ await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
821
831
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
822
832
  });
823
833
  server.registerTool('update_campaign_status', {
@@ -2321,7 +2331,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2321
2331
  .limit(1)
2322
2332
  .maybeSingle();
2323
2333
  if (suppression)
2324
- return txt(`Error: Recipient suppressed — ${suppression.reason}`);
2334
+ return { ...txt('Error: Recipient is suppressed'), code: 'invalid_recipient' };
2325
2335
  // Idempotency check
2326
2336
  if (idempotency_key) {
2327
2337
  const { data: existing } = await db()
@@ -2359,8 +2369,10 @@ Do not include body_html in your response; Sendinel renders body_html from block
2359
2369
  .eq('id', template_id)
2360
2370
  .eq('project_id', projectId)
2361
2371
  .maybeSingle();
2362
- if (templateError)
2363
- return txt(`Error: ${templateError.message}`);
2372
+ if (templateError) {
2373
+ console.error('MCP send template lookup failed:', templateError);
2374
+ return { ...txt('Error: Internal server error'), ...sendError('internal_error') };
2375
+ }
2364
2376
  if (!template)
2365
2377
  return txt('Error: Template not found');
2366
2378
  const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
@@ -2415,10 +2427,13 @@ Do not include body_html in your response; Sendinel renders body_html from block
2415
2427
  assertSendAccepted(sendResult);
2416
2428
  }
2417
2429
  catch (error) {
2430
+ console.error('MCP transactional send failed:', error);
2418
2431
  if (error instanceof SendRefusedError) {
2419
2432
  await db().from('email_log').insert({ project_id: projectId, site_id, to_email: to, from_email: site.from_email, subject: resolvedSubject, category: 'transactional', status: 'failed', error_message: error.message });
2420
2433
  }
2421
- return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
2434
+ const safe = providerSendError(error);
2435
+ const code = error instanceof SendRefusedError ? error.refusal.code : safe.code;
2436
+ return { ...txt(`Error: Send failed — ${safe.error} (${code})`), error: safe.error, code };
2422
2437
  }
2423
2438
  // Log to email_log
2424
2439
  const { data: logRow } = await db().from('email_log').insert({
@@ -3101,7 +3116,7 @@ Return ONLY valid JSON with this exact shape:
3101
3116
  return j({ error: campaignErr?.message ?? 'Campaign not found', code: 'NOT_FOUND' });
3102
3117
  const { data, error } = await db()
3103
3118
  .from('campaign_steps')
3104
- .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, created_at, updated_at')
3119
+ .select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, created_at, updated_at')
3105
3120
  .eq('campaign_id', campaign_id)
3106
3121
  .order('step_order', { ascending: true });
3107
3122
  if (error)
@@ -3127,6 +3142,8 @@ Return ONLY valid JSON with this exact shape:
3127
3142
  delay_hours: z.number().int().min(0).optional(),
3128
3143
  step_order: z.number().int().min(0).optional(),
3129
3144
  survey_id: z.string().uuid().optional(),
3145
+ skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
3146
+ send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
3130
3147
  skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
3131
3148
  send_only_if_event: z.string().trim().min(1).nullable().optional().describe('Event required for this step to send; null clears the rule.'),
3132
3149
  link_actions: z.array(z.discriminatedUnion('action', [
@@ -3143,6 +3160,9 @@ Return ONLY valid JSON with this exact shape:
3143
3160
  }, async ({ step_id, ...updates }) => {
3144
3161
  const projectId = getProjectId();
3145
3162
  const { survey_id, ...stepUpdates } = updates;
3163
+ const properties = validateStepPropertyRules(stepUpdates);
3164
+ if (!properties.ok)
3165
+ return j({ error: properties.error, code: "INVALID_INPUT" });
3146
3166
  const { data: step, error: stepErr } = await db()
3147
3167
  .from('campaign_steps')
3148
3168
  .select('id, campaign_id')
@@ -3179,10 +3199,11 @@ Return ONLY valid JSON with this exact shape:
3179
3199
  .from('campaign_steps')
3180
3200
  .update({ ...renderedUpdates, updated_at: new Date().toISOString() })
3181
3201
  .eq('id', step_id)
3182
- .select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, updated_at')
3202
+ .select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property, updated_at')
3183
3203
  .single();
3184
3204
  if (error)
3185
3205
  return j({ error: error.message });
3206
+ await logAudit({ action: 'campaign.step_updated', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
3186
3207
  return j({ step: data, ok: true });
3187
3208
  });
3188
3209
  server.registerTool('delete_campaign_step', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.96",
3
+ "version": "1.0.98",
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",