@sendinel/mcp-server 1.0.31 → 1.0.33

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.
@@ -12,7 +12,9 @@ import { getProjectId } from '../project.js';
12
12
  import { checkPlanLimit } from '../plan-limits.js';
13
13
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
14
14
  import { fireWebhookEvent } from '../lib/webhook-events.js';
15
- import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
15
+ import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
16
+ import { resolveSendAudience } from '../lib/send-audience.js';
17
+ import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
16
18
  import { getAnthropicModelId } from '../anthropic-model.js';
17
19
  import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } from '../lib/schedule.js';
18
20
  import { sendEmailForSite } from '../lib/email-provider.js';
@@ -67,7 +69,7 @@ function applyCampaignSendAtField(updates, sendAt, campaignType) {
67
69
  updates.send_at = parsed.scheduledAt;
68
70
  return null;
69
71
  }
70
- async function applyCampaignTargetingFields(updates, input, projectId) {
72
+ export async function applyCampaignTargetingFields(updates, input, projectId) {
71
73
  if (input.trigger_event_name !== undefined)
72
74
  updates.trigger_event_name = input.trigger_event_name?.trim() || null;
73
75
  if (input.trigger_tag !== undefined)
@@ -151,63 +153,6 @@ function applySegmentPaymentDateRule(query, field, rule) {
151
153
  q = q.lte(column, rule.before);
152
154
  return q;
153
155
  }
154
- async function getSegmentContactIds(projectId, rules) {
155
- let q = db()
156
- .from('subscribers')
157
- .select('id, email')
158
- .eq('project_id', projectId)
159
- .eq('global_unsubscribed', false)
160
- .is('cooled_at', null);
161
- if (rules.lifecycle_stage)
162
- q = q.eq('lifecycle_stage', rules.lifecycle_stage);
163
- q = applySegmentPaymentRules(q, rules.payment);
164
- if (rules.min_score !== undefined)
165
- q = q.gte('engagement_score', rules.min_score);
166
- if (rules.max_score !== undefined)
167
- q = q.lte('engagement_score', rules.max_score);
168
- if (rules.source)
169
- q = q.eq('source', rules.source);
170
- if (rules.subscribed_after)
171
- q = q.gte('created_at', `${rules.subscribed_after}T00:00:00Z`);
172
- if (rules.subscribed_before)
173
- q = q.lte('created_at', `${rules.subscribed_before}T23:59:59Z`);
174
- if (rules.has_opened === true)
175
- q = q.not('last_opened_at', 'is', null);
176
- if (rules.has_opened === false)
177
- q = q.is('last_opened_at', null);
178
- if (rules.has_clicked === true)
179
- q = q.not('last_clicked_at', 'is', null);
180
- if (rules.has_clicked === false)
181
- q = q.is('last_clicked_at', null);
182
- if (rules.tag)
183
- q = q.contains('tags', [rules.tag]);
184
- if (rules.site_id) {
185
- const { data: subs } = await db()
186
- .from('site_subscriptions')
187
- .select('contact_id')
188
- .eq('site_id', rules.site_id)
189
- .eq('subscribed', true);
190
- const siteIds = (subs ?? []).map((s) => s.contact_id);
191
- if (!siteIds.length)
192
- return [];
193
- q = q.in('id', siteIds);
194
- }
195
- const { data, error } = await q;
196
- if (error || !data)
197
- return [];
198
- let ids = data.map((c) => c.id);
199
- const { data: suppressed } = await db()
200
- .from('suppressions')
201
- .select('email')
202
- .eq('project_id', projectId);
203
- if (suppressed?.length) {
204
- const suppressedEmails = new Set(suppressed.map((s) => s.email));
205
- ids = data
206
- .filter((c) => !suppressedEmails.has(c.email))
207
- .map((c) => c.id);
208
- }
209
- return ids;
210
- }
211
156
  function extractJsonObject(raw) {
212
157
  const cleaned = raw.replace(/```(?:json)?/gi, '').replace(/```/g, '').trim();
213
158
  const start = cleaned.indexOf('{');
@@ -282,6 +227,94 @@ async function validateBranchTarget(campaignId, action, targetStepId, targetCamp
282
227
  }
283
228
  return null;
284
229
  }
230
+ /**
231
+ * Create, enroll and activate a one-email broadcast (#3669). Shared by broadcast_to_segment and
232
+ * send_broadcast so both run the same steps. Enrolls exactly the given contact ids.
233
+ */
234
+ async function executeBroadcast(input) {
235
+ const limit = await checkPlanLimit('active_campaigns');
236
+ if (!limit.allowed) {
237
+ return { ok: false, error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`, code: 'PLAN_LIMIT' };
238
+ }
239
+ const { data: campaign, error: campaignErr } = await db()
240
+ .from('campaigns')
241
+ .insert({
242
+ project_id: input.projectId,
243
+ site_id: input.siteId,
244
+ segment_id: input.segmentId,
245
+ name: input.name,
246
+ type: 'broadcast',
247
+ status: 'draft',
248
+ send_at: input.sendAt,
249
+ send_class: input.sendClass,
250
+ sends_per_hour: input.sendsPerHour ?? null,
251
+ requires_approval: false,
252
+ metadata: { source: input.source, ...(input.metadata ?? {}) },
253
+ })
254
+ .select('id')
255
+ .single();
256
+ if (campaignErr || !campaign) {
257
+ return { ok: false, error: campaignErr?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' };
258
+ }
259
+ const { data: step, error: stepErr } = await db()
260
+ .from('campaign_steps')
261
+ .insert({
262
+ campaign_id: campaign.id,
263
+ step_order: 0,
264
+ subject: input.subject,
265
+ body_html: input.bodyHtml,
266
+ body_text: input.bodyText ?? null,
267
+ ...(Array.isArray(input.blocks) ? { blocks: input.blocks } : {}),
268
+ delay_days: 0,
269
+ delay_hours: 0,
270
+ })
271
+ .select('id')
272
+ .single();
273
+ if (stepErr || !step) {
274
+ return { ok: false, error: stepErr?.message ?? 'Campaign created but failed to add broadcast step', code: 'STEP_CREATE_FAILED', campaign_id: campaign.id };
275
+ }
276
+ await recordBrandAssetUsage({
277
+ client: db(),
278
+ projectId: input.projectId,
279
+ source: 'campaign_step',
280
+ resourceId: step.id,
281
+ html: input.bodyHtml,
282
+ assetIds: input.brandAssetIds,
283
+ }).catch((err) => {
284
+ console.warn('Failed to record brand asset usage for MCP broadcast:', err);
285
+ });
286
+ const nextSendAt = input.sendAt ?? new Date().toISOString();
287
+ const batchSize = 500;
288
+ for (let i = 0; i < input.contactIds.length; i += batchSize) {
289
+ const batch = input.contactIds.slice(i, i + batchSize).map((contact_id) => ({
290
+ campaign_id: campaign.id,
291
+ contact_id,
292
+ next_send_at: nextSendAt,
293
+ }));
294
+ const { error: enrollErr } = await db()
295
+ .from('campaign_enrollments')
296
+ .upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
297
+ if (enrollErr) {
298
+ return { ok: false, error: enrollErr.message, code: 'ENROLLMENT_FAILED', campaign_id: campaign.id, enrolled_before_failure: i };
299
+ }
300
+ }
301
+ const { error: activateErr } = await db()
302
+ .from('campaigns')
303
+ .update({ status: 'active', total_enrolled: input.contactIds.length, updated_at: new Date().toISOString() })
304
+ .eq('id', campaign.id)
305
+ .eq('project_id', input.projectId);
306
+ if (activateErr) {
307
+ return { ok: false, error: activateErr.message, code: 'ACTIVATION_FAILED', campaign_id: campaign.id, enrolled: input.contactIds.length };
308
+ }
309
+ return { ok: true, campaignId: campaign.id, stepId: step.id, enrolled: input.contactIds.length, nextSendAt };
310
+ }
311
+ function heldBackLines(audience) {
312
+ return {
313
+ matched: audience.matched,
314
+ not_signed_up_for_sender: audience.notOnSender,
315
+ held_back_by_consent: audience.heldBack,
316
+ };
317
+ }
285
318
  export function registerCampaignTools(server) {
286
319
  server.registerTool('review_campaign_portfolio', {
287
320
  title: 'Review Campaign Portfolio',
@@ -569,7 +602,17 @@ export function registerCampaignTools(server) {
569
602
  if (!decision.allowed)
570
603
  return txt(decision.reason ?? 'This channel cannot request this action.');
571
604
  if (decision.tier !== 'autonomous') {
572
- const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext);
605
+ const { data: camp } = await db()
606
+ .from('campaigns')
607
+ .select('name, total_enrolled, trigger_event_name, trigger_tag')
608
+ .eq('id', campaign_id)
609
+ .eq('project_id', getProjectId())
610
+ .maybeSingle();
611
+ const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext, {
612
+ campaign_name: camp?.name ?? null,
613
+ enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
614
+ trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
615
+ });
573
616
  return j(approval);
574
617
  }
575
618
  }
@@ -824,7 +867,16 @@ export function registerCampaignTools(server) {
824
867
  ...(segment.rules ?? {}),
825
868
  ...(segment.site_id ? { site_id: segment.site_id } : {}),
826
869
  };
827
- const contactIds = await getSegmentContactIds(projectId, rules);
870
+ // #3669: the same audience rules as send_broadcast and the dashboard: paged, signed up to this
871
+ // sender, consent gate applied. The count shown is the count enrolled.
872
+ let audience;
873
+ try {
874
+ audience = await resolveSendAudience(projectId, site_id, rules);
875
+ }
876
+ catch (error) {
877
+ return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
878
+ }
879
+ const contactIds = audience.contactIds;
828
880
  if (!confirmed) {
829
881
  return j({
830
882
  confirmation_required: true,
@@ -838,16 +890,18 @@ export function registerCampaignTools(server) {
838
890
  sends_per_hour: sends_per_hour ?? null,
839
891
  },
840
892
  matching_contacts: contactIds.length,
893
+ audience: heldBackLines(audience),
841
894
  will_send_at: 'immediately',
842
895
  message: `Broadcast "${campaignName}" will enroll ${contactIds.length} eligible contact(s) from segment "${segment.name}" and activate immediately. Call again with confirmed=true to proceed.`,
843
896
  });
844
897
  }
845
898
  if (contactIds.length === 0) {
846
899
  return j({
847
- error: 'No eligible contacts match this segment.',
900
+ error: 'Nobody in this segment can get email from this sender right now.',
848
901
  code: 'EMPTY_SEGMENT',
849
902
  segment_id,
850
903
  site_id,
904
+ audience: heldBackLines(audience),
851
905
  });
852
906
  }
853
907
  const channelContext = currentChannelContext();
@@ -856,7 +910,12 @@ export function registerCampaignTools(server) {
856
910
  return txt(decision.reason ?? 'This channel cannot request this action.');
857
911
  if (decision.tier !== 'autonomous') {
858
912
  if (!approval_id) {
859
- const approval = await requestApproval('broadcast_to_segment', approvalParams, undefined, channelContext);
913
+ const approval = await requestApproval('broadcast_to_segment', approvalParams, undefined, channelContext, {
914
+ subject,
915
+ segment_name: segment.name,
916
+ recipient_count: contactIds.length,
917
+ body_excerpt: approvalExcerpt(body_html),
918
+ });
860
919
  return j(approval);
861
920
  }
862
921
  const check = await checkApproval(approval_id, { consume: true, action: 'broadcast_to_segment', params: approvalParams });
@@ -864,98 +923,28 @@ export function registerCampaignTools(server) {
864
923
  return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
865
924
  }
866
925
  }
867
- const limit = await checkPlanLimit('active_campaigns');
868
- if (!limit.allowed) {
869
- return j({
870
- error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`,
871
- code: 'PLAN_LIMIT',
872
- });
873
- }
874
- const { data: campaign, error: campaignErr } = await db()
875
- .from('campaigns')
876
- .insert({
877
- project_id: projectId,
878
- site_id,
879
- segment_id,
926
+ const executed = await executeBroadcast({
927
+ projectId,
928
+ siteId: site_id,
929
+ segmentId: segment_id,
880
930
  name: campaignName,
881
- type: 'broadcast',
882
- status: 'draft',
883
- sends_per_hour: sends_per_hour ?? null,
884
- requires_approval: false,
885
- metadata: { source: 'mcp_broadcast_to_segment' },
886
- })
887
- .select('id')
888
- .single();
889
- if (campaignErr || !campaign) {
890
- return j({ error: campaignErr?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' });
891
- }
892
- const { data: step, error: stepErr } = await db()
893
- .from('campaign_steps')
894
- .insert({
895
- campaign_id: campaign.id,
896
- step_order: 0,
897
931
  subject,
898
- body_html,
899
- body_text: body_text ?? null,
900
- delay_days: 0,
901
- delay_hours: 0,
902
- })
903
- .select('id')
904
- .single();
905
- if (stepErr || !step) {
906
- return j({
907
- error: stepErr?.message ?? 'Campaign created but failed to add broadcast step',
908
- code: 'STEP_CREATE_FAILED',
909
- campaign_id: campaign.id,
910
- });
911
- }
912
- await recordBrandAssetUsage({
913
- client: db(),
914
- projectId,
915
- source: 'campaign_step',
916
- resourceId: step.id,
917
- html: body_html,
918
- assetIds: brand_asset_ids,
919
- }).catch((err) => {
920
- console.warn('Failed to record brand asset usage for MCP broadcast:', err);
932
+ bodyHtml: body_html,
933
+ bodyText: body_text ?? null,
934
+ sendsPerHour: sends_per_hour ?? null,
935
+ contactIds,
936
+ sendAt: null,
937
+ sendClass: 'broadcast',
938
+ brandAssetIds: brand_asset_ids,
939
+ source: 'mcp_broadcast_to_segment',
921
940
  });
922
- const sendAt = new Date().toISOString();
923
- const batchSize = 500;
924
- for (let i = 0; i < contactIds.length; i += batchSize) {
925
- const batch = contactIds.slice(i, i + batchSize).map((contact_id) => ({
926
- campaign_id: campaign.id,
927
- contact_id,
928
- next_send_at: sendAt,
929
- }));
930
- const { error: enrollErr } = await db()
931
- .from('campaign_enrollments')
932
- .upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
933
- if (enrollErr) {
934
- return j({
935
- error: enrollErr.message,
936
- code: 'ENROLLMENT_FAILED',
937
- campaign_id: campaign.id,
938
- enrolled_before_failure: i,
939
- });
940
- }
941
- }
942
- const { error: activateErr } = await db()
943
- .from('campaigns')
944
- .update({
945
- status: 'active',
946
- total_enrolled: contactIds.length,
947
- updated_at: new Date().toISOString(),
948
- })
949
- .eq('id', campaign.id)
950
- .eq('project_id', projectId);
951
- if (activateErr) {
952
- return j({
953
- error: activateErr.message,
954
- code: 'ACTIVATION_FAILED',
955
- campaign_id: campaign.id,
956
- enrolled: contactIds.length,
957
- });
941
+ if (!executed.ok) {
942
+ const { ok: _ok, ...failure } = executed;
943
+ return j(failure);
958
944
  }
945
+ const campaign = { id: executed.campaignId };
946
+ const step = { id: executed.stepId };
947
+ const sendAt = executed.nextSendAt;
959
948
  await logAudit({
960
949
  action: 'campaign.broadcast_to_segment',
961
950
  resourceType: 'campaign',
@@ -985,6 +974,226 @@ export function registerCampaignTools(server) {
985
974
  message: `Broadcast "${campaignName}" is active with ${contactIds.length} contact(s) enrolled for immediate sending.`,
986
975
  });
987
976
  });
977
+ server.registerTool('send_broadcast', {
978
+ title: 'Send Broadcast',
979
+ annotations: {
980
+ readOnlyHint: false,
981
+ destructiveHint: true,
982
+ idempotentHint: false,
983
+ openWorldHint: true,
984
+ },
985
+ description: 'Send a one-off email in two calls. Call 1: give an audience (audience in plain words, or segment_id) and content (brief for an AI draft, or html + subject); it returns the finished email, the recipient count, the sender and an approval_id, and sends nothing. A person approves it (approve_url works on a phone). Call 2: pass ONLY approval_id; it sends exactly what was approved. Use this for "send an email to…", newsletters, announcements.',
986
+ inputSchema: {
987
+ audience: z.string().min(3).max(500).optional().describe('Who gets it, in plain words (for example "trial users who have not opened anything"). Use this or segment_id.'),
988
+ segment_id: z.string().uuid().optional().describe('A saved segment. Use this or audience.'),
989
+ brief: z.string().min(10).max(4000).optional().describe('What the email should say; Sendinel drafts it in your brand voice. Use this or html.'),
990
+ html: z.string().min(1).optional().describe('A finished HTML body. Requires subject. Use this or brief.'),
991
+ subject: z.string().min(1).max(300).optional().describe('Subject line. Required with html; overrides the drafted subject with brief.'),
992
+ body_text: z.string().optional().describe('Plain-text version for html. Derived from the HTML when omitted.'),
993
+ site_id: z.string().uuid().optional().describe('Sender. Optional when the project has exactly one sender.'),
994
+ send_at: z.string().datetime().optional().describe('Future ISO time to send. Omit to send right after approval.'),
995
+ name: z.string().min(1).max(200).optional().describe('Campaign name. Defaults from the subject.'),
996
+ send_class: z.enum(['broadcast', 'product']).optional().describe('broadcast (marketing, default) or product (account and onboarding email). Decides which consent rules apply.'),
997
+ brand_asset_ids: z.array(z.string().uuid()).optional().describe('Brand media assets used in the body.'),
998
+ approval_id: z.string().uuid().optional().describe('Call 2 only: the approved approval_id from call 1. Pass nothing else.'),
999
+ },
1000
+ }, async (args) => {
1001
+ const projectId = getProjectId();
1002
+ const { approval_id } = args;
1003
+ // ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
1004
+ if (approval_id) {
1005
+ const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
1006
+ if (extras.length) {
1007
+ return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
1008
+ }
1009
+ const { data: row, error: rowErr } = await db()
1010
+ .from('action_approvals')
1011
+ .select('params')
1012
+ .eq('id', approval_id)
1013
+ .eq('project_id', projectId)
1014
+ .eq('action', 'send_broadcast')
1015
+ .maybeSingle();
1016
+ if (rowErr)
1017
+ return j({ error: rowErr.message, code: 'APPROVAL_LOOKUP_FAILED' });
1018
+ if (!row)
1019
+ return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
1020
+ const stored = (row.params ?? {});
1021
+ const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
1022
+ if (!check.approved) {
1023
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1024
+ }
1025
+ const payload = (stored.payload ?? {});
1026
+ const contactIds = Array.isArray(payload.contact_ids) ? payload.contact_ids.map(String) : [];
1027
+ if (payload.send_at && new Date(payload.send_at).getTime() <= Date.now()) {
1028
+ // An approved future time that has now passed: send right away rather than silently skip.
1029
+ payload.send_at = null;
1030
+ }
1031
+ const executed = await executeBroadcast({
1032
+ projectId,
1033
+ siteId: String(payload.site_id),
1034
+ segmentId: payload.segment_id ?? null,
1035
+ name: String(payload.name),
1036
+ subject: String(payload.subject),
1037
+ bodyHtml: String(payload.body_html),
1038
+ bodyText: payload.body_text ?? null,
1039
+ blocks: Array.isArray(payload.blocks) ? payload.blocks : null,
1040
+ contactIds,
1041
+ sendAt: payload.send_at ?? null,
1042
+ sendClass: payload.send_class === 'product' ? 'product' : 'broadcast',
1043
+ brandAssetIds: Array.isArray(payload.brand_asset_ids) ? payload.brand_asset_ids : undefined,
1044
+ source: 'mcp_send_broadcast',
1045
+ metadata: { approval_id, ...(payload.rules ? { audience_rules: payload.rules } : {}) },
1046
+ });
1047
+ if (!executed.ok) {
1048
+ const { ok: _ok, ...failure } = executed;
1049
+ return j(failure);
1050
+ }
1051
+ await logAudit({
1052
+ action: 'campaign.send_broadcast',
1053
+ resourceType: 'campaign',
1054
+ resourceId: executed.campaignId,
1055
+ details: { approval_id, decided_by: check.decided_by ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
1056
+ }).catch(() => { });
1057
+ fireWebhookEvent('campaign.activated', {
1058
+ campaign_id: executed.campaignId,
1059
+ campaign_name: payload.name,
1060
+ type: 'broadcast',
1061
+ contacts_enrolled: executed.enrolled,
1062
+ steps: 1,
1063
+ }).catch(() => { });
1064
+ return j({
1065
+ campaign_id: executed.campaignId,
1066
+ step_id: executed.stepId,
1067
+ status: 'active',
1068
+ contacts_enrolled: executed.enrolled,
1069
+ send_at: payload.send_at ?? 'now',
1070
+ message: payload.send_at
1071
+ ? `Scheduled: "${payload.subject}" goes to ${executed.enrolled} people at ${payload.send_at}.`
1072
+ : `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
1073
+ });
1074
+ }
1075
+ // ── Call 1: resolve, render, count, request approval. Sends nothing. ──
1076
+ const { audience, segment_id, brief, html, subject, body_text, site_id, send_at, name, send_class, brand_asset_ids } = args;
1077
+ if (Boolean(audience) === Boolean(segment_id))
1078
+ return j({ error: 'Give exactly one of audience or segment_id.', code: 'AUDIENCE_REQUIRED' });
1079
+ if (Boolean(brief) === Boolean(html))
1080
+ return j({ error: 'Give exactly one of brief or html.', code: 'CONTENT_REQUIRED' });
1081
+ if (html && !subject)
1082
+ return j({ error: 'subject is required with html.', code: 'SUBJECT_REQUIRED' });
1083
+ let sendAt = null;
1084
+ if (send_at) {
1085
+ const parsed = parseFutureDate(send_at, 'send_at');
1086
+ if ('error' in parsed)
1087
+ return j({ error: parsed.error, code: 'INVALID_SEND_AT' });
1088
+ sendAt = parsed.scheduledAt;
1089
+ }
1090
+ // Sender: the given one, or the only one. Never guessed between several.
1091
+ const { data: sites, error: sitesErr } = await db()
1092
+ .from('sites')
1093
+ .select('id, name, from_email')
1094
+ .eq('project_id', projectId);
1095
+ if (sitesErr)
1096
+ return j({ error: sitesErr.message, code: 'SITE_LOOKUP_FAILED' });
1097
+ const site = site_id ? (sites ?? []).find((s) => s.id === site_id) : (sites?.length === 1 ? sites[0] : null);
1098
+ if (!site) {
1099
+ return j({
1100
+ error: site_id ? 'Site not found in this project.' : 'This project has more than one sender. Pass site_id.',
1101
+ code: site_id ? 'SITE_NOT_FOUND' : 'SITE_REQUIRED',
1102
+ sites: (sites ?? []).map((s) => ({ id: s.id, name: s.name, from_email: s.from_email })),
1103
+ });
1104
+ }
1105
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1106
+ if ((audience || brief) && !apiKey)
1107
+ return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_NOT_CONFIGURED' });
1108
+ const anthropic = apiKey ? new Anthropic({ apiKey }) : null;
1109
+ let rules;
1110
+ let segmentName;
1111
+ if (segment_id) {
1112
+ const { data: segment, error: segmentErr } = await db()
1113
+ .from('segments')
1114
+ .select('id, name, rules, site_id')
1115
+ .eq('id', segment_id)
1116
+ .eq('project_id', projectId)
1117
+ .maybeSingle();
1118
+ if (segmentErr)
1119
+ return j({ error: segmentErr.message, code: 'SEGMENT_LOOKUP_FAILED' });
1120
+ if (!segment)
1121
+ return j({ error: 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
1122
+ rules = { ...(segment.rules ?? {}), ...(segment.site_id ? { site_id: segment.site_id } : {}) };
1123
+ segmentName = segment.name;
1124
+ }
1125
+ else {
1126
+ try {
1127
+ rules = await parseAudienceRules(anthropic, audience);
1128
+ }
1129
+ catch (error) {
1130
+ return j({ error: error instanceof Error ? error.message : String(error), code: 'AUDIENCE_NOT_UNDERSTOOD' });
1131
+ }
1132
+ segmentName = audience;
1133
+ }
1134
+ const sendClass = send_class === 'product' ? 'product' : 'broadcast';
1135
+ let resolved;
1136
+ try {
1137
+ resolved = await resolveSendAudience(projectId, site.id, rules, sendClass);
1138
+ }
1139
+ catch (error) {
1140
+ return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
1141
+ }
1142
+ if (resolved.contactIds.length === 0) {
1143
+ return j({ error: 'Nobody in this audience can get this email from this sender.', code: 'EMPTY_AUDIENCE', audience: heldBackLines(resolved), rules });
1144
+ }
1145
+ let content;
1146
+ if (brief) {
1147
+ try {
1148
+ const generated = await generateEmailFromBrief(anthropic, projectId, site.id, brief);
1149
+ content = { subject: subject ?? generated.subject, body_html: generated.body_html, body_text: generated.body_text, blocks: generated.blocks };
1150
+ }
1151
+ catch (error) {
1152
+ return j({ error: error instanceof Error ? error.message : String(error), code: 'DRAFT_FAILED' });
1153
+ }
1154
+ }
1155
+ else {
1156
+ content = { subject: subject, body_html: html, body_text: body_text ?? approvalExcerpt(html, 100000), blocks: null };
1157
+ }
1158
+ const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
1159
+ const payload = {
1160
+ site_id: site.id,
1161
+ segment_id: segment_id ?? null,
1162
+ rules: segment_id ? null : rules,
1163
+ subject: content.subject,
1164
+ body_html: content.body_html,
1165
+ body_text: content.body_text,
1166
+ blocks: content.blocks,
1167
+ name: campaignName,
1168
+ send_at: sendAt,
1169
+ send_class: sendClass,
1170
+ brand_asset_ids: brand_asset_ids ?? null,
1171
+ // Frozen at approval time: the people approved are the people enrolled.
1172
+ contact_ids: resolved.contactIds,
1173
+ };
1174
+ const approvalParams = { payload };
1175
+ const channelContext = currentChannelContext();
1176
+ const decision = assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
1177
+ if (!decision.allowed)
1178
+ return txt(decision.reason ?? 'This channel cannot request this action.');
1179
+ const approval = await requestApproval('send_broadcast', approvalParams, undefined, channelContext, {
1180
+ subject: content.subject,
1181
+ segment_name: segment_id ? segmentName : null,
1182
+ audience_label: segment_id ? null : `"${segmentName}"`,
1183
+ recipient_count: resolved.contactIds.length,
1184
+ body_excerpt: approvalExcerpt(content.body_html),
1185
+ send_at: sendAt,
1186
+ });
1187
+ return j({
1188
+ ...approval,
1189
+ rendered: { subject: content.subject, body_html: content.body_html, body_text: content.body_text },
1190
+ recipient_count: resolved.contactIds.length,
1191
+ audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
1192
+ site: { id: site.id, name: site.name, from_email: site.from_email },
1193
+ send_at: sendAt ?? 'right after approval',
1194
+ next: 'Show the user the subject, recipient count and approve_url. After a person approves, call send_broadcast with only approval_id. Do not say it was sent before that call succeeds.',
1195
+ });
1196
+ });
988
1197
  server.registerTool('clone_campaign', {
989
1198
  title: 'Clone Campaign',
990
1199
  annotations: {
@@ -3,4 +3,15 @@ export interface GatewayOptions {
3
3
  scopes?: string[];
4
4
  toolGroups?: string[];
5
5
  }
6
+ /**
7
+ * Plain words people use for a tool, so search_tools finds "send email", "welcome" or
8
+ * "last campaign" (#3669). Matched alongside name, group and description.
9
+ */
10
+ export declare const INTENT_ALIASES: Record<string, string[]>;
11
+ /** Every word of the query must appear somewhere in the tool's text (name, group, description, aliases). */
12
+ export declare function matchesToolQuery(entry: {
13
+ name: string;
14
+ group: string;
15
+ description: string;
16
+ }, query: string): boolean;
6
17
  export declare function registerGatewayTools(server: McpServer, options: GatewayOptions): void;
@@ -9,6 +9,29 @@ const notFound = (message) => ({
9
9
  isError: true,
10
10
  content: [{ type: 'text', text: message }],
11
11
  });
12
+ /**
13
+ * Plain words people use for a tool, so search_tools finds "send email", "welcome" or
14
+ * "last campaign" (#3669). Matched alongside name, group and description.
15
+ */
16
+ export const INTENT_ALIASES = {
17
+ send_broadcast: ['send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email'],
18
+ list_campaigns: ['last campaign', 'latest campaign', 'recent campaign', 'my campaigns'],
19
+ get_campaign: ['last campaign', 'campaign details', 'campaign results'],
20
+ create_campaign: ['welcome', 'welcome email', 'onboarding', 'drip', 'sequence', 'triggered email'],
21
+ add_campaign_step: ['welcome', 'onboarding', 'follow up email', 'next email in sequence'],
22
+ };
23
+ /** Every word of the query must appear somewhere in the tool's text (name, group, description, aliases). */
24
+ export function matchesToolQuery(entry, query) {
25
+ const q = query.trim().toLowerCase();
26
+ if (!q)
27
+ return true;
28
+ const haystack = [entry.name, entry.name.replace(/_/g, ' '), entry.group, entry.description, ...(INTENT_ALIASES[entry.name] ?? [])]
29
+ .join(' \n ')
30
+ .toLowerCase();
31
+ if (haystack.includes(q))
32
+ return true;
33
+ return q.split(/\s+/).filter(Boolean).every((token) => haystack.includes(token));
34
+ }
12
35
  async function buildCatalog(options) {
13
36
  const innerServer = new McpServer({ name: 'sendinel-gateway-inner', version: '1.0.0' });
14
37
  registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups });
@@ -39,7 +62,7 @@ export function registerGatewayTools(server, options) {
39
62
  description: 'Search the permitted Sendinel MCP tool catalog by keyword and/or group. Returns name, group, and description. Call describe_tool for the full input schema before invoke_tool.',
40
63
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
41
64
  inputSchema: {
42
- query: z.string().optional().describe('Keyword to match against tool name, group, or description'),
65
+ query: z.string().optional().describe('Words to match against tool name, group, description, or common phrasing (for example "send email")'),
43
66
  group: z.string().optional().describe('Restrict results to one tool group, for example campaigns'),
44
67
  },
45
68
  }, async ({ query, group }) => {
@@ -49,11 +72,7 @@ export function registerGatewayTools(server, options) {
49
72
  const results = catalog.entries.filter((tool) => {
50
73
  if (requestedGroup && tool.group !== requestedGroup)
51
74
  return false;
52
- if (!q)
53
- return true;
54
- return (tool.name.toLowerCase().includes(q) ||
55
- tool.group.toLowerCase().includes(q) ||
56
- tool.description.toLowerCase().includes(q));
75
+ return matchesToolQuery(tool, q);
57
76
  });
58
77
  return jsonResult({
59
78
  tools: results.map(({ name, group, description }) => ({ name, group, description })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
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",