@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.
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 318 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` | 42 | 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,8 @@ 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)
20
+ 'activate_campaigns', // turns on a set of campaigns behind one approval (#3669)
19
21
  'rotate_custom_webhook_secret', // immediately invalidates an inbound signing secret
20
22
  'create_campaign_with_content', // can activate + send in one call
21
23
  'enroll_segment', // bulk enrollment
@@ -49,6 +51,10 @@ export function getApprovalTier(action, params) {
49
51
  return 'autonomous';
50
52
  return 'protected';
51
53
  }
54
+ // send_broadcast exists for agents and always needs a person's approval, even in autonomous
55
+ // mode: it must never approve itself (#3669, AI-worker boundary in CLAUDE.md).
56
+ if (action === 'send_broadcast' || action === 'activate_campaigns')
57
+ return 'gated';
52
58
  // Autonomous mode: user explicitly opted out of approval gates for gated actions
53
59
  // (request-scoped under HTTP — env is only trustworthy for single-project stdio)
54
60
  const mode = requestContext()?.approvalMode ?? process.env.SENDINEL_APPROVAL_MODE ?? 'standard';
@@ -123,8 +129,28 @@ export function currentChannelContext() {
123
129
  verified: ctx?.channelVerified ?? true,
124
130
  };
125
131
  }
132
+ /** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
133
+ export function approvalExcerpt(html, max = 200) {
134
+ const text = String(html ?? '')
135
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
136
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
137
+ .replace(/<[^>]+>/g, ' ')
138
+ .replace(/&nbsp;/g, ' ')
139
+ .replace(/\s+/g, ' ')
140
+ .trim();
141
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
142
+ }
143
+ function broadcastSummary(d) {
144
+ if (!d.subject)
145
+ return null;
146
+ const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : null;
147
+ const audience = d.segment_name ? `segment "${d.segment_name}"` : (d.audience_label ? String(d.audience_label) : 'the chosen audience');
148
+ const when = d.send_at ? ` at ${d.send_at}` : '';
149
+ const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
150
+ return `Send "${d.subject}" to ${audience}${count ? ` (${count})` : ''}${when}${excerpt}`;
151
+ }
126
152
  /** Build a human-readable summary of the action for the approval screen */
127
- export function buildApprovalSummary(action, params) {
153
+ export function buildApprovalSummary(action, params, display = {}) {
128
154
  const summaries = {
129
155
  launch_campaign: (p) => `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
130
156
  install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
@@ -132,7 +158,26 @@ export function buildApprovalSummary(action, params) {
132
158
  set_connector_mapping: () => `Rewrite the BYOD connector field mapping`,
133
159
  resync_connector: () => `Force a full BYOD connector resync`,
134
160
  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`,
161
+ broadcast_to_segment: (p) => broadcastSummary(display) ?? `Broadcast "${p.name || p.subject || 'untitled'}" to segment ${p.segment_id} — will send emails to real contacts`,
162
+ send_broadcast: (p) => {
163
+ const payload = (p.payload ?? {});
164
+ return broadcastSummary({
165
+ subject: payload.subject,
166
+ recipient_count: Array.isArray(payload.contact_ids) ? payload.contact_ids.length : undefined,
167
+ body_excerpt: approvalExcerpt(payload.body_html),
168
+ send_at: payload.send_at ?? undefined,
169
+ ...display,
170
+ }) ?? `Send a broadcast — will send emails to real contacts`;
171
+ },
172
+ activate_campaigns: (p) => {
173
+ const count = typeof display.campaign_count === 'number' ? display.campaign_count : p.campaign_ids?.length ?? 0;
174
+ const emails = typeof display.email_count === 'number' ? ` (${display.email_count} email${display.email_count === 1 ? '' : 's'})` : '';
175
+ const names = display.campaign_names ? `: ${display.campaign_names}` : '';
176
+ return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires`;
177
+ },
178
+ update_campaign_status: (p) => (p.status === 'active'
179
+ ? `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}"` : ''}`
180
+ : `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
136
181
  rotate_custom_webhook_secret: (p) => `Rotate custom webhook ${p.connection_id} signing secret — immediately invalidates the previous secret with no grace period`,
