@sendinel/mcp-server 1.0.4 → 1.0.6
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/build/audit.d.ts +1 -0
- package/build/audit.js +9 -3
- package/build/auth.d.ts +10 -0
- package/build/auth.js +82 -9
- package/build/db.d.ts +12 -0
- package/build/db.js +37 -2
- package/build/index.js +38 -12
- package/build/lib/action-approvals.d.ts +28 -1
- package/build/lib/action-approvals.js +89 -2
- package/build/lib/brand-asset-persist.d.ts +35 -0
- package/build/lib/brand-asset-persist.js +161 -0
- package/build/lib/brand-asset-usage.d.ts +13 -0
- package/build/lib/brand-asset-usage.js +46 -0
- package/build/lib/email-provider.d.ts +19 -0
- package/build/lib/email-provider.js +281 -0
- package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
- package/build/lib/ssrf-pinned-fetch.js +101 -0
- package/build/lib/survey-link-resolver.d.ts +6 -0
- package/build/lib/survey-link-resolver.js +23 -0
- package/build/lib/svg-sanitize.d.ts +2 -0
- package/build/lib/svg-sanitize.js +110 -0
- package/build/lib/webhook-events.d.ts +1 -1
- package/build/scoped-token.d.ts +11 -0
- package/build/scoped-token.js +59 -0
- package/build/server.js +30 -9
- package/build/tools/ab-testing.js +28 -0
- package/build/tools/advisor.js +52 -2
- package/build/tools/ai-work.d.ts +2 -0
- package/build/tools/ai-work.js +460 -0
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +203 -0
- package/build/tools/approvals.js +15 -1
- package/build/tools/automations.js +288 -65
- package/build/tools/brand-kit.d.ts +2 -0
- package/build/tools/brand-kit.js +277 -0
- package/build/tools/campaigns.js +900 -69
- package/build/tools/compound.js +28 -0
- package/build/tools/contacts.js +338 -17
- package/build/tools/content.js +266 -1
- package/build/tools/data-proposals.js +21 -0
- package/build/tools/data.js +140 -8
- package/build/tools/delivery-ops.js +84 -0
- package/build/tools/drafts.js +64 -1
- package/build/tools/forms.js +42 -9
- package/build/tools/gdpr.js +22 -3
- package/build/tools/newsletters.d.ts +2 -0
- package/build/tools/newsletters.js +583 -0
- package/build/tools/org.js +297 -3
- package/build/tools/segments.js +45 -3
- package/build/tools/short-links.d.ts +2 -0
- package/build/tools/short-links.js +157 -0
- package/build/tools/sites.js +35 -0
- package/build/tools/sms.js +49 -0
- package/build/tools/social-posts.js +81 -5
- package/build/tools/surveys.d.ts +2 -0
- package/build/tools/surveys.js +222 -0
- package/build/tools/templates.js +78 -2
- package/build/tools/warmup.js +28 -0
- package/build/tools/webhooks.js +29 -0
- package/package.json +9 -3
|
@@ -7,9 +7,133 @@ import { buildVoicePrompt, buildEmailSystemPrompt } from '../lib/voice.js';
|
|
|
7
7
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
8
8
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
9
9
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
10
|
+
function normalizeValue(value) {
|
|
11
|
+
if (typeof value !== 'string')
|
|
12
|
+
return null;
|
|
13
|
+
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
14
|
+
return normalized || null;
|
|
15
|
+
}
|
|
16
|
+
function normalizeList(value) {
|
|
17
|
+
const raw = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
|
18
|
+
return Array.from(new Set(raw.map(normalizeValue).filter((item) => Boolean(item))));
|
|
19
|
+
}
|
|
20
|
+
function triggerConfig(value) {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
22
|
+
return { events: [], tags: [] };
|
|
23
|
+
const config = value;
|
|
24
|
+
return {
|
|
25
|
+
events: normalizeList(config.events ?? config.event_names ?? config.trigger_event_names),
|
|
26
|
+
tags: normalizeList(config.tags ?? config.trigger_tags ?? config.entity_tags ?? config.project_tags),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function campaignMatchesTrigger(campaign, input) {
|
|
30
|
+
const config = triggerConfig(campaign.trigger_config);
|
|
31
|
+
const legacyEvent = normalizeValue(campaign.trigger_event_name);
|
|
32
|
+
const legacyTag = normalizeValue(campaign.trigger_tag);
|
|
33
|
+
const campaignEvents = Array.from(new Set([...(legacyEvent ? [legacyEvent] : []), ...config.events]));
|
|
34
|
+
const campaignTags = Array.from(new Set([...(legacyTag ? [legacyTag] : []), ...config.tags]));
|
|
35
|
+
const event = normalizeValue(input.event);
|
|
36
|
+
const tags = new Set((input.tags ?? []).map(normalizeValue).filter((tag) => Boolean(tag)));
|
|
37
|
+
if (campaignEvents.length > 0 && (!event || !campaignEvents.includes(event)))
|
|
38
|
+
return false;
|
|
39
|
+
if (campaignTags.length > 0 && !campaignTags.some((tag) => tags.has(tag)))
|
|
40
|
+
return false;
|
|
41
|
+
return campaignEvents.length > 0 || campaignTags.length > 0;
|
|
42
|
+
}
|
|
43
|
+
async function enrollSubscriberIntoTriggeredCampaigns(input) {
|
|
44
|
+
const { subscriber_id, campaign_id, site_id, trigger_event_name, trigger_event_names, trigger_tag, trigger_tags, next_send_at, context } = input;
|
|
45
|
+
const triggerEvents = Array.from(new Set([...normalizeList(trigger_event_names), ...(trigger_event_name ? [trigger_event_name] : [])]));
|
|
46
|
+
const triggerTags = Array.from(new Set([...normalizeList(trigger_tags), ...(trigger_tag ? [trigger_tag] : [])]));
|
|
47
|
+
const projectId = getProjectId();
|
|
48
|
+
const { data: subscriber, error: subscriberError } = await db()
|
|
49
|
+
.from('subscribers')
|
|
50
|
+
.select('id, email, global_unsubscribed, cooled_at')
|
|
51
|
+
.eq('id', subscriber_id)
|
|
52
|
+
.eq('project_id', projectId)
|
|
53
|
+
.maybeSingle();
|
|
54
|
+
if (subscriberError)
|
|
55
|
+
return txt(`Error: ${subscriberError.message}`);
|
|
56
|
+
if (!subscriber)
|
|
57
|
+
return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
|
|
58
|
+
if (subscriber.global_unsubscribed)
|
|
59
|
+
return j({ enrolled: 0, skipped: 'global_unsubscribed' });
|
|
60
|
+
if (subscriber.cooled_at)
|
|
61
|
+
return j({ enrolled: 0, skipped: 'cooled_contact' });
|
|
62
|
+
const { data: suppression } = await db()
|
|
63
|
+
.from('suppressions')
|
|
64
|
+
.select('id')
|
|
65
|
+
.eq('project_id', projectId)
|
|
66
|
+
.eq('email', subscriber.email)
|
|
67
|
+
.maybeSingle();
|
|
68
|
+
if (suppression)
|
|
69
|
+
return j({ enrolled: 0, skipped: 'suppressed' });
|
|
70
|
+
let query = db()
|
|
71
|
+
.from('campaigns')
|
|
72
|
+
.select('id, name, site_id, trigger_event_name, trigger_tag, trigger_config')
|
|
73
|
+
.eq('project_id', projectId)
|
|
74
|
+
.eq('type', 'triggered')
|
|
75
|
+
.eq('status', 'active');
|
|
76
|
+
if (campaign_id)
|
|
77
|
+
query = query.eq('id', campaign_id);
|
|
78
|
+
if (site_id)
|
|
79
|
+
query = query.eq('site_id', site_id);
|
|
80
|
+
if (!campaign_id && !triggerEvents.length && !triggerTags.length) {
|
|
81
|
+
return txt('Provide campaign_id, trigger_event_name(s), or trigger_tag(s).');
|
|
82
|
+
}
|
|
83
|
+
const { data: candidateCampaigns, error: campaignError } = await query;
|
|
84
|
+
if (campaignError)
|
|
85
|
+
return txt(`Error: ${campaignError.message}`);
|
|
86
|
+
const campaigns = campaign_id
|
|
87
|
+
? (candidateCampaigns ?? [])
|
|
88
|
+
: (candidateCampaigns ?? []).filter((campaign) => triggerEvents.some(event => campaignMatchesTrigger(campaign, { event, tags: triggerTags }))
|
|
89
|
+
|| (!triggerEvents.length && campaignMatchesTrigger(campaign, { tags: triggerTags })));
|
|
90
|
+
if (!campaigns?.length)
|
|
91
|
+
return j({ enrolled: 0, campaigns: [], skipped: 'no_matching_active_automation' });
|
|
92
|
+
const sendAt = next_send_at ?? new Date().toISOString();
|
|
93
|
+
const rows = campaigns.map((campaign) => ({
|
|
94
|
+
campaign_id: campaign.id,
|
|
95
|
+
contact_id: subscriber_id,
|
|
96
|
+
current_step: 0,
|
|
97
|
+
status: 'active',
|
|
98
|
+
enrolled_at: new Date().toISOString(),
|
|
99
|
+
next_send_at: sendAt,
|
|
100
|
+
enrollment_context: context ?? {},
|
|
101
|
+
}));
|
|
102
|
+
const { data: inserted, error: enrollError } = await db()
|
|
103
|
+
.from('campaign_enrollments')
|
|
104
|
+
.upsert(rows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true })
|
|
105
|
+
.select('id, campaign_id');
|
|
106
|
+
if (enrollError)
|
|
107
|
+
return txt(`Error: ${enrollError.message}`);
|
|
108
|
+
if (inserted?.length) {
|
|
109
|
+
for (const campaign of campaigns) {
|
|
110
|
+
const count = inserted.filter((row) => row.campaign_id === campaign.id).length;
|
|
111
|
+
if (count > 0) {
|
|
112
|
+
const { data: current } = await db().from('campaigns').select('total_enrolled').eq('id', campaign.id).maybeSingle();
|
|
113
|
+
await db().from('campaigns').update({ total_enrolled: (current?.total_enrolled ?? 0) + count }).eq('id', campaign.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return j({
|
|
118
|
+
enrolled: inserted?.length ?? 0,
|
|
119
|
+
subscriber_id,
|
|
120
|
+
campaigns: campaigns.map((campaign) => ({
|
|
121
|
+
id: campaign.id,
|
|
122
|
+
name: campaign.name,
|
|
123
|
+
matched: Boolean(inserted?.some((row) => row.campaign_id === campaign.id)),
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
10
127
|
export function registerAutomationTools(server) {
|
|
11
128
|
// ── list_automations ──────────────────────────────────────────────────────
|
|
12
129
|
server.registerTool('list_automations', {
|
|
130
|
+
title: 'List Automations',
|
|
131
|
+
annotations: {
|
|
132
|
+
readOnlyHint: true,
|
|
133
|
+
destructiveHint: false,
|
|
134
|
+
idempotentHint: true,
|
|
135
|
+
openWorldHint: false,
|
|
136
|
+
},
|
|
13
137
|
description: 'List content automation sources (RSS feeds, GitHub Issues, webhooks, schedules). Each source auto-generates email drafts when new content is found.',
|
|
14
138
|
inputSchema: {
|
|
15
139
|
site_id: z.string().uuid().optional().describe('Filter by site UUID'),
|
|
@@ -31,97 +155,189 @@ export function registerAutomationTools(server) {
|
|
|
31
155
|
return j(data ?? []);
|
|
32
156
|
});
|
|
33
157
|
server.registerTool('enroll_automation', {
|
|
158
|
+
title: 'Enroll Automation',
|
|
159
|
+
annotations: {
|
|
160
|
+
readOnlyHint: false,
|
|
161
|
+
destructiveHint: false,
|
|
162
|
+
idempotentHint: false,
|
|
163
|
+
openWorldHint: false,
|
|
164
|
+
},
|
|
34
165
|
description: 'Enroll a subscriber into active triggered automation campaigns. Match by campaign_id, trigger_event_name, or trigger_tag. Safe to call repeatedly.',
|
|
35
166
|
inputSchema: {
|
|
36
167
|
subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
|
|
37
168
|
campaign_id: z.string().uuid().optional().describe('Specific triggered campaign UUID'),
|
|
38
169
|
site_id: z.string().uuid().optional().describe('Optional site scope when matching by trigger'),
|
|
39
170
|
trigger_event_name: z.string().optional().describe('Event trigger name, e.g. signup'),
|
|
171
|
+
trigger_event_names: z.array(z.string()).optional().describe('OR-combined event trigger names, e.g. ["contact_created", "form_submitted"]'),
|
|
40
172
|
trigger_tag: z.string().optional().describe('Tag trigger name'),
|
|
173
|
+
trigger_tags: z.array(z.string()).optional().describe('Tag/entity filters for reusable triggered campaigns'),
|
|
41
174
|
next_send_at: z.string().datetime().optional().describe('First send time. Defaults to now.'),
|
|
42
175
|
context: z.record(z.string(), z.unknown()).optional().describe('Optional enrollment context metadata'),
|
|
43
176
|
},
|
|
44
|
-
},
|
|
177
|
+
}, enrollSubscriberIntoTriggeredCampaigns);
|
|
178
|
+
server.registerTool('create_escalation_ladder', {
|
|
179
|
+
title: 'Create Escalation Ladder',
|
|
180
|
+
annotations: {
|
|
181
|
+
readOnlyHint: false,
|
|
182
|
+
destructiveHint: false,
|
|
183
|
+
idempotentHint: false,
|
|
184
|
+
openWorldHint: false,
|
|
185
|
+
},
|
|
186
|
+
description: 'Create or update response-gated escalation ladder steps for an active triggered campaign. This defines the ladder only; automatic advancement remains disabled until response detection is wired.',
|
|
187
|
+
inputSchema: {
|
|
188
|
+
campaign_id: z.string().uuid().describe('Active triggered campaign UUID'),
|
|
189
|
+
steps: z.array(z.object({
|
|
190
|
+
step_index: z.number().int().min(0).describe('Zero-based ladder step index'),
|
|
191
|
+
wait_hours: z.number().int().min(0).default(0).describe('Hours to wait before this step is eligible'),
|
|
192
|
+
channel: z.enum(['email', 'sms', 'voicemail', 'manual']).describe('Planned channel for the step'),
|
|
193
|
+
action_type: z.enum(['send_win_back_draft', 'send_template', 'notify_human', 'no_op']).describe('Action to take if the contact has not responded'),
|
|
194
|
+
content_ref: z.record(z.string(), z.unknown()).optional().describe('Reference to existing content/template/draft metadata'),
|
|
195
|
+
response_gate: z.record(z.string(), z.boolean()).optional().describe('Response signals that halt the ladder, e.g. { "reply": true, "click": true, "booking": true }'),
|
|
196
|
+
})).min(1).max(20),
|
|
197
|
+
},
|
|
198
|
+
}, async ({ campaign_id, steps }) => {
|
|
45
199
|
const projectId = getProjectId();
|
|
46
|
-
const { data:
|
|
47
|
-
.from('
|
|
48
|
-
.select('id,
|
|
49
|
-
.eq('id',
|
|
200
|
+
const { data: campaign, error: campaignError } = await db()
|
|
201
|
+
.from('campaigns')
|
|
202
|
+
.select('id, name, type, status')
|
|
203
|
+
.eq('id', campaign_id)
|
|
50
204
|
.eq('project_id', projectId)
|
|
51
205
|
.maybeSingle();
|
|
52
|
-
if (
|
|
53
|
-
return txt(`Error: ${
|
|
54
|
-
if (!
|
|
55
|
-
return j({ error: '
|
|
56
|
-
if (
|
|
57
|
-
return j({
|
|
58
|
-
const
|
|
59
|
-
|
|
206
|
+
if (campaignError)
|
|
207
|
+
return txt(`Error: ${campaignError.message}`);
|
|
208
|
+
if (!campaign)
|
|
209
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
210
|
+
if (campaign.type !== 'triggered')
|
|
211
|
+
return j({ error: 'Escalation ladders require a triggered campaign', code: 'INVALID_CAMPAIGN_TYPE' });
|
|
212
|
+
const rows = steps.map((step) => ({
|
|
213
|
+
campaign_id,
|
|
214
|
+
step_index: step.step_index,
|
|
215
|
+
wait_hours: step.wait_hours ?? 0,
|
|
216
|
+
channel: step.channel,
|
|
217
|
+
action_type: step.action_type,
|
|
218
|
+
content_ref: step.content_ref ?? {},
|
|
219
|
+
response_gate: step.response_gate ?? { reply: true, click: true, booking: true },
|
|
220
|
+
updated_at: new Date().toISOString(),
|
|
221
|
+
}));
|
|
222
|
+
const { data, error } = await db()
|
|
223
|
+
.from('campaign_step_gates')
|
|
224
|
+
.upsert(rows, { onConflict: 'campaign_id,step_index' })
|
|
225
|
+
.select('id, campaign_id, step_index, wait_hours, channel, action_type, content_ref, response_gate')
|
|
226
|
+
.order('step_index', { ascending: true });
|
|
227
|
+
if (error)
|
|
228
|
+
return txt(`Error: ${error.message}`);
|
|
229
|
+
return j({
|
|
230
|
+
campaign_id,
|
|
231
|
+
campaign_name: campaign.name,
|
|
232
|
+
status: campaign.status,
|
|
233
|
+
steps: data ?? [],
|
|
234
|
+
advancement_enabled: false,
|
|
235
|
+
note: 'Ladder definition saved. Automatic response-gated advancement is intentionally disabled until reply/booking signal schemas are confirmed.',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
server.registerTool('enroll_in_escalation_ladder', {
|
|
239
|
+
title: 'Enroll In Escalation Ladder',
|
|
240
|
+
annotations: {
|
|
241
|
+
readOnlyHint: false,
|
|
242
|
+
destructiveHint: false,
|
|
243
|
+
idempotentHint: false,
|
|
244
|
+
openWorldHint: false,
|
|
245
|
+
},
|
|
246
|
+
description: 'Enroll a subscriber into an active triggered campaign that has escalation ladder steps. Reuses enroll_automation suppression and unsubscribe checks.',
|
|
247
|
+
inputSchema: {
|
|
248
|
+
subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
|
|
249
|
+
campaign_id: z.string().uuid().describe('Triggered campaign UUID with ladder steps'),
|
|
250
|
+
next_send_at: z.string().datetime().optional().describe('First eligible send time. Defaults to now.'),
|
|
251
|
+
context: z.record(z.string(), z.unknown()).optional().describe('Optional enrollment context metadata'),
|
|
252
|
+
},
|
|
253
|
+
}, async ({ subscriber_id, campaign_id, next_send_at, context }) => {
|
|
254
|
+
const projectId = getProjectId();
|
|
255
|
+
const { data: gate, error: gateError } = await db()
|
|
256
|
+
.from('campaign_step_gates')
|
|
60
257
|
.select('id')
|
|
61
|
-
.eq('
|
|
62
|
-
.
|
|
258
|
+
.eq('campaign_id', campaign_id)
|
|
259
|
+
.limit(1)
|
|
63
260
|
.maybeSingle();
|
|
64
|
-
if (
|
|
65
|
-
return
|
|
66
|
-
|
|
261
|
+
if (gateError)
|
|
262
|
+
return txt(`Error: ${gateError.message}`);
|
|
263
|
+
if (!gate)
|
|
264
|
+
return j({ enrolled: 0, skipped: 'no_escalation_ladder_steps' });
|
|
265
|
+
return enrollSubscriberIntoTriggeredCampaigns({
|
|
266
|
+
subscriber_id,
|
|
267
|
+
campaign_id,
|
|
268
|
+
next_send_at,
|
|
269
|
+
context: { ...(context ?? {}), escalation_ladder: true, project_id: projectId },
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
server.registerTool('list_escalation_ladder_status', {
|
|
273
|
+
title: 'List Escalation Ladder Status',
|
|
274
|
+
annotations: {
|
|
275
|
+
readOnlyHint: true,
|
|
276
|
+
destructiveHint: false,
|
|
277
|
+
idempotentHint: true,
|
|
278
|
+
openWorldHint: false,
|
|
279
|
+
},
|
|
280
|
+
description: 'List enrollment status for a response-gated escalation ladder campaign, including current step and response-exit fields.',
|
|
281
|
+
inputSchema: {
|
|
282
|
+
campaign_id: z.string().uuid().describe('Escalation ladder campaign UUID'),
|
|
283
|
+
},
|
|
284
|
+
}, async ({ campaign_id }) => {
|
|
285
|
+
const projectId = getProjectId();
|
|
286
|
+
const { data: campaign, error: campaignError } = await db()
|
|
67
287
|
.from('campaigns')
|
|
68
|
-
.select('id, name,
|
|
288
|
+
.select('id, name, status')
|
|
289
|
+
.eq('id', campaign_id)
|
|
69
290
|
.eq('project_id', projectId)
|
|
70
|
-
.
|
|
71
|
-
.eq('status', 'active');
|
|
72
|
-
if (campaign_id)
|
|
73
|
-
query = query.eq('id', campaign_id);
|
|
74
|
-
if (site_id)
|
|
75
|
-
query = query.eq('site_id', site_id);
|
|
76
|
-
if (trigger_event_name)
|
|
77
|
-
query = query.eq('trigger_event_name', trigger_event_name.trim());
|
|
78
|
-
if (trigger_tag)
|
|
79
|
-
query = query.eq('trigger_tag', trigger_tag.trim().toLowerCase());
|
|
80
|
-
if (!campaign_id && !trigger_event_name && !trigger_tag) {
|
|
81
|
-
return txt('Provide campaign_id, trigger_event_name, or trigger_tag.');
|
|
82
|
-
}
|
|
83
|
-
const { data: campaigns, error: campaignError } = await query;
|
|
291
|
+
.maybeSingle();
|
|
84
292
|
if (campaignError)
|
|
85
293
|
return txt(`Error: ${campaignError.message}`);
|
|
86
|
-
if (!
|
|
87
|
-
return j({
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
return txt(`Error: ${
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
294
|
+
if (!campaign)
|
|
295
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
296
|
+
const [{ data: steps, error: stepsError }, { data: enrollments, error: enrollmentsError }] = await Promise.all([
|
|
297
|
+
db()
|
|
298
|
+
.from('campaign_step_gates')
|
|
299
|
+
.select('id, step_index, wait_hours, channel, action_type, response_gate')
|
|
300
|
+
.eq('campaign_id', campaign_id)
|
|
301
|
+
.order('step_index', { ascending: true }),
|
|
302
|
+
db()
|
|
303
|
+
.from('campaign_enrollments')
|
|
304
|
+
.select('id, contact_id, current_step, status, enrolled_at, next_send_at, completed_at, exited_reason, response_detected_at, response_type, enrollment_context')
|
|
305
|
+
.eq('campaign_id', campaign_id)
|
|
306
|
+
.order('enrolled_at', { ascending: false }),
|
|
307
|
+
]);
|
|
308
|
+
if (stepsError)
|
|
309
|
+
return txt(`Error: ${stepsError.message}`);
|
|
310
|
+
if (enrollmentsError)
|
|
311
|
+
return txt(`Error: ${enrollmentsError.message}`);
|
|
312
|
+
const contactIds = [...new Set((enrollments ?? []).map((row) => row.contact_id))];
|
|
313
|
+
let contactsById = new Map();
|
|
314
|
+
if (contactIds.length) {
|
|
315
|
+
const { data: contacts } = await db()
|
|
316
|
+
.from('subscribers')
|
|
317
|
+
.select('id, email, first_name')
|
|
318
|
+
.eq('project_id', projectId)
|
|
319
|
+
.in('id', contactIds);
|
|
320
|
+
contactsById = new Map((contacts ?? []).map((contact) => [contact.id, contact]));
|
|
112
321
|
}
|
|
113
322
|
return j({
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
323
|
+
campaign,
|
|
324
|
+
steps: steps ?? [],
|
|
325
|
+
advancement_enabled: false,
|
|
326
|
+
enrollments: (enrollments ?? []).map((row) => ({
|
|
327
|
+
...row,
|
|
328
|
+
subscriber: contactsById.get(row.contact_id) ?? null,
|
|
120
329
|
})),
|
|
121
330
|
});
|
|
122
331
|
});
|
|
123
332
|
// ── preview_automation ────────────────────────────────────────────────────
|
|
124
333
|
server.registerTool('preview_automation', {
|
|
334
|
+
title: 'Preview Automation',
|
|
335
|
+
annotations: {
|
|
336
|
+
readOnlyHint: true,
|
|
337
|
+
destructiveHint: false,
|
|
338
|
+
idempotentHint: true,
|
|
339
|
+
openWorldHint: false,
|
|
340
|
+
},
|
|
125
341
|
description: 'Preview what content would be fetched from a source right now, without generating a draft. Use this to inspect GitHub issues, RSS items, etc. before triggering a full run.',
|
|
126
342
|
inputSchema: {
|
|
127
343
|
source_id: z.string().uuid().describe('The content source UUID to preview'),
|
|
@@ -199,6 +415,13 @@ export function registerAutomationTools(server) {
|
|
|
199
415
|
});
|
|
200
416
|
// ── trigger_automation ────────────────────────────────────────────────────
|
|
201
417
|
server.registerTool('trigger_automation', {
|
|
418
|
+
title: 'Trigger Automation',
|
|
419
|
+
annotations: {
|
|
420
|
+
readOnlyHint: false,
|
|
421
|
+
destructiveHint: false,
|
|
422
|
+
idempotentHint: false,
|
|
423
|
+
openWorldHint: true,
|
|
424
|
+
},
|
|
202
425
|
description: 'Run a content source on demand right now — fetches new content, generates a certificate-prompted email draft, and adds it to the approval queue. Returns the draft ID. Use preview_automation first to confirm what content will be pulled.',
|
|
203
426
|
inputSchema: {
|
|
204
427
|
source_id: z.string().uuid().describe('The content source UUID to trigger'),
|