@sendinel/mcp-server 1.0.31 → 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 +2 -2
- package/build/index.js +3 -3
- package/build/lib/action-approvals.d.ts +9 -2
- package/build/lib/action-approvals.js +42 -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 +11 -1
- package/build/tools/campaigns.js +359 -150
- 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 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` |
|
|
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
|
|
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(/ /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')) {
|
package/build/tools/campaigns.js
CHANGED
|
@@ -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',
|
|
@@ -569,7 +602,17 @@ export function registerCampaignTools(server) {
|
|
|
569
602
|
if (!decision.allowed)
|
|
570
603
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
571
604
|
if (decision.tier !== 'autonomous') {
|
|
572
|
-
const
|
|
605
|
+
const { data: camp } = await db()
|
|
606
|
+
.from('campaigns')
|
|
607
|
+
.select('name, total_enrolled, trigger_event_name, trigger_tag')
|
|
608
|
+
.eq('id', campaign_id)
|
|
609
|
+
.eq('project_id', getProjectId())
|
|
610
|
+
.maybeSingle();
|
|
611
|
+
const approval = await requestApproval('update_campaign_status', approvalParams, undefined, channelContext, {
|
|
612
|
+
campaign_name: camp?.name ?? null,
|
|
613
|
+
enrolled: typeof camp?.total_enrolled === 'number' ? camp.total_enrolled : null,
|
|
614
|
+
trigger: camp?.trigger_event_name ?? (camp?.trigger_tag ? `tag ${camp.trigger_tag}` : null),
|
|
615
|
+
});
|
|
573
616
|
return j(approval);
|
|
574
617
|
}
|
|
575
618
|
}
|
|
@@ -824,7 +867,16 @@ export function registerCampaignTools(server) {
|
|
|
824
867
|
...(segment.rules ?? {}),
|
|
825
868
|
...(segment.site_id ? { site_id: segment.site_id } : {}),
|
|
826
869
|
};
|
|
827
|
-
|
|
870
|
+
// #3669: the same audience rules as send_broadcast and the dashboard: paged, signed up to this
|
|
871
|
+
// sender, consent gate applied. The count shown is the count enrolled.
|
|
872
|
+
let audience;
|
|
873
|
+
try {
|
|
874
|
+
audience = await resolveSendAudience(projectId, site_id, rules);
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
|
|
878
|
+
}
|
|
879
|
+
const contactIds = audience.contactIds;
|
|
828
880
|
if (!confirmed) {
|
|
829
881
|
return j({
|
|
830
882
|
confirmation_required: true,
|
|
@@ -838,16 +890,18 @@ export function registerCampaignTools(server) {
|
|
|
838
890
|
sends_per_hour: sends_per_hour ?? null,
|
|
839
891
|
},
|
|
840
892
|
matching_contacts: contactIds.length,
|
|
893
|
+
audience: heldBackLines(audience),
|
|
841
894
|
will_send_at: 'immediately',
|
|
842
895
|
message: `Broadcast "${campaignName}" will enroll ${contactIds.length} eligible contact(s) from segment "${segment.name}" and activate immediately. Call again with confirmed=true to proceed.`,
|
|
843
896
|
});
|
|
844
897
|
}
|
|
845
898
|
if (contactIds.length === 0) {
|
|
846
899
|
return j({
|
|
847
|
-
error: '
|
|
900
|
+
error: 'Nobody in this segment can get email from this sender right now.',
|
|
848
901
|
code: 'EMPTY_SEGMENT',
|
|
849
902
|
segment_id,
|
|
850
903
|
site_id,
|
|
904
|
+
audience: heldBackLines(audience),
|
|
851
905
|
});
|
|
852
906
|
}
|
|
853
907
|
const channelContext = currentChannelContext();
|
|
@@ -856,7 +910,12 @@ export function registerCampaignTools(server) {
|
|
|
856
910
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
857
911
|
if (decision.tier !== 'autonomous') {
|
|
858
912
|
if (!approval_id) {
|
|
859
|
-
const approval = await requestApproval('broadcast_to_segment', approvalParams, undefined, channelContext
|
|
913
|
+
const approval = await requestApproval('broadcast_to_segment', approvalParams, undefined, channelContext, {
|
|
914
|
+
subject,
|
|
915
|
+
segment_name: segment.name,
|
|
916
|
+
recipient_count: contactIds.length,
|
|
917
|
+
body_excerpt: approvalExcerpt(body_html),
|
|
918
|
+
});
|
|
860
919
|
return j(approval);
|
|
861
920
|
}
|
|
862
921
|
const check = await checkApproval(approval_id, { consume: true, action: 'broadcast_to_segment', params: approvalParams });
|
|
@@ -864,98 +923,28 @@ export function registerCampaignTools(server) {
|
|
|
864
923
|
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
865
924
|
}
|
|
866
925
|
}
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
code: 'PLAN_LIMIT',
|
|
872
|
-
});
|
|
873
|
-
}
|
|
874
|
-
const { data: campaign, error: campaignErr } = await db()
|
|
875
|
-
.from('campaigns')
|
|
876
|
-
.insert({
|
|
877
|
-
project_id: projectId,
|
|
878
|
-
site_id,
|
|
879
|
-
segment_id,
|
|
926
|
+
const executed = await executeBroadcast({
|
|
927
|
+
projectId,
|
|
928
|
+
siteId: site_id,
|
|
929
|
+
segmentId: segment_id,
|
|
880
930
|
name: campaignName,
|
|
881
|
-
type: 'broadcast',
|
|
882
|
-
status: 'draft',
|
|
883
|
-
sends_per_hour: sends_per_hour ?? null,
|
|
884
|
-
requires_approval: false,
|
|
885
|
-
metadata: { source: 'mcp_broadcast_to_segment' },
|
|
886
|
-
})
|
|
887
|
-
.select('id')
|
|
888
|
-
.single();
|
|
889
|
-
if (campaignErr || !campaign) {
|
|
890
|
-
return j({ error: campaignErr?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' });
|
|
891
|
-
}
|
|
892
|
-
const { data: step, error: stepErr } = await db()
|
|
893
|
-
.from('campaign_steps')
|
|
894
|
-
.insert({
|
|
895
|
-
campaign_id: campaign.id,
|
|
896
|
-
step_order: 0,
|
|
897
931
|
subject,
|
|
898
|
-
body_html,
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
return j({
|
|
907
|
-
error: stepErr?.message ?? 'Campaign created but failed to add broadcast step',
|
|
908
|
-
code: 'STEP_CREATE_FAILED',
|
|
909
|
-
campaign_id: campaign.id,
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
await recordBrandAssetUsage({
|
|
913
|
-
client: db(),
|
|
914
|
-
projectId,
|
|
915
|
-
source: 'campaign_step',
|
|
916
|
-
resourceId: step.id,
|
|
917
|
-
html: body_html,
|
|
918
|
-
assetIds: brand_asset_ids,
|
|
919
|
-
}).catch((err) => {
|
|
920
|
-
console.warn('Failed to record brand asset usage for MCP broadcast:', err);
|
|
932
|
+
bodyHtml: body_html,
|
|
933
|
+
bodyText: body_text ?? null,
|
|
934
|
+
sendsPerHour: sends_per_hour ?? null,
|
|
935
|
+
contactIds,
|
|
936
|
+
sendAt: null,
|
|
937
|
+
sendClass: 'broadcast',
|
|
938
|
+
brandAssetIds: brand_asset_ids,
|
|
939
|
+
source: 'mcp_broadcast_to_segment',
|
|
921
940
|
});
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
const batch = contactIds.slice(i, i + batchSize).map((contact_id) => ({
|
|
926
|
-
campaign_id: campaign.id,
|
|
927
|
-
contact_id,
|
|
928
|
-
next_send_at: sendAt,
|
|
929
|
-
}));
|
|
930
|
-
const { error: enrollErr } = await db()
|
|
931
|
-
.from('campaign_enrollments')
|
|
932
|
-
.upsert(batch, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
|
|
933
|
-
if (enrollErr) {
|
|
934
|
-
return j({
|
|
935
|
-
error: enrollErr.message,
|
|
936
|
-
code: 'ENROLLMENT_FAILED',
|
|
937
|
-
campaign_id: campaign.id,
|
|
938
|
-
enrolled_before_failure: i,
|
|
939
|
-
});
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
const { error: activateErr } = await db()
|
|
943
|
-
.from('campaigns')
|
|
944
|
-
.update({
|
|
945
|
-
status: 'active',
|
|
946
|
-
total_enrolled: contactIds.length,
|
|
947
|
-
updated_at: new Date().toISOString(),
|
|
948
|
-
})
|
|
949
|
-
.eq('id', campaign.id)
|
|
950
|
-
.eq('project_id', projectId);
|
|
951
|
-
if (activateErr) {
|
|
952
|
-
return j({
|
|
953
|
-
error: activateErr.message,
|
|
954
|
-
code: 'ACTIVATION_FAILED',
|
|
955
|
-
campaign_id: campaign.id,
|
|
956
|
-
enrolled: contactIds.length,
|
|
957
|
-
});
|
|
941
|
+
if (!executed.ok) {
|
|
942
|
+
const { ok: _ok, ...failure } = executed;
|
|
943
|
+
return j(failure);
|
|
958
944
|
}
|
|
945
|
+
const campaign = { id: executed.campaignId };
|
|
946
|
+
const step = { id: executed.stepId };
|
|
947
|
+
const sendAt = executed.nextSendAt;
|
|
959
948
|
await logAudit({
|
|
960
949
|
action: 'campaign.broadcast_to_segment',
|
|
961
950
|
resourceType: 'campaign',
|
|
@@ -985,6 +974,226 @@ export function registerCampaignTools(server) {
|
|
|
985
974
|
message: `Broadcast "${campaignName}" is active with ${contactIds.length} contact(s) enrolled for immediate sending.`,
|
|
986
975
|
});
|
|
987
976
|
});
|
|
977
|
+
server.registerTool('send_broadcast', {
|
|
978
|
+
title: 'Send Broadcast',
|
|
979
|
+
annotations: {
|
|
980
|
+
readOnlyHint: false,
|
|
981
|
+
destructiveHint: true,
|
|
982
|
+
idempotentHint: false,
|
|
983
|
+
openWorldHint: true,
|
|
984
|
+
},
|
|
985
|
+
description: 'Send a one-off email in two calls. Call 1: give an audience (audience in plain words, or segment_id) and content (brief for an AI draft, or html + subject); it returns the finished email, the recipient count, the sender and an approval_id, and sends nothing. A person approves it (approve_url works on a phone). Call 2: pass ONLY approval_id; it sends exactly what was approved. Use this for "send an email to…", newsletters, announcements.',
|
|
986
|
+
inputSchema: {
|
|
987
|
+
audience: z.string().min(3).max(500).optional().describe('Who gets it, in plain words (for example "trial users who have not opened anything"). Use this or segment_id.'),
|
|
988
|
+
segment_id: z.string().uuid().optional().describe('A saved segment. Use this or audience.'),
|
|
989
|
+
brief: z.string().min(10).max(4000).optional().describe('What the email should say; Sendinel drafts it in your brand voice. Use this or html.'),
|
|
990
|
+
html: z.string().min(1).optional().describe('A finished HTML body. Requires subject. Use this or brief.'),
|
|
991
|
+
subject: z.string().min(1).max(300).optional().describe('Subject line. Required with html; overrides the drafted subject with brief.'),
|
|
992
|
+
body_text: z.string().optional().describe('Plain-text version for html. Derived from the HTML when omitted.'),
|
|
993
|
+
site_id: z.string().uuid().optional().describe('Sender. Optional when the project has exactly one sender.'),
|
|
994
|
+
send_at: z.string().datetime().optional().describe('Future ISO time to send. Omit to send right after approval.'),
|
|
995
|
+
name: z.string().min(1).max(200).optional().describe('Campaign name. Defaults from the subject.'),
|
|
996
|
+
send_class: z.enum(['broadcast', 'product']).optional().describe('broadcast (marketing, default) or product (account and onboarding email). Decides which consent rules apply.'),
|
|
997
|
+
brand_asset_ids: z.array(z.string().uuid()).optional().describe('Brand media assets used in the body.'),
|
|
998
|
+
approval_id: z.string().uuid().optional().describe('Call 2 only: the approved approval_id from call 1. Pass nothing else.'),
|
|
999
|
+
},
|
|
1000
|
+
}, async (args) => {
|
|
1001
|
+
const projectId = getProjectId();
|
|
1002
|
+
const { approval_id } = args;
|
|
1003
|
+
// ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
|
|
1004
|
+
if (approval_id) {
|
|
1005
|
+
const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
|
|
1006
|
+
if (extras.length) {
|
|
1007
|
+
return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
|
|
1008
|
+
}
|
|
1009
|
+
const { data: row, error: rowErr } = await db()
|
|
1010
|
+
.from('action_approvals')
|
|
1011
|
+
.select('params')
|
|
1012
|
+
.eq('id', approval_id)
|
|
1013
|
+
.eq('project_id', projectId)
|
|
1014
|
+
.eq('action', 'send_broadcast')
|
|
1015
|
+
.maybeSingle();
|
|
1016
|
+
if (rowErr)
|
|
1017
|
+
return j({ error: rowErr.message, code: 'APPROVAL_LOOKUP_FAILED' });
|
|
1018
|
+
if (!row)
|
|
1019
|
+
return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
|
|
1020
|
+
const stored = (row.params ?? {});
|
|
1021
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
|
|
1022
|
+
if (!check.approved) {
|
|
1023
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
1024
|
+
}
|
|
1025
|
+
const payload = (stored.payload ?? {});
|
|
1026
|
+
const contactIds = Array.isArray(payload.contact_ids) ? payload.contact_ids.map(String) : [];
|
|
1027
|
+
if (payload.send_at && new Date(payload.send_at).getTime() <= Date.now()) {
|
|
1028
|
+
// An approved future time that has now passed: send right away rather than silently skip.
|
|
1029
|
+
payload.send_at = null;
|
|
1030
|
+
}
|
|
1031
|
+
const executed = await executeBroadcast({
|
|
1032
|
+
projectId,
|
|
1033
|
+
siteId: String(payload.site_id),
|
|
1034
|
+
segmentId: payload.segment_id ?? null,
|
|
1035
|
+
name: String(payload.name),
|
|
1036
|
+
subject: String(payload.subject),
|
|
1037
|
+
bodyHtml: String(payload.body_html),
|
|
1038
|
+
bodyText: payload.body_text ?? null,
|
|
1039
|
+
blocks: Array.isArray(payload.blocks) ? payload.blocks : null,
|
|
1040
|
+
contactIds,
|
|
1041
|
+
sendAt: payload.send_at ?? null,
|
|
1042
|
+
sendClass: payload.send_class === 'product' ? 'product' : 'broadcast',
|
|
1043
|
+
brandAssetIds: Array.isArray(payload.brand_asset_ids) ? payload.brand_asset_ids : undefined,
|
|
1044
|
+
source: 'mcp_send_broadcast',
|
|
1045
|
+
metadata: { approval_id, ...(payload.rules ? { audience_rules: payload.rules } : {}) },
|
|
1046
|
+
});
|
|
1047
|
+
if (!executed.ok) {
|
|
1048
|
+
const { ok: _ok, ...failure } = executed;
|
|
1049
|
+
return j(failure);
|
|
1050
|
+
}
|
|
1051
|
+
await logAudit({
|
|
1052
|
+
action: 'campaign.send_broadcast',
|
|
1053
|
+
resourceType: 'campaign',
|
|
1054
|
+
resourceId: executed.campaignId,
|
|
1055
|
+
details: { approval_id, decided_by: check.decided_by ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
|
|
1056
|
+
}).catch(() => { });
|
|
1057
|
+
fireWebhookEvent('campaign.activated', {
|
|
1058
|
+
campaign_id: executed.campaignId,
|
|
1059
|
+
campaign_name: payload.name,
|
|
1060
|
+
type: 'broadcast',
|
|
1061
|
+
contacts_enrolled: executed.enrolled,
|
|
1062
|
+
steps: 1,
|
|
1063
|
+
}).catch(() => { });
|
|
1064
|
+
return j({
|
|
1065
|
+
campaign_id: executed.campaignId,
|
|
1066
|
+
step_id: executed.stepId,
|
|
1067
|
+
status: 'active',
|
|
1068
|
+
contacts_enrolled: executed.enrolled,
|
|
1069
|
+
send_at: payload.send_at ?? 'now',
|
|
1070
|
+
message: payload.send_at
|
|
1071
|
+
? `Scheduled: "${payload.subject}" goes to ${executed.enrolled} people at ${payload.send_at}.`
|
|
1072
|
+
: `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
// ── Call 1: resolve, render, count, request approval. Sends nothing. ──
|
|
1076
|
+
const { audience, segment_id, brief, html, subject, body_text, site_id, send_at, name, send_class, brand_asset_ids } = args;
|
|
1077
|
+
if (Boolean(audience) === Boolean(segment_id))
|
|
1078
|
+
return j({ error: 'Give exactly one of audience or segment_id.', code: 'AUDIENCE_REQUIRED' });
|
|
1079
|
+
if (Boolean(brief) === Boolean(html))
|
|
1080
|
+
return j({ error: 'Give exactly one of brief or html.', code: 'CONTENT_REQUIRED' });
|
|
1081
|
+
if (html && !subject)
|
|
1082
|
+
return j({ error: 'subject is required with html.', code: 'SUBJECT_REQUIRED' });
|
|
1083
|
+
let sendAt = null;
|
|
1084
|
+
if (send_at) {
|
|
1085
|
+
const parsed = parseFutureDate(send_at, 'send_at');
|
|
1086
|
+
if ('error' in parsed)
|
|
1087
|
+
return j({ error: parsed.error, code: 'INVALID_SEND_AT' });
|
|
1088
|
+
sendAt = parsed.scheduledAt;
|
|
1089
|
+
}
|
|
1090
|
+
// Sender: the given one, or the only one. Never guessed between several.
|
|
1091
|
+
const { data: sites, error: sitesErr } = await db()
|
|
1092
|
+
.from('sites')
|
|
1093
|
+
.select('id, name, from_email')
|
|
1094
|
+
.eq('project_id', projectId);
|
|
1095
|
+
if (sitesErr)
|
|
1096
|
+
return j({ error: sitesErr.message, code: 'SITE_LOOKUP_FAILED' });
|
|
1097
|
+
const site = site_id ? (sites ?? []).find((s) => s.id === site_id) : (sites?.length === 1 ? sites[0] : null);
|
|
1098
|
+
if (!site) {
|
|
1099
|
+
return j({
|
|
1100
|
+
error: site_id ? 'Site not found in this project.' : 'This project has more than one sender. Pass site_id.',
|
|
1101
|
+
code: site_id ? 'SITE_NOT_FOUND' : 'SITE_REQUIRED',
|
|
1102
|
+
sites: (sites ?? []).map((s) => ({ id: s.id, name: s.name, from_email: s.from_email })),
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
1106
|
+
if ((audience || brief) && !apiKey)
|
|
1107
|
+
return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_NOT_CONFIGURED' });
|
|
1108
|
+
const anthropic = apiKey ? new Anthropic({ apiKey }) : null;
|
|
1109
|
+
let rules;
|
|
1110
|
+
let segmentName;
|
|
1111
|
+
if (segment_id) {
|
|
1112
|
+
const { data: segment, error: segmentErr } = await db()
|
|
1113
|
+
.from('segments')
|
|
1114
|
+
.select('id, name, rules, site_id')
|
|
1115
|
+
.eq('id', segment_id)
|
|
1116
|
+
.eq('project_id', projectId)
|
|
1117
|
+
.maybeSingle();
|
|
1118
|
+
if (segmentErr)
|
|
1119
|
+
return j({ error: segmentErr.message, code: 'SEGMENT_LOOKUP_FAILED' });
|
|
1120
|
+
if (!segment)
|
|
1121
|
+
return j({ error: 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
|
|
1122
|
+
rules = { ...(segment.rules ?? {}), ...(segment.site_id ? { site_id: segment.site_id } : {}) };
|
|
1123
|
+
segmentName = segment.name;
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
try {
|
|
1127
|
+
rules = await parseAudienceRules(anthropic, audience);
|
|
1128
|
+
}
|
|
1129
|
+
catch (error) {
|
|
1130
|
+
return j({ error: error instanceof Error ? error.message : String(error), code: 'AUDIENCE_NOT_UNDERSTOOD' });
|
|
1131
|
+
}
|
|
1132
|
+
segmentName = audience;
|
|
1133
|
+
}
|
|
1134
|
+
const sendClass = send_class === 'product' ? 'product' : 'broadcast';
|
|
1135
|
+
let resolved;
|
|
1136
|
+
try {
|
|
1137
|
+
resolved = await resolveSendAudience(projectId, site.id, rules, sendClass);
|
|
1138
|
+
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
|
|
1141
|
+
}
|
|
1142
|
+
if (resolved.contactIds.length === 0) {
|
|
1143
|
+
return j({ error: 'Nobody in this audience can get this email from this sender.', code: 'EMPTY_AUDIENCE', audience: heldBackLines(resolved), rules });
|
|
1144
|
+
}
|
|
1145
|
+
let content;
|
|
1146
|
+
if (brief) {
|
|
1147
|
+
try {
|
|
1148
|
+
const generated = await generateEmailFromBrief(anthropic, projectId, site.id, brief);
|
|
1149
|
+
content = { subject: subject ?? generated.subject, body_html: generated.body_html, body_text: generated.body_text, blocks: generated.blocks };
|
|
1150
|
+
}
|
|
1151
|
+
catch (error) {
|
|
1152
|
+
return j({ error: error instanceof Error ? error.message : String(error), code: 'DRAFT_FAILED' });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
else {
|
|
1156
|
+
content = { subject: subject, body_html: html, body_text: body_text ?? approvalExcerpt(html, 100000), blocks: null };
|
|
1157
|
+
}
|
|
1158
|
+
const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
|
|
1159
|
+
const payload = {
|
|
1160
|
+
site_id: site.id,
|
|
1161
|
+
segment_id: segment_id ?? null,
|
|
1162
|
+
rules: segment_id ? null : rules,
|
|
1163
|
+
subject: content.subject,
|
|
1164
|
+
body_html: content.body_html,
|
|
1165
|
+
body_text: content.body_text,
|
|
1166
|
+
blocks: content.blocks,
|
|
1167
|
+
name: campaignName,
|
|
1168
|
+
send_at: sendAt,
|
|
1169
|
+
send_class: sendClass,
|
|
1170
|
+
brand_asset_ids: brand_asset_ids ?? null,
|
|
1171
|
+
// Frozen at approval time: the people approved are the people enrolled.
|
|
1172
|
+
contact_ids: resolved.contactIds,
|
|
1173
|
+
};
|
|
1174
|
+
const approvalParams = { payload };
|
|
1175
|
+
const channelContext = currentChannelContext();
|
|
1176
|
+
const decision = assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
|
|
1177
|
+
if (!decision.allowed)
|
|
1178
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1179
|
+
const approval = await requestApproval('send_broadcast', approvalParams, undefined, channelContext, {
|
|
1180
|
+
subject: content.subject,
|
|
1181
|
+
segment_name: segment_id ? segmentName : null,
|
|
1182
|
+
audience_label: segment_id ? null : `"${segmentName}"`,
|
|
1183
|
+
recipient_count: resolved.contactIds.length,
|
|
1184
|
+
body_excerpt: approvalExcerpt(content.body_html),
|
|
1185
|
+
send_at: sendAt,
|
|
1186
|
+
});
|
|
1187
|
+
return j({
|
|
1188
|
+
...approval,
|
|
1189
|
+
rendered: { subject: content.subject, body_html: content.body_html, body_text: content.body_text },
|
|
1190
|
+
recipient_count: resolved.contactIds.length,
|
|
1191
|
+
audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
|
|
1192
|
+
site: { id: site.id, name: site.name, from_email: site.from_email },
|
|
1193
|
+
send_at: sendAt ?? 'right after approval',
|
|
1194
|
+
next: 'Show the user the subject, recipient count and approve_url. After a person approves, call send_broadcast with only approval_id. Do not say it was sent before that call succeeds.',
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
988
1197
|
server.registerTool('clone_campaign', {
|
|
989
1198
|
title: 'Clone Campaign',
|
|
990
1199
|
annotations: {
|
package/build/tools/gateway.d.ts
CHANGED
|
@@ -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;
|
package/build/tools/gateway.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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.
|
|
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",
|