137
182
  create_campaign_with_content: (p) => `Create + activate campaign "${p.name}" with ${p.steps?.length ?? 0} email(s) and enroll contacts`,
138
183
  enroll_segment: (p) => `Enroll all contacts from site into campaign ${p.campaign_id}`,
@@ -178,9 +223,9 @@ function approvalParamsEqual(expected, actual) {
178
223
  * Request human approval for an action. Creates a pending approval token
179
224
  * and returns instructions for the agent to wait.
180
225
  */
181
- export async function requestApproval(action, params, apiKeyId, channelContext) {
226
+ export async function requestApproval(action, params, apiKeyId, channelContext, display) {
182
227
  const tier = getApprovalTier(action, params);
183
- const summary = buildApprovalSummary(action, params);
228
+ const summary = buildApprovalSummary(action, params, display);
184
229
  const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
185
230
  const { channel, verified } = channelContext ?? currentChannelContext();
186
231
  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
@@ -1,6 +1,7 @@
1
1
  import { runWithToolContext } from './db.js';
2
2
  import { registerAnalyticsTools } from './tools/analytics.js';
3
3
  import { registerCampaignTools } from './tools/campaigns.js';
4
+ import { registerCampaignSetTools } from './tools/campaign-sets.js';
4
5
  import { registerContactTools } from './tools/contacts.js';
5
6
  import { registerDraftTools } from './tools/drafts.js';
6
7
  import { registerGdprTools } from './tools/gdpr.js';
@@ -36,7 +37,8 @@ import { registerConnectorTools } from './tools/connector.js';
36
37
  import { registerCloudImportTools } from './tools/cloud-import.js';
37
38
  const GROUP_REGISTRY = {
38
39
  analytics: registerAnalyticsTools,
39
- campaigns: registerCampaignTools,
40
+ // Campaign sets (#3669) live in their own module but belong to the campaigns group.
41
+ campaigns: (server) => { registerCampaignTools(server); registerCampaignSetTools(server); },
40
42
  contacts: registerContactTools,
41
43
  segments: registerSegmentTools,
42
44
  lists: registerListTools,
@@ -83,7 +85,7 @@ const WRITE_TOOLS = new Set([
83
85
  'add_campaign_branch', 'remove_campaign_branch', 'set_campaign_goal', 'remove_campaign_goal',
84
86
  'review_campaign_portfolio',
85
87
  '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',
88
+ 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'send_broadcast', 'create_campaign_set', 'activate_campaigns', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
87
89
  'create_social_campaign', 'schedule_social_post',
88
90
  'create_social_post', 'update_social_post', 'delete_social_post',
89
91
  'create_draft', 'approve_draft', 'reject_draft',
@@ -300,6 +302,16 @@ function createScopeProxy(server, scopes, toolGroup) {
300
302
  },
301
303
  });
302
304
  }
305
+ /**
306
+ * Server-level guidance every MCP client receives (#3669). Kept short: clients prepend it to the
307
+ * model's context on every connection.
308
+ */
309
+ export const SENDINEL_SERVER_INSTRUCTIONS = [
310
+ 'Sendinel runs email for this workspace: contacts, segments, campaigns, templates and sending.',
311
+ '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.',
312
+ '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.',
313
+ '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.',
314
+ ].join(' ');
303
315
  export function registerAllTools(server, options) {
304
316
  const rawScopes = options?.scopes ?? [];
305
317
  if (rawScopes.includes('gateway')) {
@@ -0,0 +1,24 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ type CampaignSetRow = {
3
+ id: string;
4
+ name: string;
5
+ status: string;
6
+ trigger_event_name: string | null;
7
+ trigger_tag: string | null;
8
+ send_class: string | null;
9
+ site_id: string;
10
+ };
11
+ type StepRow = {
12
+ campaign_id: string;
13
+ step_order: number;
14
+ variant_label: string | null;
15
+ subject: string;
16
+ body_html: string | null;
17
+ body_text: string | null;
18
+ delay_days: number | null;
19
+ delay_hours: number | null;
20
+ };
21
+ /** Fingerprint of everything an approver saw: triggers and every email's exact content. */
22
+ export declare function campaignSetContentHash(campaigns: CampaignSetRow[], steps: StepRow[]): string;
23
+ export declare function registerCampaignSetTools(server: McpServer): void;
24
+ export {};