@sendinel/mcp-server 1.0.114 → 1.0.116
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.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applySegmentRules, buildSegmentBaseQuery, chunkIds, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
1
|
+
import { applySegmentRules, buildSegmentBaseQuery, chunkIds, evaluateSegmentRules, hydrateEmailSendsContacts, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
async function loadFieldTypes(projectId) {
|
|
4
4
|
const { data, error } = await db()
|
|
@@ -30,7 +30,8 @@ export async function resolveSegmentContactIds(projectId, rules) {
|
|
|
30
30
|
if (siteContactIds && siteContactIds.length === 0)
|
|
31
31
|
return [];
|
|
32
32
|
const data = await readAllRows(filtered);
|
|
33
|
-
const
|
|
33
|
+
const hydrated = await hydrateEmailSendsContacts(db(), projectId, data, tree);
|
|
34
|
+
const contacts = hydrated.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
|
|
34
35
|
if (tree.exclude_suppressed === false || contacts.length === 0)
|
|
35
36
|
return contacts.map((c) => String(c.id));
|
|
36
37
|
const suppressed = await readAllRows(db().from('suppressions').select('id, email').eq('project_id', projectId));
|
package/build/tools/campaigns.js
CHANGED
|
@@ -31,6 +31,7 @@ import { callSendinelV1 } from '../lib/v1-client.js';
|
|
|
31
31
|
import { countUniqueEngagementByCampaign } from '../lib/campaign-engagement.js';
|
|
32
32
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
33
33
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
34
|
+
const ACTIVATION_APPROVAL_DESCRIPTION = 'Activation-only: requires_approval gates campaign activation. It does not stop sends of an already-active campaign or sends triggered by other paths.';
|
|
34
35
|
const SENT_EMAIL_STATUSES = ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'unsubscribed'];
|
|
35
36
|
function stripHtml(value) {
|
|
36
37
|
return value
|
|
@@ -642,7 +643,7 @@ export function registerCampaignTools(server) {
|
|
|
642
643
|
idempotentHint: true,
|
|
643
644
|
openWorldHint: false,
|
|
644
645
|
},
|
|
645
|
-
description: 'List campaigns with saved trigger fields, fires_on summaries and trigger_diagnostics; optionally filter by site or status.',
|
|
646
|
+
description: 'List campaigns with saved trigger fields, fires_on summaries and trigger_diagnostics; optionally filter by site or status. ' + ACTIVATION_APPROVAL_DESCRIPTION,
|
|
646
647
|
inputSchema: {
|
|
647
648
|
site_id: z.string().uuid().optional(),
|
|
648
649
|
period: z.string().min(1).optional().describe("Return one enrollment period in by_period; totals remain all-time."),
|
|
@@ -652,7 +653,7 @@ export function registerCampaignTools(server) {
|
|
|
652
653
|
}, async ({ site_id, status, limit, period }) => {
|
|
653
654
|
let q = db()
|
|
654
655
|
.from('campaigns')
|
|
655
|
-
.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, send_class, unsub_footer_template')
|
|
656
|
+
.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, send_class, unsub_footer_template, requires_approval, approved_by, approved_at')
|
|
656
657
|
.eq('project_id', getProjectId())
|
|
657
658
|
.order('created_at', { ascending: false })
|
|
658
659
|
.limit(limit ?? 25);
|
|
@@ -672,6 +673,7 @@ export function registerCampaignTools(server) {
|
|
|
672
673
|
const { period_key: _periodKey, ...legacy } = row;
|
|
673
674
|
return {
|
|
674
675
|
...legacy,
|
|
676
|
+
requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
|
|
675
677
|
fires_on: buildCampaignFiresOn(row),
|
|
676
678
|
trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
|
|
677
679
|
...campaignPeriodFields(periods, period),
|
|
@@ -735,7 +737,7 @@ export function registerCampaignTools(server) {
|
|
|
735
737
|
type: z.enum(['sequence', 'broadcast', 'triggered']),
|
|
736
738
|
description: z.string().optional(),
|
|
737
739
|
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.'),
|
|
738
|
-
requires_approval: z.boolean().optional().default(true),
|
|
740
|
+
requires_approval: z.boolean().optional().default(true).describe(ACTIVATION_APPROVAL_DESCRIPTION),
|
|
739
741
|
trigger_event_name: z.string().optional().describe('For type=triggered: event name that fires this campaign'),
|
|
740
742
|
trigger_tag: z.string().optional().describe('For type=triggered: contact tag that fires this campaign when applied'),
|
|
741
743
|
trigger_type: z.enum(['date_field']).optional().describe('Set for elapsed-time triggers keyed on a contact custom_properties date field'),
|
|
@@ -773,7 +775,7 @@ export function registerCampaignTools(server) {
|
|
|
773
775
|
.single();
|
|
774
776
|
if (error)
|
|
775
777
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
776
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
778
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...data, requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION }, null, 2) }] };
|
|
777
779
|
});
|
|
778
780
|
const propertyConditions = z.array(z.union([
|
|
779
781
|
z.object({ key: z.string(), op: z.enum(['eq', 'neq']), value: z.string() }).strict(),
|
|
@@ -849,7 +851,7 @@ export function registerCampaignTools(server) {
|
|
|
849
851
|
idempotentHint: true,
|
|
850
852
|
openWorldHint: true,
|
|
851
853
|
},
|
|
852
|
-
description: 'Activate, pause, or archive a campaign. Activating requires human approval via the dashboard — call without approval_id to request one, then call again with the approved token.',
|
|
854
|
+
description: 'Activate, pause, or archive a campaign. Activating requires human approval via the dashboard — call without approval_id to request one, then call again with the approved token. ' + ACTIVATION_APPROVAL_DESCRIPTION,
|
|
853
855
|
inputSchema: {
|
|
854
856
|
campaign_id: z.string().uuid(),
|
|
855
857
|
status: z.enum(['draft', 'active', 'paused', 'completed', 'archived']),
|
|
@@ -3558,7 +3560,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3558
3560
|
idempotentHint: true,
|
|
3559
3561
|
openWorldHint: false,
|
|
3560
3562
|
},
|
|
3561
|
-
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.',
|
|
3563
|
+
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. ' + ACTIVATION_APPROVAL_DESCRIPTION,
|
|
3562
3564
|
inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
|
|
3563
3565
|
}, async ({ campaign_id, period }) => {
|
|
3564
3566
|
const projectId = getProjectId();
|
|
@@ -3629,6 +3631,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3629
3631
|
const unresolvedCount = dlqUnresolvedCount ?? 0;
|
|
3630
3632
|
return j({
|
|
3631
3633
|
...campaign,
|
|
3634
|
+
requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
|
|
3632
3635
|
fires_on: buildCampaignFiresOn(campaign),
|
|
3633
3636
|
trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
|
|
3634
3637
|
total_sent: periods?.totals?.sent ?? sentCount ?? 0,
|
package/build/tools/lists.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, isSegmentRulesError, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
|
|
2
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, 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';
|
|
@@ -39,7 +39,8 @@ async function getSegmentContactIds(rules) {
|
|
|
39
39
|
return [];
|
|
40
40
|
// Paged and loud (#3674): an unpaged read stopped at 1,000 rows, and an error read as "no one".
|
|
41
41
|
const data = await readAllRows(filtered);
|
|
42
|
-
const
|
|
42
|
+
const hydrated = await hydrateEmailSendsContacts(db(), projectId, data, tree);
|
|
43
|
+
const contacts = hydrated.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
|
|
43
44
|
let ids = contacts.map((contact) => String(contact.id));
|
|
44
45
|
if (tree.exclude_suppressed !== false && ids.length > 0) {
|
|
45
46
|
const suppressed = await readAllRows(db().from('suppressions').select('id, email').eq('project_id', projectId));
|
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, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
|
|
8
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, 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;
|
|
@@ -41,7 +41,8 @@ async function getSegmentContactIds(rules) {
|
|
|
41
41
|
return [];
|
|
42
42
|
// Paged and loud (#3674): an unpaged read stopped at 1,000 rows, and an error read as "no one".
|
|
43
43
|
const data = await readAllRows(filtered);
|
|
44
|
-
const
|
|
44
|
+
const hydrated = await hydrateEmailSendsContacts(db(), projectId, data, tree);
|
|
45
|
+
const contacts = hydrated.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
|
|
45
46
|
let ids = contacts.map((c) => String(c.id));
|
|
46
47
|
if (tree.exclude_suppressed !== false && ids.length > 0) {
|
|
47
48
|
const suppressed = await readAllRows(db().from('suppressions').select('id, email').eq('project_id', projectId));
|
|
@@ -76,7 +77,7 @@ export function registerSegmentTools(server) {
|
|
|
76
77
|
inputSchema: {
|
|
77
78
|
name: z.string().min(1).describe('Segment name (unique per project)'),
|
|
78
79
|
description: z.string().optional(),
|
|
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
|
|
80
|
+
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>, email_sends (eq only, value: {min_count: positive integer, within_days: positive number}; at most one per segment). tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
|
|
80
81
|
},
|
|
81
82
|
}, async ({ name, description, rules }) => {
|
|
82
83
|
let storedRules;
|
|
@@ -281,7 +282,7 @@ export function registerSegmentTools(server) {
|
|
|
281
282
|
const resp = await anthropic.messages.create({
|
|
282
283
|
model: getAnthropicModelId(),
|
|
283
284
|
max_tokens: 4096,
|
|
284
|
-
system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
|
|
285
|
+
system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at, email_sends. email_sends uses eq only and value {"min_count":4,"within_days":30}; both required, at most one per segment. It counts email_log rows with non-null sent_at in the inclusive elapsed-day window through now, regardless of current status. No observed sends = zero; failed history reads are errors. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
|
|
285
286
|
messages: [{ role: 'user', content: description }],
|
|
286
287
|
});
|
|
287
288
|
assertUsableStopReason(resp.stop_reason);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.116",
|
|
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",
|