@sendinel/mcp-server 1.0.30 → 1.0.32

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.
package/README.md CHANGED
@@ -12,7 +12,7 @@ AI-controlled email operations control plane for campaigns, contacts, segments,
12
12
 
13
13
  ## Tool groups
14
14
 
15
- Sendinel currently exposes 34 tool groups and 315 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
15
+ Sendinel currently exposes 34 tool groups and 316 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
16
16
 
17
17
  | Group | Count | Purpose |
18
18
  |-------|-------|---------|
@@ -23,7 +23,7 @@ Sendinel currently exposes 34 tool groups and 315 tools (gateway mode adds 3 met
23
23
  | `approvals` | 2 | Poll and list risk-tiered action approvals |
24
24
  | `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
25
25
  | `brand_kit` | 5 | Brand Core text import, media library upload/URL ingest, asset-context reads |
26
- | `campaigns` | 39 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates |
26
+ | `campaigns` | 40 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates |
27
27
  | `cloud-import` | 6 | Cloud storage import |
28
28
  | `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
29
29
  | `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
package/build/index.js CHANGED
@@ -7,14 +7,14 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
9
  import express from 'express';
10
- import { registerAllTools } from './server.js';
10
+ import { registerAllTools, SENDINEL_SERVER_INSTRUCTIONS } from './server.js';
11
11
  import { registerAllPrompts } from './prompts.js';
12
12
  import { runWithClient } from './db.js';
13
13
  import { initProjectClient, resolveProjectClientStrict, TenantResolutionError } from './byod-client.js';
14
14
  import { authenticateBearer, McpAuthenticationError, validateKey } from './auth.js';
15
15
  const transport = process.env.MCP_TRANSPORT ?? 'stdio';
16
16
  async function runStdio() {
17
- const server = new McpServer({ name: 'sendinel', version: '0.1.0' });
17
+ const server = new McpServer({ name: 'sendinel', version: '0.1.0' }, { instructions: SENDINEL_SERVER_INSTRUCTIONS });
18
18
  // For stdio, validate SENDINEL_API_KEY and fail closed if it is absent.
19
19
  const apiKey = process.env.SENDINEL_API_KEY;
20
20
  let toolGroups;
@@ -94,7 +94,7 @@ async function runHttp() {
94
94
  throw err;
95
95
  }
96
96
  await runWithClient(projectClient, async () => {
97
- const server = new McpServer({ name: 'sendinel', version: '0.1.0' });
97
+ const server = new McpServer({ name: 'sendinel', version: '0.1.0' }, { instructions: SENDINEL_SERVER_INSTRUCTIONS });
98
98
  registerAllTools(server, {
99
99
  scopes: info.scopes,
100
100
  toolGroups: info.toolGroups,
@@ -25,8 +25,15 @@ export interface ExecutionDecision {
25
25
  export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): ExecutionDecision;
26
26
  /** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
27
27
  export declare function currentChannelContext(): ChannelExecutionContext;
28
+ /**
29
+ * Display-only facts for the approval summary (segment name, count, subject, excerpt). Never
30
+ * stored in params, so it can't change the params an approval is checked against.
31
+ */
32
+ export type ApprovalDisplay = Record<string, string | number | null | undefined>;
33
+ /** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
34
+ export declare function approvalExcerpt(html: unknown, max?: number): string;
28
35
  /** Build a human-readable summary of the action for the approval screen */
29
- export declare function buildApprovalSummary(action: string, params: Record<string, unknown>): string;
36
+ export declare function buildApprovalSummary(action: string, params: Record<string, unknown>, display?: ApprovalDisplay): string;
30
37
  export interface ApprovalResult {
31
38
  requires_approval: true;
32
39
  approval_id: string;
@@ -43,7 +50,7 @@ export interface ApprovalResult {
43
50
  * Request human approval for an action. Creates a pending approval token
44
51
  * and returns instructions for the agent to wait.
45
52
  */
46
- export declare function requestApproval(action: string, params: Record<string, unknown>, apiKeyId?: string, channelContext?: ChannelExecutionContext): Promise<ApprovalResult>;
53
+ export declare function requestApproval(action: string, params: Record<string, unknown>, apiKeyId?: string, channelContext?: ChannelExecutionContext, display?: ApprovalDisplay): Promise<ApprovalResult>;
47
54
  /**
48
55
  * Check if an approval token has been approved.
49
56
  * Returns the approval if approved, null if still pending, throws if rejected/expired.
@@ -16,6 +16,7 @@ const GATED_ACTIONS = new Set([
16
16
  'resync_connector', // forces a full resync
17
17
  'publish_dmarc_record', // writes a DNS record into a customer-owned Cloudflare zone
18
18
  'broadcast_to_segment', // creates + activates a real broadcast
19
+ 'send_broadcast', // one-call broadcast; executes a frozen, approved payload (#3669)
19
20
  'rotate_custom_webhook_secret', // immediately invalidates an inbound signing secret
20
21
  'create_campaign_with_content', // can activate + send in one call
21
22
  'enroll_segment', // bulk enrollment
@@ -49,6 +50,10 @@ export function getApprovalTier(action, params) {
49
50
  return 'autonomous';
50
51
  return 'protected';
51
52
  }
53
+ // send_broadcast exists for agents and always needs a person's approval, even in autonomous
54
+ // mode: it must never approve itself (#3669, AI-worker boundary in CLAUDE.md).
55
+ if (action === 'send_broadcast')
56
+ return 'gated';
52
57
  // Autonomous mode: user explicitly opted out of approval gates for gated actions
53
58
  // (request-scoped under HTTP — env is only trustworthy for single-project stdio)
54
59
  const mode = requestContext()?.approvalMode ?? process.env.SENDINEL_APPROVAL_MODE ?? 'standard';
@@ -123,8 +128,28 @@ export function currentChannelContext() {
123
128
  verified: ctx?.channelVerified ?? true,
124
129
  };
125
130
  }
131
+ /** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
132
+ export function approvalExcerpt(html, max = 200) {
133
+ const text = String(html ?? '')
134
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
135
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
136
+ .replace(/<[^>]+>/g, ' ')
137
+ .replace(/&nbsp;/g, ' ')
138
+ .replace(/\s+/g, ' ')
139
+ .trim();
140
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
141
+ }
142
+ function broadcastSummary(d) {
143
+ if (!d.subject)
144
+ return null;
145
+ const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : null;
146
+ const audience = d.segment_name ? `segment "${d.segment_name}"` : (d.audience_label ? String(d.audience_label) : 'the chosen audience');
147
+ const when = d.send_at ? ` at ${d.send_at}` : '';
148
+ const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
149
+ return `Send "${d.subject}" to ${audience}${count ? ` (${count})` : ''}${when}${excerpt}`;
150
+ }
126
151
  /** Build a human-readable summary of the action for the approval screen */
127
- export function buildApprovalSummary(action, params) {
152
+ export function buildApprovalSummary(action, params, display = {}) {
128
153
  const summaries = {
129
154
  launch_campaign: (p) => `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
130
155
  install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
@@ -132,7 +157,20 @@ export function buildApprovalSummary(action, params) {
132
157
  set_connector_mapping: () => `Rewrite the BYOD connector field mapping`,
133
158
  resync_connector: () => `Force a full BYOD connector resync`,
134
159
  publish_dmarc_record: (p) => `Publish DMARC record for ${p.domain} - writes DNS in a customer-owned Cloudflare zone`,
135
- broadcast_to_segment: (p) => `Broadcast "${p.name || p.subject || 'untitled'}" to segment ${p.segment_id} — will send emails to real contacts`,
160
+ broadcast_to_segment: (p) => broadcastSummary(display) ?? `Broadcast "${p.name || p.subject || 'untitled'}" to segment ${p.segment_id} — will send emails to real contacts`,
161
+ send_broadcast: (p) => {
162
+ const payload = (p.payload ?? {});
163
+ return broadcastSummary({
164
+ subject: payload.subject,
165
+ recipient_count: Array.isArray(payload.contact_ids) ? payload.contact_ids.length : undefined,
166
+ body_excerpt: approvalExcerpt(payload.body_html),
167
+ send_at: payload.send_at ?? undefined,
168
+ ...display,
169
+ }) ?? `Send a broadcast — will send emails to real contacts`;
170
+ },
171
+ update_campaign_status: (p) => (p.status === 'active'
172
+ ? `Activate campaign "${display.campaign_name ?? p.campaign_id}"${typeof display.enrolled === 'number' ? ` — sends email to ${display.enrolled.toLocaleString('en-US')} enrolled contact${display.enrolled === 1 ? '' : 's'}` : ' — will send emails to real contacts'}${display.trigger ? ` and to new contacts on "${display.trigger}"` : ''}`
173
+ : `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
136
174
  rotate_custom_webhook_secret: (p) => `Rotate custom webhook ${p.connection_id} signing secret — immediately invalidates the previous secret with no grace period`,
137
175
  create_campaign_with_content: (p) => `Create + activate campaign "${p.name}" with ${p.steps?.length ?? 0} email(s) and enroll contacts`,
138
176
  enroll_segment: (p) => `Enroll all contacts from site into campaign ${p.campaign_id}`,
@@ -178,9 +216,9 @@ function approvalParamsEqual(expected, actual) {
178
216
  * Request human approval for an action. Creates a pending approval token
179
217
  * and returns instructions for the agent to wait.
180
218
  */
181
- export async function requestApproval(action, params, apiKeyId, channelContext) {
219
+ export async function requestApproval(action, params, apiKeyId, channelContext, display) {
182
220
  const tier = getApprovalTier(action, params);
183
- const summary = buildApprovalSummary(action, params);
221
+ const summary = buildApprovalSummary(action, params, display);
184
222
  const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
185
223
  const { channel, verified } = channelContext ?? currentChannelContext();
186
224
  const { data, error } = await db()
@@ -0,0 +1,11 @@
1
+ import type Anthropic from '@anthropic-ai/sdk';
2
+ import { type ComposerBlock } from './composer-blocks.js';
3
+ export declare function parseAudienceRules(anthropic: Anthropic, description: string): Promise<Record<string, unknown>>;
4
+ export type GeneratedEmail = {
5
+ subject: string;
6
+ preview_text: string | null;
7
+ body_html: string;
8
+ body_text: string;
9
+ blocks: ComposerBlock[];
10
+ };
11
+ export declare function generateEmailFromBrief(anthropic: Anthropic, projectId: string, siteId: string, brief: string): Promise<GeneratedEmail>;
@@ -0,0 +1,92 @@
1
+ import { db } from '../db.js';
2
+ import { getAnthropicModelId } from '../anthropic-model.js';
3
+ import { buildEmailSystemPrompt, buildVoicePrompt } from './voice.js';
4
+ import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from './composer-blocks.js';
5
+ /**
6
+ * Shared AI helpers for the MCP send tools (#3669): turn a plain-language audience into segment
7
+ * rules, and turn a brief into a finished email (Brand Brain voice, Composer blocks rendered to
8
+ * HTML). Same prompts as launch_campaign. A failure throws: no placeholder email is ever produced.
9
+ */
10
+ function parseJsonResponse(raw) {
11
+ return JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
12
+ }
13
+ function responseText(resp) {
14
+ const block = resp.content.find((b) => b.type === 'text');
15
+ return block && 'text' in block ? block.text : '';
16
+ }
17
+ export async function parseAudienceRules(anthropic, description) {
18
+ const resp = await anthropic.messages.create({
19
+ model: getAnthropicModelId(),
20
+ max_tokens: 512,
21
+ system: 'You translate natural language audience descriptions into a JSON filter rules object. Return ONLY valid JSON. Available keys: lifecycle_stage (lead|trial|customer|churned|win-back), payment (object with plan, mrr_cents, ltv_cents, subscription_status, trial_ends_at, churned_at, paid_at), min_score, max_score, source, subscribed_after, subscribed_before, has_opened, has_clicked, tag, custom_property, exclude_suppressed. Example: {"payment":{"plan":"pro","trial_ends_at":{"within_days":7}},"lifecycle_stage":"trial"}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
22
+ messages: [{ role: 'user', content: description }],
23
+ });
24
+ try {
25
+ const rules = parseJsonResponse(responseText(resp) || '{}');
26
+ if (!rules || typeof rules !== 'object' || Array.isArray(rules))
27
+ throw new Error('not an object');
28
+ return rules;
29
+ }
30
+ catch {
31
+ throw new Error(`Could not understand the audience "${description}". Try a saved segment instead.`);
32
+ }
33
+ }
34
+ export async function generateEmailFromBrief(anthropic, projectId, siteId, brief) {
35
+ const [{ data: site, error: siteError }, { data: project, error: projectError }] = await Promise.all([
36
+ db().from('sites').select('name, sending_domain, from_name, from_email, brand_voice').eq('id', siteId).eq('project_id', projectId).maybeSingle(),
37
+ db().from('projects').select('brand_settings').eq('id', projectId).maybeSingle(),
38
+ ]);
39
+ if (siteError)
40
+ throw siteError;
41
+ if (projectError)
42
+ throw projectError;
43
+ const brandCtx = site ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name}` : '';
44
+ const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
45
+ const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt, project?.brand_settings ?? null)}
46
+
47
+ Generate structured Composer blocks first. Return ONLY valid JSON with:
48
+ {
49
+ "execution_trace": object,
50
+ "conclusion": {
51
+ "subject": string,
52
+ "preview_text": string,
53
+ "blocks": ComposerBlock[],
54
+ "body_text": string,
55
+ "cta_text": string,
56
+ "reasoning_log": string,
57
+ "brand_voice_compliance": "PASS" | string
58
+ }
59
+ }
60
+
61
+ ComposerBlock[] must conform to this JSON Schema:
62
+ ${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
63
+
64
+ Do not include body_html in your response; Sendinel renders body_html from blocks.`;
65
+ const resp = await anthropic.messages.create({
66
+ model: getAnthropicModelId(),
67
+ max_tokens: 3000,
68
+ system: systemPrompt,
69
+ messages: [{ role: 'user', content: brief }],
70
+ });
71
+ let conclusion;
72
+ try {
73
+ const parsed = parseJsonResponse(responseText(resp) || '{}');
74
+ conclusion = parsed.conclusion ?? parsed;
75
+ }
76
+ catch {
77
+ throw new Error('The email draft came back in an unreadable format. Try again or pass html instead of brief.');
78
+ }
79
+ const blocks = normalizeComposerBlocks(conclusion?.blocks);
80
+ const subject = typeof conclusion?.subject === 'string' ? conclusion.subject.trim() : '';
81
+ if (!blocks.length || !subject) {
82
+ throw new Error('The email draft was empty. Try again or pass html instead of brief.');
83
+ }
84
+ const previewText = typeof conclusion.preview_text === 'string' ? conclusion.preview_text : null;
85
+ return {
86
+ subject,
87
+ preview_text: previewText,
88
+ body_html: blocksToHtml(blocks, { preheader: previewText }),
89
+ body_text: typeof conclusion.body_text === 'string' && conclusion.body_text.trim() ? conclusion.body_text : blocksToText(blocks),
90
+ blocks,
91
+ };
92
+ }
@@ -0,0 +1,24 @@
1
+ import { type SegmentRules } from '@sendinel/domain';
2
+ /**
3
+ * Who a broadcast can actually reach (#3669). One place for the MCP send tools so the count an
4
+ * approver sees is the count that gets enrolled:
5
+ * - the shared segment base query (test, deleted, globally unsubscribed, cooled excluded),
6
+ * paged and chunked (#3674), with suppressions removed;
7
+ * - only people signed up to the sending site (the send step claims nobody else, #3668);
8
+ * - the project's consent gate when it is on (#3667).
9
+ * Every read throws on failure: an unknown audience must never read as "nobody" or "everyone".
10
+ */
11
+ export type SendClass = 'broadcast' | 'product';
12
+ /** Contact ids matching segment rules (same semantics as the segments tools and the dashboard). */
13
+ export declare function resolveSegmentContactIds(projectId: string, rules: SegmentRules): Promise<string[]>;
14
+ export type SendAudience = {
15
+ /** People the send will enroll. */
16
+ contactIds: string[];
17
+ /** People matching the segment before the sender and consent checks. */
18
+ matched: number;
19
+ /** Matched but not signed up to the sending site. */
20
+ notOnSender: number;
21
+ /** Held back by the consent gate, by reason. Empty when the gate is off. */
22
+ heldBack: Record<string, number>;
23
+ };
24
+ export declare function resolveSendAudience(projectId: string, siteId: string, rules: SegmentRules, sendClass?: SendClass): Promise<SendAudience>;
@@ -0,0 +1,79 @@
1
+ import { applySegmentRules, buildSegmentBaseQuery, chunkIds, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
2
+ import { db } from '../db.js';
3
+ async function loadFieldTypes(projectId) {
4
+ const { data, error } = await db()
5
+ .from('custom_field_definitions')
6
+ .select('key, type, options')
7
+ .eq('project_id', projectId);
8
+ if (error)
9
+ throw error;
10
+ const fields = {};
11
+ for (const row of data ?? []) {
12
+ if (typeof row.key !== 'string' || typeof row.type !== 'string')
13
+ continue;
14
+ fields[row.key] = { type: row.type, options: Array.isArray(row.options) ? row.options : null };
15
+ }
16
+ return fields;
17
+ }
18
+ /** Contact ids matching segment rules (same semantics as the segments tools and the dashboard). */
19
+ export async function resolveSegmentContactIds(projectId, rules) {
20
+ const tree = normalizeSegmentRulesToTree(rules);
21
+ const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
22
+ const q = buildSegmentBaseQuery(db(), projectId);
23
+ const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
24
+ if (siteContactIds && siteContactIds.length === 0)
25
+ return [];
26
+ const data = await readAllRows(filtered);
27
+ const contacts = data.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
28
+ if (tree.exclude_suppressed === false || contacts.length === 0)
29
+ return contacts.map((c) => String(c.id));
30
+ const suppressed = await readAllRows(db().from('suppressions').select('id, email').eq('project_id', projectId));
31
+ const suppressedEmails = new Set(suppressed.map((row) => String(row.email).toLowerCase()));
32
+ return contacts
33
+ .filter((c) => !suppressedEmails.has(String(c.email).toLowerCase()))
34
+ .map((c) => String(c.id));
35
+ }
36
+ export async function resolveSendAudience(projectId, siteId, rules, sendClass = 'broadcast') {
37
+ const matchedIds = await resolveSegmentContactIds(projectId, rules);
38
+ const onSender = new Set();
39
+ for (const chunk of chunkIds(matchedIds)) {
40
+ const rows = await readAllRows(db().from('site_subscriptions').select('contact_id').eq('site_id', siteId).eq('subscribed', true).in('contact_id', chunk), 'contact_id');
41
+ for (const row of rows)
42
+ onSender.add(String(row.contact_id));
43
+ }
44
+ const senderIds = matchedIds.filter((id) => onSender.has(id));
45
+ const { data: project, error: projectError } = await db()
46
+ .from('projects')
47
+ .select('marketing_consent_mode')
48
+ .eq('id', projectId)
49
+ .maybeSingle();
50
+ if (projectError)
51
+ throw projectError;
52
+ const heldBack = {};
53
+ let contactIds = senderIds;
54
+ if (project?.marketing_consent_mode === 'enforce' && senderIds.length > 0) {
55
+ const reasons = new Map();
56
+ for (const chunk of chunkIds(senderIds, 1000)) {
57
+ const { data, error } = await db().rpc('consent_reasons_for_contacts', {
58
+ p_project_id: projectId,
59
+ p_contact_ids: chunk,
60
+ p_send_class: sendClass,
61
+ });
62
+ if (error)
63
+ throw error;
64
+ for (const row of (data ?? [])) {
65
+ reasons.set(String(row.contact_id), row.reason);
66
+ }
67
+ }
68
+ contactIds = [];
69
+ for (const id of senderIds) {
70
+ // A contact the lookup did not return is held back, never assumed to have agreed.
71
+ const reason = reasons.has(id) ? reasons.get(id) : 'no_consent_basis';
72
+ if (reason == null)
73
+ contactIds.push(id);
74
+ else
75
+ heldBack[reason] = (heldBack[reason] ?? 0) + 1;
76
+ }
77
+ }
78
+ return { contactIds, matched: matchedIds.length, notOnSender: matchedIds.length - senderIds.length, heldBack };
79
+ }
package/build/server.d.ts CHANGED
@@ -15,5 +15,10 @@ export declare const GATEWAY_TOOL_NAMES: string[];
15
15
  * through the same proxy path as real MCP registration.
16
16
  */
17
17
  export declare function getRegisteredToolGroups(options?: ToolRegistrationOptions): ToolGroupMap;
18
+ /**
19
+ * Server-level guidance every MCP client receives (#3669). Kept short: clients prepend it to the
20
+ * model's context on every connection.
21
+ */
22
+ export declare const SENDINEL_SERVER_INSTRUCTIONS: string;
18
23
  export declare function registerAllTools(server: McpServer, options?: ToolRegistrationOptions): void;
19
24
  export {};
package/build/server.js CHANGED
@@ -83,7 +83,7 @@ const WRITE_TOOLS = new Set([
83
83
  'add_campaign_branch', 'remove_campaign_branch', 'set_campaign_goal', 'remove_campaign_goal',
84
84
  'review_campaign_portfolio',
85
85
  'list_campaign_steps', 'update_campaign_step', 'delete_campaign_step',
86
- 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
86
+ 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'send_broadcast', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
87
87
  'create_social_campaign', 'schedule_social_post',
88
88
  'create_social_post', 'update_social_post', 'delete_social_post',
89
89
  'create_draft', 'approve_draft', 'reject_draft',
@@ -300,6 +300,16 @@ function createScopeProxy(server, scopes, toolGroup) {
300
300
  },
301
301
  });
302
302
  }
303
+ /**
304
+ * Server-level guidance every MCP client receives (#3669). Kept short: clients prepend it to the
305
+ * model's context on every connection.
306
+ */
307
+ export const SENDINEL_SERVER_INSTRUCTIONS = [
308
+ 'Sendinel runs email for this workspace: contacts, segments, campaigns, templates and sending.',
309
+ 'To send a one-off email, use send_broadcast: call it with an audience and a brief (or html + subject), show the person the subject, recipient count and approve_url it returns, then call it again with only approval_id once they have approved.',
310
+ 'Anything that emails real people needs a person to approve it. Never say an email was sent until the tool that sends it has succeeded.',
311
+ 'In gateway mode, find tools with search_tools (plain words work, for example "send email" or "last campaign"), check arguments with describe_tool, then call invoke_tool.',
312
+ ].join(' ');
303
313
  export function registerAllTools(server, options) {
304
314
  const rawScopes = options?.scopes ?? [];
305
315
  if (rawScopes.includes('gateway')) {
@@ -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';
@@ -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',
@@ -458,8 +491,9 @@ export function registerCampaignTools(server) {
458
491
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('For trigger_type=date_field: { field, days_before?, recurring? }. Otherwise: { events?: string[], tags?: string[] }.'),
459
492
  segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
460
493
  send_at: z.string().datetime().nullable().optional().describe('For type=broadcast only: ISO 8601 instant to schedule the send. Must be in the future. Set null for no schedule. The campaign must still be activated via update_campaign_status.'),
494
+ send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
461
495
  },
462
- }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at }) => {
496
+ }, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class }) => {
463
497
  const limit = await checkPlanLimit('active_campaigns');
464
498
  if (!limit.allowed) {
465
499
  return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
@@ -473,6 +507,7 @@ export function registerCampaignTools(server) {
473
507
  description,
474
508
  sends_per_hour: sends_per_hour ?? null,
475
509
  requires_approval,
510
+ ...(send_class ? { send_class } : {}),
476
511
  };
477
512
  const scheduleError = applyCampaignSendAtField(insertPayload, send_at, type);
478
513
  if (scheduleError)
@@ -567,7 +602,17 @@ export function registerCampaignTools(server) {
567
602
  if (!decision.allowed)
568
603
  return txt(decision.reason ?? 'This channel cannot request this action.');
569
604
  if (decision.tier !== 'autonomous') {
570
- 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
+ });
571
616
  return j(approval);
572
617
  }
573
618
  }
@@ -822,7 +867,16 @@ export function registerCampaignTools(server) {
822
867
  ...(segment.rules ?? {}),
823
868
  ...(segment.site_id ? { site_id: segment.site_id } : {}),
824
869
  };
825
- 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;
826
880
  if (!confirmed) {
827
881
  return j({
828
882
  confirmation_required: true,
@@ -836,16 +890,18 @@ export function registerCampaignTools(server) {
836
890
  sends_per_hour: sends_per_hour ?? null,
837
891
  },
838
892
  matching_contacts: contactIds.length,
893
+ audience: heldBackLines(audience),
839
894
  will_send_at: 'immediately',
840
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.`,
841
896
  });
