@sendinel/mcp-server 1.0.101 → 1.0.103
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/lib/action-approvals.js +2 -0
- package/build/lib/how-to-recipes.js +13 -0
- package/build/lib/send-audience.d.ts +5 -1
- package/build/lib/send-audience.js +7 -1
- package/build/server.js +1 -1
- package/build/tools/campaigns.js +159 -29
- package/build/tools/contacts.js +14 -5
- package/build/tools/lists.js +14 -2
- package/build/tools/segments.js +59 -8
- package/build/tools/tool-search.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
|
|
|
18
18
|
[event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
|
|
19
19
|
inputs, idempotency, subscription requirements, and conditional step examples.
|
|
20
20
|
|
|
21
|
-
Sendinel currently exposes 35 tool groups and
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 341 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
22
22
|
|
|
23
23
|
| Group | Count | Purpose |
|
|
24
24
|
|-------|-------|---------|
|
|
@@ -30,7 +30,7 @@ Sendinel currently exposes 35 tool groups and 340 tools (gateway mode exposes 4
|
|
|
30
30
|
| `approvals` | 2 | Poll and list risk-tiered action approvals |
|
|
31
31
|
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
32
32
|
| `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
|
|
33
|
-
| `campaigns` |
|
|
33
|
+
| `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
|
|
34
34
|
| `cloud-import` | 6 | Cloud storage import |
|
|
35
35
|
| `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
|
|
36
36
|
| `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
|
|
@@ -28,6 +28,7 @@ const GATED_ACTIONS = new Set([
|
|
|
28
28
|
'import_subscribers', // bulk data ingestion
|
|
29
29
|
'subscribe_webhook', // outbound data channel
|
|
30
30
|
'send_transactional', // sends a real email
|
|
31
|
+
'set_campaign_trigger', // changes who starts an active or paused campaign
|
|
31
32
|
'update_campaign_status', // activation sends real emails (see special case below)
|
|
32
33
|
'add_list_members', // bulk contact-list membership write
|
|
33
34
|
'remove_list_members', // bulk contact-list membership write
|
|
@@ -254,6 +255,7 @@ export function buildApprovalSummary(action, params, display = {}) {
|
|
|
254
255
|
const warnings = display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : '';
|
|
255
256
|
return `Activate ${count} campaign${count === 1 ? '' : 's'}${emails}${names} — each emails contacts when its trigger fires${warnings}`;
|
|
256
257
|
},
|
|
258
|
+
set_campaign_trigger: (p) => `Change what starts campaign \"${display.campaign_name ?? p.campaign_id}\" from ${display.from_trigger ?? 'its current trigger'} to ${display.to_trigger ?? 'the requested trigger'}; ${typeof display.recipient_count === 'number' ? `up to ${display.recipient_count.toLocaleString('en-US')} people` : 'an unknown number of people'} may start receiving it`,
|
|
257
259
|
update_campaign_status: (p) => (p.status === 'active'
|
|
258
260
|
? `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}"` : ''}${display.content_warnings ? ` — Check before approving: ${display.content_warnings}` : ''}`
|
|
259
261
|
: `Change campaign "${display.campaign_name ?? p.campaign_id}" to ${p.status}`),
|
|
@@ -6,6 +6,19 @@ const projectRule = 'On hosted /mcp, pass project_id on each call. It is require
|
|
|
6
6
|
const previewRule = 'preview_email only substitutes sample merge variables; it is not the real send renderer. send_test_email with dry_run:true uses the real renderer without sending, but still follows the approval flow in standard mode; keep the same args plus approval_id when re-calling it. Stdio requires SENDINEL_API_KEY.';
|
|
7
7
|
const sender = { n: 1, tool: 'get_sites', args_example: {}, note: 'Choose the intended sender from the returned array; do not assume the first sender is correct. Replace example content and addresses with the user’s own choices.' };
|
|
8
8
|
export const HOW_TO_RECIPES = [
|
|
9
|
+
{
|
|
10
|
+
id: 'set-campaign-trigger', title: 'Set what a campaign fires on',
|
|
11
|
+
when_to_use: 'Bind an existing campaign to an event such as invoice_payment_failed for dunning, or change its tag/date/contact-event trigger.',
|
|
12
|
+
synonyms: TOOL_ALIASES.set_campaign_trigger,
|
|
13
|
+
requires: { scopes: ['write'], tester_ok: false },
|
|
14
|
+
steps: [
|
|
15
|
+
{ n: 1, tool: 'list_campaigns', args_example: { limit: 25 }, note: 'Select the intended campaign and inspect its status and saved trigger.' },
|
|
16
|
+
{ n: 2, tool: 'set_campaign_trigger', args_example: { campaign_id: from(1, '0.id'), trigger_event_name: 'invoice_payment_failed', trigger_config: { events: ['invoice_payment_failed'], where: { plan: 'paid' } } }, approval, note: 'Use the actual event and intended filter. Drafts save directly; active/paused campaigns return approve_url. Wait for approval, then repeat the SAME args plus approval_id.' },
|
|
17
|
+
{ n: 3, tool: 'get_campaign', args_example: { campaign_id: from(1, '0.id') }, note: 'Verify saved trigger fields, where, fires_on and trigger_diagnostics.' },
|
|
18
|
+
],
|
|
19
|
+
gotchas: [projectRule, 'Archived/completed campaigns refuse changes. clear:true clears trigger fields and cannot be combined with trigger arguments. Drafts still need separate activation approval; setting a trigger does not emit an event.'],
|
|
20
|
+
done_when: 'The intended trigger and filter round-trip through get_campaign; any activation or live event test remains a separate authorized action.',
|
|
21
|
+
},
|
|
9
22
|
{
|
|
10
23
|
id: 'send-one-off-email', title: 'Send a one-off email to my list',
|
|
11
24
|
when_to_use: 'Send an announcement or email blast to an audience with human approval.',
|
|
@@ -9,7 +9,11 @@ import { type SegmentRules } from '@sendinel/domain';
|
|
|
9
9
|
* Every read throws on failure: an unknown audience must never read as "nobody" or "everyone".
|
|
10
10
|
*/
|
|
11
11
|
export type SendClass = 'broadcast' | 'product';
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Contact ids matching segment rules (same semantics as the segments tools and the dashboard).
|
|
14
|
+
* Throws SegmentRulesError for invalid rules (#4270): a malformed rule never widens to everyone,
|
|
15
|
+
* and `{ match_all: true }` is the only way to ask for every contact.
|
|
16
|
+
*/
|
|
13
17
|
export declare function resolveSegmentContactIds(projectId: string, rules: SegmentRules): Promise<string[]>;
|
|
14
18
|
export type SendAudience = {
|
|
15
19
|
/** People the send will enroll. */
|
|
@@ -15,9 +15,15 @@ async function loadFieldTypes(projectId) {
|
|
|
15
15
|
}
|
|
16
16
|
return fields;
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Contact ids matching segment rules (same semantics as the segments tools and the dashboard).
|
|
20
|
+
* Throws SegmentRulesError for invalid rules (#4270): a malformed rule never widens to everyone,
|
|
21
|
+
* and `{ match_all: true }` is the only way to ask for every contact.
|
|
22
|
+
*/
|
|
19
23
|
export async function resolveSegmentContactIds(projectId, rules) {
|
|
20
24
|
const tree = normalizeSegmentRulesToTree(rules);
|
|
25
|
+
if (tree.match_none)
|
|
26
|
+
return [];
|
|
21
27
|
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
22
28
|
const q = buildSegmentBaseQuery(db(), projectId);
|
|
23
29
|
const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
|
package/build/server.js
CHANGED
|
@@ -85,7 +85,7 @@ const WRITE_TOOLS = new Set([
|
|
|
85
85
|
'add_subscriber', 'identify_subscriber', 'emit_contact_event', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
|
|
86
86
|
'create_subscriber_view', 'update_subscriber_view', 'delete_subscriber_view',
|
|
87
87
|
'enrich_subscriber',
|
|
88
|
-
'create_campaign', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
|
|
88
|
+
'create_campaign', 'set_campaign_trigger', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
|
|
89
89
|
// #3348 - branch/goal writes. Same scope as the campaign edits they sit alongside:
|
|
90
90
|
// they change what the send engine does to enrolled contacts, so they are not read-only.
|
|
91
91
|
'add_campaign_branch', 'remove_campaign_branch', 'set_campaign_goal', 'remove_campaign_goal',
|
package/build/tools/campaigns.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
|
2
2
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
-
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
|
|
5
|
+
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, } from '@sendinel/domain';
|
|
6
6
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
7
7
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
8
8
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -15,7 +15,7 @@ import { checkPlanLimit } from '../plan-limits.js';
|
|
|
15
15
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
16
16
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
17
17
|
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
18
|
-
import { resolveSendAudience } from '../lib/send-audience.js';
|
|
18
|
+
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
19
19
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
20
20
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
21
21
|
import { assertUsableStopReason } from '@sendinel/domain';
|
|
@@ -641,7 +641,7 @@ export function registerCampaignTools(server) {
|
|
|
641
641
|
idempotentHint: true,
|
|
642
642
|
openWorldHint: false,
|
|
643
643
|
},
|
|
644
|
-
description: 'List campaigns, optionally
|
|
644
|
+
description: 'List campaigns with saved trigger fields, fires_on summaries and trigger_diagnostics; optionally filter by site or status.',
|
|
645
645
|
inputSchema: {
|
|
646
646
|
site_id: z.string().uuid().optional(),
|
|
647
647
|
period: z.string().min(1).optional().describe("Return one enrollment period in by_period; totals remain all-time."),
|
|
@@ -651,7 +651,7 @@ export function registerCampaignTools(server) {
|
|
|
651
651
|
}, async ({ site_id, status, limit, period }) => {
|
|
652
652
|
let q = db()
|
|
653
653
|
.from('campaigns')
|
|
654
|
-
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key')
|
|
654
|
+
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config')
|
|
655
655
|
.eq('project_id', getProjectId())
|
|
656
656
|
.order('created_at', { ascending: false })
|
|
657
657
|
.limit(limit ?? 25);
|
|
@@ -671,6 +671,8 @@ export function registerCampaignTools(server) {
|
|
|
671
671
|
const { period_key: _periodKey, ...legacy } = row;
|
|
672
672
|
return {
|
|
673
673
|
...legacy,
|
|
674
|
+
fires_on: buildCampaignFiresOn(row),
|
|
675
|
+
trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
|
|
674
676
|
...campaignPeriodFields(periods, period),
|
|
675
677
|
...(periods?.totals ? { total_sent: periods.totals.sent } : {}),
|
|
676
678
|
unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
|
|
@@ -1164,10 +1166,7 @@ export function registerCampaignTools(server) {
|
|
|
1164
1166
|
.maybeSingle();
|
|
1165
1167
|
if (segmentErr || !segment)
|
|
1166
1168
|
return j({ error: segmentErr?.message ?? 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
|
|
1167
|
-
const rules =
|
|
1168
|
-
...(segment.rules ?? {}),
|
|
1169
|
-
...(segment.site_id ? { site_id: segment.site_id } : {}),
|
|
1170
|
-
};
|
|
1169
|
+
const rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
|
|
1171
1170
|
// #3669: the same audience rules as send_broadcast and the dashboard: paged, signed up to this
|
|
1172
1171
|
// sender, consent gate applied. The count shown is the count enrolled.
|
|
1173
1172
|
let audience;
|
|
@@ -1175,6 +1174,9 @@ export function registerCampaignTools(server) {
|
|
|
1175
1174
|
audience = await resolveSendAudience(projectId, site_id, rules);
|
|
1176
1175
|
}
|
|
1177
1176
|
catch (error) {
|
|
1177
|
+
// #4270: a malformed segment enrolls nobody and says why.
|
|
1178
|
+
if (isSegmentRulesError(error))
|
|
1179
|
+
return j({ error: error.message, code: 'INVALID_SEGMENT_RULES', errors: error.errors });
|
|
1178
1180
|
return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
|
|
1179
1181
|
}
|
|
1180
1182
|
const contactIds = audience.contactIds;
|
|
@@ -1423,7 +1425,7 @@ export function registerCampaignTools(server) {
|
|
|
1423
1425
|
return j({ error: segmentErr.message, code: 'SEGMENT_LOOKUP_FAILED' });
|
|
1424
1426
|
if (!segment)
|
|
1425
1427
|
return j({ error: 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
|
|
1426
|
-
rules =
|
|
1428
|
+
rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
|
|
1427
1429
|
segmentName = segment.name;
|
|
1428
1430
|
}
|
|
1429
1431
|
else {
|
|
@@ -1441,6 +1443,9 @@ export function registerCampaignTools(server) {
|
|
|
1441
1443
|
resolved = await resolveSendAudience(projectId, site.id, rules, sendClass);
|
|
1442
1444
|
}
|
|
1443
1445
|
catch (error) {
|
|
1446
|
+
// #4270: malformed segment or parsed audience rules send to nobody and say why.
|
|
1447
|
+
if (isSegmentRulesError(error))
|
|
1448
|
+
return j({ error: error.message, code: 'INVALID_SEGMENT_RULES', errors: error.errors, rules });
|
|
1444
1449
|
return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
|
|
1445
1450
|
}
|
|
1446
1451
|
if (resolved.contactIds.length === 0) {
|
|
@@ -1884,6 +1889,10 @@ Adapt your tone accordingly:
|
|
|
1884
1889
|
async function launchFromPlan(plan) {
|
|
1885
1890
|
let segId = segment_id;
|
|
1886
1891
|
if (segment_description) {
|
|
1892
|
+
// #4270: never save a segment from rules the segment engine can't use.
|
|
1893
|
+
const rulesCheck = validateSegmentRules(plan.rules);
|
|
1894
|
+
if (!rulesCheck.ok)
|
|
1895
|
+
return txt(`Error: Could not use the audience rules. ${rulesCheck.errors[0]} Nothing was created.`);
|
|
1887
1896
|
const { data: seg, error: segErr } = await db()
|
|
1888
1897
|
.from('segments')
|
|
1889
1898
|
.insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
|
|
@@ -2006,8 +2015,22 @@ Adapt your tone accordingly:
|
|
|
2006
2015
|
q = q.lte(column, rule.before);
|
|
2007
2016
|
return q;
|
|
2008
2017
|
}
|
|
2009
|
-
// Helper to get segment contact IDs (simplified inline)
|
|
2018
|
+
// Helper to get segment contact IDs (simplified inline). #4270: rules are validated first
|
|
2019
|
+
// (SegmentRulesError), and anything this flat helper cannot evaluate (rule trees, markers,
|
|
2020
|
+
// custom_property, company filters, multi-tag) goes through the shared evaluator instead of
|
|
2021
|
+
// being ignored, which used to widen those segments to everyone.
|
|
2022
|
+
const INLINE_RULE_KEYS = new Set([
|
|
2023
|
+
'lifecycle_stage', 'payment', 'min_score', 'max_score', 'source', 'subscribed_after', 'subscribed_before',
|
|
2024
|
+
'has_opened', 'has_clicked', 'tag', 'site_id', 'exclude_suppressed', 'metadata',
|
|
2025
|
+
]);
|
|
2010
2026
|
const getContactIds = async (rules) => {
|
|
2027
|
+
const check = validateSegmentRules(rules);
|
|
2028
|
+
if (!check.ok)
|
|
2029
|
+
throw new SegmentRulesError(check.errors);
|
|
2030
|
+
const inlineCanEvaluate = Object.keys(rules).every((key) => rules[key] === undefined || INLINE_RULE_KEYS.has(key))
|
|
2031
|
+
&& (rules.tag === undefined || typeof rules.tag === 'string');
|
|
2032
|
+
if (!inlineCanEvaluate)
|
|
2033
|
+
return resolveSegmentContactIds(projectId, rules);
|
|
2011
2034
|
let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false).is('cooled_at', null);
|
|
2012
2035
|
if (rules.lifecycle_stage)
|
|
2013
2036
|
q = q.eq('lifecycle_stage', rules.lifecycle_stage);
|
|
@@ -2073,7 +2096,14 @@ Adapt your tone accordingly:
|
|
|
2073
2096
|
catch {
|
|
2074
2097
|
return txt(`Error: Failed to parse segment from "${segment_description}"`);
|
|
2075
2098
|
}
|
|
2076
|
-
|
|
2099
|
+
try {
|
|
2100
|
+
contactIds = await getContactIds(parsedRules);
|
|
2101
|
+
}
|
|
2102
|
+
catch (error) {
|
|
2103
|
+
if (isSegmentRulesError(error))
|
|
2104
|
+
return txt(`Error: Could not use the audience parsed from "${segment_description}". ${error.message}`);
|
|
2105
|
+
throw error;
|
|
2106
|
+
}
|
|
2077
2107
|
contactCount = contactIds.length;
|
|
2078
2108
|
segmentName = `${name} — segment`;
|
|
2079
2109
|
}
|
|
@@ -2088,7 +2118,14 @@ Adapt your tone accordingly:
|
|
|
2088
2118
|
return txt('Error: segment not found');
|
|
2089
2119
|
segmentName = seg.name;
|
|
2090
2120
|
parsedRules = seg.rules;
|
|
2091
|
-
|
|
2121
|
+
try {
|
|
2122
|
+
contactIds = await getContactIds(parsedRules);
|
|
2123
|
+
}
|
|
2124
|
+
catch (error) {
|
|
2125
|
+
if (isSegmentRulesError(error))
|
|
2126
|
+
return txt(`Error: Segment "${seg.name}" has rules that can't be used. ${error.message}`);
|
|
2127
|
+
throw error;
|
|
2128
|
+
}
|
|
2092
2129
|
contactCount = contactIds.length;
|
|
2093
2130
|
}
|
|
2094
2131
|
if (!confirmed) {
|
|
@@ -2923,11 +2960,15 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2923
2960
|
idempotentHint: false,
|
|
2924
2961
|
openWorldHint: true,
|
|
2925
2962
|
},
|
|
2926
|
-
description: 'Create a campaign with pre-built email content in one call.
|
|
2963
|
+
description: 'Create a sequence, broadcast or triggered campaign with pre-built email content in one call. Set trigger_event_name for dunning (invoice_payment_failed, trial_ending), trigger_tag, or date_field/contact_event trigger_config; events/tags and where filters are supported. Triggered campaigns need no segment. Returns fires_on and trigger_diagnostics. Adds email steps, optionally enrolls a segment, and activates behind standard approval. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
|
|
2927
2964
|
inputSchema: {
|
|
2928
2965
|
site_id: z.string().uuid().describe('Site to send from'),
|
|
2929
2966
|
name: z.string().min(1).max(200).describe('Campaign name'),
|
|
2930
|
-
type: z.enum(['sequence', 'broadcast']).describe('Campaign type'),
|
|
2967
|
+
type: z.enum(['sequence', 'broadcast', 'triggered']).describe('Campaign type'),
|
|
2968
|
+
trigger_event_name: z.string().nullable().optional(),
|
|
2969
|
+
trigger_tag: z.string().nullable().optional(),
|
|
2970
|
+
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
2971
|
+
trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists with optional where filter, or date_field/contact_event configuration.'),
|
|
2931
2972
|
steps: z.array(z.object({
|
|
2932
2973
|
subject: z.string().min(1).describe('Email subject line'),
|
|
2933
2974
|
body_html: z.string().min(1).describe('Full HTML email body'),
|
|
@@ -2936,16 +2977,25 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2936
2977
|
delay_days: z.number().int().min(0).optional().default(0),
|
|
2937
2978
|
delay_hours: z.number().int().min(0).max(23).optional().default(0),
|
|
2938
2979
|
})).min(1).describe('Email steps with pre-built content'),
|
|
2939
|
-
segment_id: z.string().uuid().optional().describe('Segment to auto-enroll.
|
|
2980
|
+
segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. Optional for triggered campaigns; future trigger matches enroll contacts.'),
|
|
2940
2981
|
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Null or omitted sends without a campaign-specific cap.'),
|
|
2941
|
-
activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id.'),
|
|
2982
|
+
activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id or a configured triggered campaign.'),
|
|
2942
2983
|
confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
|
|
2943
2984
|
approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
|
|
2944
2985
|
},
|
|
2945
|
-
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id }) => {
|
|
2986
|
+
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config }) => {
|
|
2987
|
+
const targeting = {};
|
|
2988
|
+
const triggerInput = { trigger_event_name, trigger_tag, trigger_type, trigger_config };
|
|
2989
|
+
const targetingError = await applyCampaignTargetingFields(targeting, triggerInput, getProjectId());
|
|
2990
|
+
if (targetingError)
|
|
2991
|
+
return j({ error: targetingError, code: 'INVALID_FIELD' });
|
|
2992
|
+
const triggerConfig = normalizeTriggerConfig(targeting.trigger_config);
|
|
2993
|
+
const hasEventOrTag = Boolean(targeting.trigger_event_name || targeting.trigger_tag || triggerConfig.events.length || triggerConfig.tags.length);
|
|
2994
|
+
const campaignType = type === 'sequence' && hasEventOrTag ? 'triggered' : type;
|
|
2995
|
+
const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
|
|
2946
2996
|
// Human-in-the-loop gate for activation
|
|
2947
2997
|
if (activate && confirmed) {
|
|
2948
|
-
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate };
|
|
2998
|
+
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, ...triggerInput };
|
|
2949
2999
|
const channelContext = currentChannelContext();
|
|
2950
3000
|
const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
|
|
2951
3001
|
if (!decision.allowed)
|
|
@@ -2991,7 +3041,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2991
3041
|
if (!confirmed) {
|
|
2992
3042
|
return j({
|
|
2993
3043
|
confirmation_required: true,
|
|
2994
|
-
campaign: { name, type, site: site.name },
|
|
3044
|
+
campaign: { name, type: campaignType, site: site.name, ...targeting },
|
|
2995
3045
|
steps: steps.map((s, i) => ({
|
|
2996
3046
|
step: i + 1,
|
|
2997
3047
|
subject: s.subject,
|
|
@@ -3002,8 +3052,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3002
3052
|
segment_id: segment_id ?? null,
|
|
3003
3053
|
sends_per_hour: sends_per_hour ?? null,
|
|
3004
3054
|
estimated_contacts: contactCount,
|
|
3005
|
-
will_activate: activate && !!segment_id,
|
|
3006
|
-
message: `Campaign "${name}" with ${steps.length} step(s) on ${site.name}. ${segment_id ? `Will enroll ~${contactCount} contacts.` : 'No segment — created as draft.'} ${activate && segment_id ? 'Will activate immediately.' : ''} Call again with confirmed=true to proceed.`,
|
|
3055
|
+
will_activate: activate && (!!segment_id || canActivateOnTrigger),
|
|
3056
|
+
message: `Campaign "${name}" with ${steps.length} step(s) on ${site.name}. ${segment_id ? `Will enroll ~${contactCount} contacts.` : canActivateOnTrigger ? 'Future trigger matches will enroll contacts.' : 'No segment — created as draft.'} ${activate && (segment_id || canActivateOnTrigger) ? 'Will activate immediately.' : ''} Call again with confirmed=true to proceed.`,
|
|
3007
3057
|
});
|
|
3008
3058
|
}
|
|
3009
3059
|
// Execute mode (second call)
|
|
@@ -3014,7 +3064,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3014
3064
|
project_id: getProjectId(),
|
|
3015
3065
|
site_id,
|
|
3016
3066
|
name,
|
|
3017
|
-
type,
|
|
3067
|
+
type: campaignType,
|
|
3068
|
+
...targeting,
|
|
3018
3069
|
status: 'draft',
|
|
3019
3070
|
segment_id: segment_id ?? null,
|
|
3020
3071
|
sends_per_hour: sends_per_hour ?? null,
|
|
@@ -3078,7 +3129,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3078
3129
|
}
|
|
3079
3130
|
}
|
|
3080
3131
|
// 4. Activate if requested
|
|
3081
|
-
|
|
3132
|
+
const willActivate = Boolean(activate && ((segment_id && enrolled > 0) || canActivateOnTrigger));
|
|
3133
|
+
if (willActivate) {
|
|
3082
3134
|
await db()
|
|
3083
3135
|
.from('campaigns')
|
|
3084
3136
|
.update({ status: 'active' })
|
|
@@ -3087,19 +3139,23 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3087
3139
|
fireWebhookEvent('campaign.activated', {
|
|
3088
3140
|
campaign_id: campaign.id,
|
|
3089
3141
|
campaign_name: name,
|
|
3090
|
-
type,
|
|
3142
|
+
type: campaignType,
|
|
3091
3143
|
contacts_enrolled: enrolled,
|
|
3092
3144
|
steps: steps.length,
|
|
3093
3145
|
}).catch(() => { }); // fire-and-forget
|
|
3094
3146
|
}
|
|
3147
|
+
await logAudit({ action: 'campaign.created', resourceType: 'campaign', resourceId: campaign.id, details: { type: campaignType, ...targeting, activated: willActivate } });
|
|
3095
3148
|
return j({
|
|
3096
3149
|
campaign_id: campaign.id,
|
|
3097
3150
|
name,
|
|
3098
|
-
type,
|
|
3151
|
+
type: campaignType,
|
|
3152
|
+
...targeting,
|
|
3153
|
+
fires_on: buildCampaignFiresOn({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3154
|
+
trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3099
3155
|
steps_created: steps.length,
|
|
3100
3156
|
contacts_enrolled: enrolled,
|
|
3101
|
-
status:
|
|
3102
|
-
message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${
|
|
3157
|
+
status: willActivate ? 'active' : 'draft',
|
|
3158
|
+
message: `Campaign "${name}" created with ${steps.length} step(s). ${enrolled > 0 ? `${enrolled} contacts enrolled.` : 'No contacts enrolled.'} ${willActivate ? 'Campaign is now active — emails will begin sending on the next cron cycle.' : 'Campaign saved as draft.'}`,
|
|
3103
3159
|
});
|
|
3104
3160
|
});
|
|
3105
3161
|
server.registerTool('list_campaign_steps', {
|
|
@@ -3284,6 +3340,78 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3284
3340
|
return j({ error: error.message });
|
|
3285
3341
|
return j({ success: true, deleted: campaign.name });
|
|
3286
3342
|
}));
|
|
3343
|
+
server.registerTool('set_campaign_trigger', {
|
|
3344
|
+
title: 'Set Campaign Trigger',
|
|
3345
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
3346
|
+
description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Use for dunning on invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
|
|
3347
|
+
inputSchema: {
|
|
3348
|
+
campaign_id: z.string().uuid(),
|
|
3349
|
+
trigger_event_name: z.string().nullable().optional(),
|
|
3350
|
+
trigger_tag: z.string().nullable().optional(),
|
|
3351
|
+
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
3352
|
+
trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists and optional where filter, or date_field/contact_event configuration.'),
|
|
3353
|
+
clear: z.literal(true).optional().describe('Clear all trigger fields back to manual. Cannot be combined with trigger fields.'),
|
|
3354
|
+
approval_id: z.string().uuid().optional(),
|
|
3355
|
+
},
|
|
3356
|
+
}, async ({ approval_id, ...args }) => {
|
|
3357
|
+
const projectId = getProjectId();
|
|
3358
|
+
const { data: existing, error: loadError } = await db().from('campaigns')
|
|
3359
|
+
.select('id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at')
|
|
3360
|
+
.eq('id', args.campaign_id).eq('project_id', projectId).maybeSingle();
|
|
3361
|
+
if (loadError)
|
|
3362
|
+
return j({ error: loadError.message });
|
|
3363
|
+
if (!existing)
|
|
3364
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3365
|
+
if (!['draft', 'active', 'paused'].includes(existing.status)) {
|
|
3366
|
+
return j({ error: `Cannot change a ${existing.status} campaign trigger`, code: 'INVALID_STATUS' });
|
|
3367
|
+
}
|
|
3368
|
+
const hasFields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config']
|
|
3369
|
+
.some((key) => args[key] !== undefined);
|
|
3370
|
+
if ((!args.clear && !hasFields) || (args.clear && hasFields)) {
|
|
3371
|
+
return j({ error: 'Provide trigger fields or clear=true, exclusively', code: 'INVALID_FIELD' });
|
|
3372
|
+
}
|
|
3373
|
+
const updates = {};
|
|
3374
|
+
const targetingError = await applyCampaignTargetingFields(updates, args.clear
|
|
3375
|
+
? { trigger_type: 'manual', trigger_event_name: null, trigger_tag: null, trigger_config: null }
|
|
3376
|
+
: args, projectId, existing);
|
|
3377
|
+
if (targetingError)
|
|
3378
|
+
return j({ error: targetingError, code: 'INVALID_FIELD' });
|
|
3379
|
+
const normalized = normalizeTriggerConfig(updates.trigger_config);
|
|
3380
|
+
if (existing.status === 'draft' && existing.type === 'sequence'
|
|
3381
|
+
&& (updates.trigger_event_name || updates.trigger_tag || normalized.events.length || normalized.tags.length)) {
|
|
3382
|
+
updates.type = 'triggered';
|
|
3383
|
+
}
|
|
3384
|
+
const execute = async () => {
|
|
3385
|
+
// A draft activated during this call must never bypass approval. The timestamp also
|
|
3386
|
+
// guards against overwriting edits made while an approved call is executing.
|
|
3387
|
+
let query = db().from('campaigns').update({ ...updates, updated_at: new Date().toISOString() })
|
|
3388
|
+
.eq('id', args.campaign_id).eq('project_id', projectId).eq('status', existing.status);
|
|
3389
|
+
if (existing.updated_at)
|
|
3390
|
+
query = query.eq('updated_at', existing.updated_at);
|
|
3391
|
+
const { data, error } = await query.select().maybeSingle();
|
|
3392
|
+
if (error)
|
|
3393
|
+
return j({ error: error.message });
|
|
3394
|
+
if (!data)
|
|
3395
|
+
return j({ error: 'Campaign changed; read it and retry', code: 'CAMPAIGN_CHANGED' });
|
|
3396
|
+
await logAudit({ action: 'campaign.trigger_updated', resourceType: 'campaign', resourceId: args.campaign_id, details: { previous: existing, updates } });
|
|
3397
|
+
return j({ ...data, fires_on: buildCampaignFiresOn(data), trigger_diagnostics: buildCampaignTriggerDiagnostics(data) });
|
|
3398
|
+
};
|
|
3399
|
+
if (existing.status === 'draft')
|
|
3400
|
+
return execute();
|
|
3401
|
+
// The saved state is internal, never caller-supplied. Approval is for this exact before/after.
|
|
3402
|
+
return withApproval('set_campaign_trigger', async (_params) => execute(), {
|
|
3403
|
+
display: async () => {
|
|
3404
|
+
const { count, error } = await db().from('subscribers').select('id', { count: 'exact', head: true })
|
|
3405
|
+
.eq('project_id', projectId).eq('global_unsubscribed', false);
|
|
3406
|
+
return {
|
|
3407
|
+
campaign_name: existing.name,
|
|
3408
|
+
from_trigger: `${buildCampaignFiresOn(existing)} Config: ${JSON.stringify(existing.trigger_config)}`,
|
|
3409
|
+
to_trigger: `${buildCampaignFiresOn({ ...existing, ...updates })} Config: ${JSON.stringify(updates.trigger_config === undefined ? existing.trigger_config : updates.trigger_config)}`,
|
|
3410
|
+
recipient_count: error ? null : count,
|
|
3411
|
+
};
|
|
3412
|
+
},
|
|
3413
|
+
})({ ...args, previous_trigger: existing, approval_id });
|
|
3414
|
+
});
|
|
3287
3415
|
server.registerTool('update_campaign', {
|
|
3288
3416
|
title: 'Update Campaign',
|
|
3289
3417
|
annotations: {
|
|
@@ -3292,7 +3420,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3292
3420
|
idempotentHint: true,
|
|
3293
3421
|
openWorldHint: false,
|
|
3294
3422
|
},
|
|
3295
|
-
description: 'Update a campaign name, site, segment, send time, topic, trigger
|
|
3423
|
+
description: 'Update a campaign name, site, segment, send time, topic, trigger event name/tag/config (including where filters for dunning), or send class (broadcast vs product). trigger_type null (or manual) clears a date_field or contact_event trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on, and trigger_diagnostics naming any trigger problems. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
|
|
3296
3424
|
inputSchema: {
|
|
3297
3425
|
campaign_id: z.string().uuid(),
|
|
3298
3426
|
name: z.string().min(1).max(200).optional(),
|
|
@@ -3416,7 +3544,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3416
3544
|
idempotentHint: true,
|
|
3417
3545
|
openWorldHint: false,
|
|
3418
3546
|
},
|
|
3419
|
-
description: 'Get full details
|
|
3547
|
+
description: 'Get full campaign details including all steps, enrollment counts and saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it.',
|
|
3420
3548
|
inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
|
|
3421
3549
|
}, async ({ campaign_id, period }) => {
|
|
3422
3550
|
const projectId = getProjectId();
|
|
@@ -3487,6 +3615,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3487
3615
|
const unresolvedCount = dlqUnresolvedCount ?? 0;
|
|
3488
3616
|
return j({
|
|
3489
3617
|
...campaign,
|
|
3618
|
+
fires_on: buildCampaignFiresOn(campaign),
|
|
3619
|
+
trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
|
|
3490
3620
|
total_sent: periods?.totals?.sent ?? sentCount ?? 0,
|
|
3491
3621
|
...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
|
|
3492
3622
|
...campaignPeriodFields(periods, period),
|
package/build/tools/contacts.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
3
3
|
import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
|
|
4
|
-
import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
4
|
+
import { buildPreviewAttentionEnvelope, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
7
7
|
import { db, projectClientForWriteback } from '../db.js';
|
|
@@ -2103,11 +2103,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2103
2103
|
q = q.eq('company_id', params.company_id);
|
|
2104
2104
|
const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
|
|
2105
2105
|
if (params.custom_property) {
|
|
2106
|
-
|
|
2106
|
+
try {
|
|
2107
|
+
// #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
|
|
2108
|
+
({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
|
|
2109
|
+
}
|
|
2110
|
+
catch (error) {
|
|
2111
|
+
if (isSegmentRulesError(error))
|
|
2112
|
+
return txt(`Error: ${error.message}`);
|
|
2113
|
+
throw error;
|
|
2114
|
+
}
|
|
2107
2115
|
}
|
|
2108
2116
|
if (params.site_id) {
|
|
2109
2117
|
// Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
|
|
2110
|
-
|
|
2118
|
+
// Deliberately everyone on the site, so allowEmpty.
|
|
2119
|
+
const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
|
|
2111
2120
|
if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0)
|
|
2112
2121
|
return j({ total: 0, sample: [] });
|
|
2113
2122
|
q = siteFiltered.query;
|
|
@@ -2155,11 +2164,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2155
2164
|
if (params.tag)
|
|
2156
2165
|
countQ = countQ.contains('tags', [params.tag]);
|
|
2157
2166
|
if (params.custom_property) {
|
|
2158
|
-
({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
|
|
2167
|
+
({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
|
|
2159
2168
|
}
|
|
2160
2169
|
// The total must count the same site the sample came from (#3674).
|
|
2161
2170
|
if (params.site_id)
|
|
2162
|
-
({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId));
|
|
2171
|
+
({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
|
|
2163
2172
|
const { count } = await countQ;
|
|
2164
2173
|
return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
|
|
2165
2174
|
});
|
package/build/tools/lists.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
2
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, isSegmentRulesError, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { logAudit } from '../audit.js';
|
|
@@ -28,7 +28,10 @@ async function loadFieldTypes(projectId) {
|
|
|
28
28
|
}
|
|
29
29
|
async function getSegmentContactIds(rules) {
|
|
30
30
|
const projectId = getProjectId();
|
|
31
|
+
// Throws SegmentRulesError for invalid rules (#4270) instead of widening to everyone.
|
|
31
32
|
const tree = normalizeSegmentRulesToTree(rules);
|
|
33
|
+
if (tree.match_none)
|
|
34
|
+
return [];
|
|
32
35
|
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
33
36
|
const q = buildSegmentBaseQuery(db(), projectId);
|
|
34
37
|
const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
|
|
@@ -357,7 +360,16 @@ export function registerListTools(server) {
|
|
|
357
360
|
},
|
|
358
361
|
}, async ({ list_id, rules, confirmed, approval_id }) => {
|
|
359
362
|
const projectId = getProjectId();
|
|
360
|
-
|
|
363
|
+
let normalizedRules;
|
|
364
|
+
try {
|
|
365
|
+
normalizedRules = normalizeSegmentRulesToTree(rules);
|
|
366
|
+
}
|
|
367
|
+
catch (rulesError) {
|
|
368
|
+
// #4270: invalid (or empty) filter rules add nobody and say why. Everyone is { match_all: true }.
|
|
369
|
+
if (isSegmentRulesError(rulesError))
|
|
370
|
+
return j({ error: rulesError.message, code: 'INVALID_SEGMENT_RULES', errors: rulesError.errors });
|
|
371
|
+
throw rulesError;
|
|
372
|
+
}
|
|
361
373
|
const approvalParams = { list_id, rules: normalizedRules };
|
|
362
374
|
const { data: list, error: listError } = await db()
|
|
363
375
|
.from('contact_lists')
|
package/build/tools/segments.js
CHANGED
|
@@ -5,7 +5,7 @@ import { db } from '../db.js';
|
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
6
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
7
7
|
import { assertUsableStopReason } from '@sendinel/domain';
|
|
8
|
-
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
8
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
|
|
9
9
|
async function loadFieldTypes(projectId) {
|
|
10
10
|
const client = db();
|
|
11
11
|
const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
|
|
@@ -27,7 +27,10 @@ async function loadFieldTypes(projectId) {
|
|
|
27
27
|
}
|
|
28
28
|
async function getSegmentContactIds(rules) {
|
|
29
29
|
const projectId = getProjectId();
|
|
30
|
+
// Throws SegmentRulesError for invalid rules (#4270) instead of widening to everyone.
|
|
30
31
|
const tree = normalizeSegmentRulesToTree(rules);
|
|
32
|
+
if (tree.match_none)
|
|
33
|
+
return [];
|
|
31
34
|
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
32
35
|
// Shared base query (#3084). This used to be hand-written here and omitted
|
|
33
36
|
// is_test / source_deleted_at / the cooled exclusion the dashboard applies,
|
|
@@ -53,6 +56,13 @@ async function getSegmentContactIds(rules) {
|
|
|
53
56
|
}
|
|
54
57
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
55
58
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
59
|
+
/**
|
|
60
|
+
* #4270: invalid segment rules are an error response naming the first problem,
|
|
61
|
+
* never an audience of everyone. Returns null for any other error.
|
|
62
|
+
*/
|
|
63
|
+
function invalidRules(error) {
|
|
64
|
+
return isSegmentRulesError(error) ? txt(`Error: ${error.message}`) : null;
|
|
65
|
+
}
|
|
56
66
|
export function registerSegmentTools(server) {
|
|
57
67
|
server.registerTool('create_segment', {
|
|
58
68
|
title: 'Create Segment',
|
|
@@ -69,9 +79,19 @@ export function registerSegmentTools(server) {
|
|
|
69
79
|
rules: z.record(z.string(), z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.<key>. tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
|
|
70
80
|
},
|
|
71
81
|
}, async ({ name, description, rules }) => {
|
|
82
|
+
let storedRules;
|
|
83
|
+
try {
|
|
84
|
+
storedRules = prepareSegmentRulesForStorage(rules);
|
|
85
|
+
}
|
|
86
|
+
catch (rulesError) {
|
|
87
|
+
const invalid = invalidRules(rulesError);
|
|
88
|
+
if (invalid)
|
|
89
|
+
return invalid;
|
|
90
|
+
throw rulesError;
|
|
91
|
+
}
|
|
72
92
|
const { data, error } = await db()
|
|
73
93
|
.from('segments')
|
|
74
|
-
.insert({ project_id: getProjectId(), name, description, rules:
|
|
94
|
+
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
75
95
|
.select('id, name, description, rules, created_at')
|
|
76
96
|
.single();
|
|
77
97
|
if (error)
|
|
@@ -105,7 +125,17 @@ export function registerSegmentTools(server) {
|
|
|
105
125
|
return txt(`Error: ${error.message}`);
|
|
106
126
|
const segments = [];
|
|
107
127
|
for (const seg of data ?? []) {
|
|
108
|
-
|
|
128
|
+
let ids;
|
|
129
|
+
try {
|
|
130
|
+
ids = await getSegmentContactIds(seg.rules);
|
|
131
|
+
}
|
|
132
|
+
catch (rulesError) {
|
|
133
|
+
if (!isSegmentRulesError(rulesError))
|
|
134
|
+
throw rulesError;
|
|
135
|
+
// #4270: one stored segment with invalid rules matches nobody; its count is unknown, not 0.
|
|
136
|
+
segments.push({ ...seg, contact_count: null, rules_error: rulesError.message });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
109
139
|
segments.push({ ...seg, contact_count: ids.length });
|
|
110
140
|
}
|
|
111
141
|
return j(segments);
|
|
@@ -131,8 +161,17 @@ export function registerSegmentTools(server) {
|
|
|
131
161
|
updates.name = name;
|
|
132
162
|
if (description !== undefined)
|
|
133
163
|
updates.description = description;
|
|
134
|
-
if (rules)
|
|
135
|
-
|
|
164
|
+
if (rules) {
|
|
165
|
+
try {
|
|
166
|
+
updates.rules = prepareSegmentRulesForStorage(rules);
|
|
167
|
+
}
|
|
168
|
+
catch (rulesError) {
|
|
169
|
+
const invalid = invalidRules(rulesError);
|
|
170
|
+
if (invalid)
|
|
171
|
+
return invalid;
|
|
172
|
+
throw rulesError;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
136
175
|
const { data, error } = await db()
|
|
137
176
|
.from('segments')
|
|
138
177
|
.update(updates)
|
|
@@ -199,7 +238,16 @@ export function registerSegmentTools(server) {
|
|
|
199
238
|
else {
|
|
200
239
|
return txt('Provide either segment_id or rules.');
|
|
201
240
|
}
|
|
202
|
-
|
|
241
|
+
let ids;
|
|
242
|
+
try {
|
|
243
|
+
ids = await getSegmentContactIds(segRules);
|
|
244
|
+
}
|
|
245
|
+
catch (rulesError) {
|
|
246
|
+
const invalid = invalidRules(rulesError);
|
|
247
|
+
if (invalid)
|
|
248
|
+
return invalid;
|
|
249
|
+
throw rulesError;
|
|
250
|
+
}
|
|
203
251
|
const sample = ids.slice(0, 10);
|
|
204
252
|
let sampleContacts = [];
|
|
205
253
|
if (sample.length) {
|
|
@@ -243,9 +291,12 @@ export function registerSegmentTools(server) {
|
|
|
243
291
|
return txt(`Error: Failed to parse rules from "${description}"`);
|
|
244
292
|
let parsedRules;
|
|
245
293
|
try {
|
|
246
|
-
parsedRules =
|
|
294
|
+
parsedRules = prepareSegmentRulesForStorage(JSON.parse(raw.replace(/```json?\n?|```/g, '').trim()));
|
|
247
295
|
}
|
|
248
|
-
catch {
|
|
296
|
+
catch (parseError) {
|
|
297
|
+
// #4270: parsed rules that are missing or malformed are an error, never "everyone".
|
|
298
|
+
if (isSegmentRulesError(parseError))
|
|
299
|
+
return txt(`Error: Could not use the rules parsed from "${description}". ${parseError.message}`);
|
|
249
300
|
return txt(`Error: Failed to parse rules from "${description}"`);
|
|
250
301
|
}
|
|
251
302
|
const ids = await getSegmentContactIds(parsedRules);
|
|
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
|
|
|
12
12
|
* Only tools that exist in the registry belong here.
|
|
13
13
|
*/
|
|
14
14
|
export const TOOL_ALIASES = {
|
|
15
|
+
set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'trial ending'],
|
|
15
16
|
send_broadcast: [
|
|
16
17
|
'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
|
|
17
18
|
'email my list', 'send to my list', 'email to my list', 'email my subscribers',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.103",
|
|
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",
|