@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 +2 -2
- package/build/index.js +3 -3
- package/build/lib/action-approvals.d.ts +9 -2
- package/build/lib/action-approvals.js +49 -4
- package/build/lib/email-generation.d.ts +11 -0
- package/build/lib/email-generation.js +92 -0
- package/build/lib/send-audience.d.ts +24 -0
- package/build/lib/send-audience.js +79 -0
- package/build/server.d.ts +5 -0
- package/build/server.js +14 -2
- package/build/tools/campaign-sets.d.ts +24 -0
- package/build/tools/campaign-sets.js +327 -0
- package/build/tools/campaigns.d.ts +7 -0
- package/build/tools/campaigns.js +360 -151
- package/build/tools/gateway.d.ts +11 -0
- package/build/tools/gateway.js +25 -6
- package/package.json +1 -1
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
|
|
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` |
|
|
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
|
|
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(/ /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
|
|
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 {};
|