842
897
  }
843
898
  if (contactIds.length === 0) {
844
899
  return j({
845
- error: 'No eligible contacts match this segment.',
900
+ error: 'Nobody in this segment can get email from this sender right now.',
846
901
  code: 'EMPTY_SEGMENT',
847
902
  segment_id,
848
903
  site_id,
904
+ audience: heldBackLines(audience),
849
905
  });
850
906
  }
851
907
  const channelContext = currentChannelContext();
@@ -854,7 +910,12 @@ export function registerCampaignTools(server) {
854
910
  return txt(decision.reason ?? 'This channel cannot request this action.');
855
911
  if (decision.tier !== 'autonomous') {
856
912
  if (!approval_id) {
857
- 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
+ });
858
919
  return j(approval);
859
920
  }
860
921
  const check = await checkApproval(approval_id, { consume: true, action: 'broadcast_to_segment', params: approvalParams });
@@ -862,98 +923,28 @@ export function registerCampaignTools(server) {
862
923
  return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
863
924
  }
864
925
  }
865
- const limit = await checkPlanLimit('active_campaigns');
866
- if (!limit.allowed) {
867
- return j({
868
- error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`,
869
- code: 'PLAN_LIMIT',
870
- });
871
- }
872
- const { data: campaign, error: campaignErr } = await db()
873
- .from('campaigns')
874
- .insert({
875
- project_id: projectId,
876
- site_id,
877
- segment_id,
926
+ const executed = await executeBroadcast({
927
+ projectId,
928
+ siteId: site_id,
929
+ segmentId: segment_id,
878
930
  name: campaignName,
879
- type: 'broadcast',
880
- status: 'draft',
881
- sends_per_hour: sends_per_hour ?? null,
882
- requires_approval: false,
883
- metadata: { source: 'mcp_broadcast_to_segment' },
884
- })
885
- .select('id')
886
- .single();
887
- if (campaignErr || !campaign) {
888
- return j({ error: campaignErr?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' });
889
- }
890
- const { data: step, error: stepErr } = await db()
891
- .from('campaign_steps')
892
- .insert({
893
- campaign_id: campaign.id,
894
- step_order: 0,
895
931
  subject,
896
- body_html,
897
- body_text: body_text ?? null,
898
- delay_days: 0,
899
- delay_hours: 0,
900
- })
901
- .select('id')
902
- .single();
903
- if (stepErr || !step) {
904
- return j({
905
- error: stepErr?.message ?? 'Campaign created but failed to add broadcast step',
906
- code: 'STEP_CREATE_FAILED',
907
- campaign_id: campaign.id,
908
- });
909
- }
910
- await recordBrandAssetUsage({
911
- client: db(),
912
- projectId,
913
- source: 'campaign_step',
914
- resourceId: step.id,
915
- html: body_html,
916
- assetIds: brand_asset_ids,
917
- }).catch((err) => {
918
- 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',
919
940
  });
920
- const sendAt = new Date().toISOString();
921
- const batchSize = 500;
922
- for (let i = 0; i < contactIds.length; i += batchSize) {
923
- const batch = contactIds.slice(i, i + batchSize).map((contact_id) => ({
924
- campaign_id: campaign.id,
925
- contact_id,
926
- next_send_at: sendAt,
927
- }));
928
- const { error: enrollErr } = await db()
929
- .from('campaign_enrollments')
930
- .upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
931
- if (enrollErr) {
932
- return j({
933
- error: enrollErr.message,
934
- code: 'ENROLLMENT_FAILED',
935
- campaign_id: campaign.id,
936
- enrolled_before_failure: i,
937
- });
938
- }
939
- }
940
- const { error: activateErr } = await db()
941
- .from('campaigns')
942
- .update({
943
- status: 'active',
944
- total_enrolled: contactIds.length,
945
- updated_at: new Date().toISOString(),
946
- })
947
- .eq('id', campaign.id)
948
- .eq('project_id', projectId);
949
- if (activateErr) {
950
- return j({
951
- error: activateErr.message,
952
- code: 'ACTIVATION_FAILED',
953
- campaign_id: campaign.id,
954
- enrolled: contactIds.length,
955
- });
941
+ if (!executed.ok) {
942
+ const { ok: _ok, ...failure } = executed;
943
+ return j(failure);
956
944
  }
945
+ const campaign = { id: executed.campaignId };
946
+ const step = { id: executed.stepId };
947
+ const sendAt = executed.nextSendAt;
957
948
  await logAudit({
958
949
  action: 'campaign.broadcast_to_segment',
959
950
  resourceType: 'campaign',
@@ -983,6 +974,226 @@ export function registerCampaignTools(server) {
983
974
  message: `Broadcast "${campaignName}" is active with ${contactIds.length} contact(s) enrolled for immediate sending.`,
984
975
  });
985
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
+ });
986
1197
  server.registerTool('clone_campaign', {
987
1198
  title: 'Clone Campaign',
988
1199
  annotations: {
@@ -2691,7 +2902,7 @@ Return ONLY valid JSON with this exact shape:
2691
2902
  idempotentHint: true,
2692
2903
  openWorldHint: false,
2693
2904
  },
2694
- description: 'Update a campaign name, site, segment, send time, topic, or trigger settings. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
2905
+ description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
2695
2906
  inputSchema: {
2696
2907
  campaign_id: z.string().uuid(),
2697
2908
  name: z.string().min(1).max(200).optional(),
@@ -2705,8 +2916,9 @@ Return ONLY valid JSON with this exact shape:
2705
2916
  trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
2706
2917
  sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
2707
2918
  send_in_contact_timezone: z.boolean().optional(),
2919
+ send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
2708
2920
  },
2709
- }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone }) => {
2921
+ }, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone, send_class }) => {
2710
2922
  const projectId = getProjectId();
2711
2923
  let campaignType;
2712
2924
  if (send_at !== undefined && send_at !== null) {
@@ -2736,6 +2948,8 @@ Return ONLY valid JSON with this exact shape:
2736
2948
  updates.sends_per_hour = sends_per_hour;
2737
2949
  if (send_in_contact_timezone !== undefined)
2738
2950
  updates.send_in_contact_timezone = send_in_contact_timezone;
2951
+ if (send_class !== undefined)
2952
+ updates.send_class = send_class;
2739
2953
  const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
2740
2954
  if (targetingError)
2741
2955
  return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
@@ -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.30",
3
+ "version": "1.0.32",
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",