@sendinel/mcp-server 1.0.154 → 1.0.156
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.
|
@@ -97,14 +97,9 @@ type ToolResult = {
|
|
|
97
97
|
text: string;
|
|
98
98
|
}>;
|
|
99
99
|
};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
* the handler; the follow-up call with an approved approval_id runs the handler on those same
|
|
104
|
-
* arguments. getApprovalTier() decides when no approval is needed (autonomous mode, special
|
|
105
|
-
* cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
|
|
106
|
-
* Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
|
|
107
|
-
*/
|
|
100
|
+
export declare function markNotExecuted<R extends object>(result: R): R;
|
|
101
|
+
/** HTTP statuses that mean the request was refused before it changed anything. */
|
|
102
|
+
export declare function isPreExecutionStatus(status: number | undefined): boolean;
|
|
108
103
|
export declare function withApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R, options?: {
|
|
109
104
|
display?: (args: A) => Promise<ApprovalDisplay> | ApprovalDisplay;
|
|
110
105
|
registeredRequestOnly?: boolean;
|
|
@@ -472,7 +472,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
472
472
|
approved: true,
|
|
473
473
|
status: 'approved',
|
|
474
474
|
decided_by: claimed.decided_by,
|
|
475
|
-
...(options.action
|
|
475
|
+
...(options.action && ['send_test_email', 'identify_subscriber', 'emit_contact_event'].includes(options.action) ? { consumed_at: consumedAt } : {}),
|
|
476
476
|
...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
|
|
477
477
|
};
|
|
478
478
|
}
|
|
@@ -487,6 +487,19 @@ function toolText(value) {
|
|
|
487
487
|
* cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
|
|
488
488
|
* Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
|
|
489
489
|
*/
|
|
490
|
+
/**
|
|
491
|
+
* Results a handler marks as "definitively did nothing" (refused or failed before any write or send).
|
|
492
|
+
* withApproval releases the consumed approval for these, so a failed call does not use up the pass.
|
|
493
|
+
*/
|
|
494
|
+
const notExecutedResults = new WeakSet();
|
|
495
|
+
export function markNotExecuted(result) {
|
|
496
|
+
notExecutedResults.add(result);
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
499
|
+
/** HTTP statuses that mean the request was refused before it changed anything. */
|
|
500
|
+
export function isPreExecutionStatus(status) {
|
|
501
|
+
return status !== undefined && [0, 400, 401, 403, 404, 422, 429, 501].includes(status);
|
|
502
|
+
}
|
|
490
503
|
export function withApproval(action, handler, options = {}) {
|
|
491
504
|
return async (args, extra) => {
|
|
492
505
|
const { approval_id, ...params } = args;
|
|
@@ -516,14 +529,17 @@ export function withApproval(action, handler, options = {}) {
|
|
|
516
529
|
const result = await handler(params, extra);
|
|
517
530
|
// Keep the atomic claim across the outbound hop. Only a definitive pre-acceptance
|
|
518
531
|
// failure may release it; transport errors and unknown outcomes can already have sent.
|
|
519
|
-
|
|
532
|
+
const releasable = action === 'send_test_email' ? testSendNotAccepted(result) : notExecutedResults.has(result);
|
|
533
|
+
if (approval_id && consumedAt && releasable) {
|
|
520
534
|
const { data: restored, error } = await approvalStore().from('action_approvals')
|
|
521
535
|
.update({ status: 'approved', used_at: null })
|
|
522
|
-
.eq('id', approval_id).eq('project_id', getProjectId()).eq('action',
|
|
536
|
+
.eq('id', approval_id).eq('project_id', getProjectId()).eq('action', action)
|
|
523
537
|
.eq('status', 'used').eq('used_at', consumedAt)
|
|
524
538
|
.select('id').maybeSingle();
|
|
525
539
|
if (error || !restored) {
|
|
526
|
-
console.error(
|
|
540
|
+
console.error(action === 'send_test_email'
|
|
541
|
+
? 'Test-send approval could not be restored after a pre-acceptance failure.'
|
|
542
|
+
: `Approval for ${action} could not be restored after a pre-execution failure.`);
|
|
527
543
|
}
|
|
528
544
|
}
|
|
529
545
|
return result;
|
|
@@ -10,9 +10,9 @@ import { TESTER_SITE_FIELDS } from './tester-policy.js';
|
|
|
10
10
|
const text = (message) => ({ content: [{ type: 'text', text: message }] });
|
|
11
11
|
/** Only installed for an authenticated tester connection, never selected by tool arguments. */
|
|
12
12
|
export function testerRequestTool(action, config, handler) {
|
|
13
|
-
const original = z.object(config.inputSchema);
|
|
13
|
+
const original = config.inputSchema instanceof z.ZodObject ? config.inputSchema : z.object(config.inputSchema);
|
|
14
14
|
// Wrapping before optional prevents Zod defaults from populating an approval-id-only retry.
|
|
15
|
-
const inputSchema = Object.fromEntries(Object.entries(
|
|
15
|
+
const inputSchema = Object.fromEntries(Object.entries(original.shape).map(([key, schema]) => [key, z.preprocess(value => value, schema).optional()]));
|
|
16
16
|
inputSchema.approval_id = z.string().uuid().optional();
|
|
17
17
|
return {
|
|
18
18
|
config: { ...config, inputSchema },
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateStepMultiEventRules, stepMultiEventRulesView } from "@sendinel/domain";
|
|
1
|
+
import { validateStepMultiEventRules, stepMultiEventRulesView, validateStepAbsenceRule, stepAbsenceRuleView } from "@sendinel/domain";
|
|
2
2
|
import { readCampaignMetricGroups, SENT_STATUSES } from "@sendinel/domain";
|
|
3
3
|
import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
4
4
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
@@ -47,6 +47,12 @@ const multiEventFields = {
|
|
|
47
47
|
send_only_if_events: z.array(z.string().trim().min(1)).min(1).max(10).nullable().optional().describe('Required events at send time, ALL by default; null clears. ANDed with legacy gates.'),
|
|
48
48
|
send_only_if_events_mode: z.enum(['all', 'any']).nullable().optional().describe('Required-event list mode; omitted/null defaults to all on new steps. Omission preserves the mode on updates.'),
|
|
49
49
|
};
|
|
50
|
+
const absenceFields = {
|
|
51
|
+
send_only_if_no_event: z.string().trim().min(1).nullable().optional().describe('Send only if this event has NOT occurred in the last send_only_if_no_event_days days at send time. Supply both fields together; two nulls clear.'),
|
|
52
|
+
send_only_if_no_event_days: z.number().int().min(1).max(365).nullable().optional().describe('Rolling lookback in days (1–365), independent of enrollment/cycle bounds. Supply with send_only_if_no_event; two nulls clear.'),
|
|
53
|
+
};
|
|
54
|
+
const absencePairValid = (input) => validateStepAbsenceRule(input).ok;
|
|
55
|
+
const absencePairError = 'send_only_if_no_event and send_only_if_no_event_days must both be set or both null';
|
|
50
56
|
function stripHtml(value) {
|
|
51
57
|
return value
|
|
52
58
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
@@ -884,7 +890,7 @@ export function registerCampaignTools(server) {
|
|
|
884
890
|
openWorldHint: false,
|
|
885
891
|
},
|
|
886
892
|
description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
|
|
887
|
-
inputSchema: {
|
|
893
|
+
inputSchema: z.object({
|
|
888
894
|
campaign_id: z.string().uuid(),
|
|
889
895
|
step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
|
|
890
896
|
subject: z.string().min(1),
|
|
@@ -899,11 +905,15 @@ export function registerCampaignTools(server) {
|
|
|
899
905
|
skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
|
|
900
906
|
send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
|
|
901
907
|
...multiEventFields,
|
|
908
|
+
...absenceFields,
|
|
902
909
|
skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
|
|
903
910
|
send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
|
|
904
|
-
},
|
|
905
|
-
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property }) => {
|
|
911
|
+
}).refine(absencePairValid, { message: absencePairError }),
|
|
912
|
+
}, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property }) => {
|
|
906
913
|
try {
|
|
914
|
+
const absence = validateStepAbsenceRule({ send_only_if_no_event, send_only_if_no_event_days });
|
|
915
|
+
if (!absence.ok)
|
|
916
|
+
return j({ error: absence.error, code: 'INVALID_INPUT' });
|
|
907
917
|
const events = validateStepMultiEventRules({ skip_if_events, send_only_if_events, send_only_if_events_mode });
|
|
908
918
|
if (!events.ok)
|
|
909
919
|
return j({ error: events.error, code: 'INVALID_INPUT' });
|
|
@@ -943,7 +953,7 @@ export function registerCampaignTools(server) {
|
|
|
943
953
|
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
944
954
|
const { data, error } = await db()
|
|
945
955
|
.from('campaign_steps')
|
|
946
|
-
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...properties.rules })
|
|
956
|
+
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...absence.rules, ...properties.rules })
|
|
947
957
|
.select()
|
|
948
958
|
.single();
|
|
949
959
|
if (error)
|
|
@@ -3125,6 +3135,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3125
3135
|
trigger_config: z.record(z.string(), z.unknown()).nullable().optional().describe('Event/tag lists with optional where filter, or date_field/contact_event configuration.'),
|
|
3126
3136
|
steps: z.array(z.object({
|
|
3127
3137
|
...multiEventFields,
|
|
3138
|
+
...absenceFields,
|
|
3128
3139
|
skip_if_event: z.string().trim().min(1).nullable().optional(),
|
|
3129
3140
|
send_only_if_event: z.string().trim().min(1).nullable().optional(),
|
|
3130
3141
|
subject: z.string().min(1).describe('Email subject line'),
|
|
@@ -3133,7 +3144,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3133
3144
|
blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
|
|
3134
3145
|
delay_days: z.number().int().min(0).optional().default(0),
|
|
3135
3146
|
delay_hours: z.number().int().min(0).max(23).optional().default(0),
|
|
3136
|
-
})).min(1).describe('Email steps with pre-built content'),
|
|
3147
|
+
}).refine(absencePairValid, { message: absencePairError })).min(1).describe('Email steps with pre-built content'),
|
|
3137
3148
|
segment_id: z.string().uuid().optional().describe('Segment to auto-enroll. Optional for triggered campaigns; future trigger matches enroll contacts.'),
|
|
3138
3149
|
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.'),
|
|
3139
3150
|
activate: z.boolean().optional().default(false).describe('Set to true to activate immediately after creation. Requires segment_id or a configured triggered campaign.'),
|
|
@@ -3142,10 +3153,13 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3142
3153
|
},
|
|
3143
3154
|
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, unsub_footer_template, reentry_mode, period_key }) => {
|
|
3144
3155
|
for (const step of steps) {
|
|
3156
|
+
const absence = validateStepAbsenceRule(step);
|
|
3157
|
+
if (!absence.ok)
|
|
3158
|
+
return j({ error: absence.error, code: 'INVALID_INPUT' });
|
|
3145
3159
|
const events = validateStepMultiEventRules(step);
|
|
3146
3160
|
if (!events.ok)
|
|
3147
3161
|
return j({ error: events.error, code: 'INVALID_INPUT' });
|
|
3148
|
-
Object.assign(step, events.rules);
|
|
3162
|
+
Object.assign(step, events.rules, absence.rules);
|
|
3149
3163
|
}
|
|
3150
3164
|
const footerTemplate = unsub_footer_template?.trim() ?? null;
|
|
3151
3165
|
if (footerTemplate !== null) {
|
|
@@ -3226,6 +3240,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3226
3240
|
skip_if_event: s.skip_if_event ?? null,
|
|
3227
3241
|
send_only_if_event: s.send_only_if_event ?? null,
|
|
3228
3242
|
...stepMultiEventRulesView(s),
|
|
3243
|
+
...stepAbsenceRuleView(s),
|
|
3229
3244
|
})),
|
|
3230
3245
|
segment_id: segment_id ?? null,
|
|
3231
3246
|
sends_per_hour: sends_per_hour ?? null,
|
|
@@ -3267,6 +3282,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3267
3282
|
skip_if_event: s.skip_if_event ?? null,
|
|
3268
3283
|
send_only_if_event: s.send_only_if_event ?? null,
|
|
3269
3284
|
...stepMultiEventRulesView(s),
|
|
3285
|
+
...stepAbsenceRuleView(s),
|
|
3270
3286
|
}));
|
|
3271
3287
|
const { error: stepErr } = await db()
|
|
3272
3288
|
.from('campaign_steps')
|
|
@@ -3364,7 +3380,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3364
3380
|
return j({ error: (campaignErr ? dbToolError(campaignErr).error : undefined) ?? 'Campaign not found', code: 'NOT_FOUND' });
|
|
3365
3381
|
const { data, error } = await db()
|
|
3366
3382
|
.from('campaign_steps')
|
|
3367
|
-
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, created_at, updated_at')
|
|
3383
|
+
.select('id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property, created_at, updated_at')
|
|
3368
3384
|
.eq('campaign_id', campaign_id)
|
|
3369
3385
|
.order('step_order', { ascending: true });
|
|
3370
3386
|
if (error)
|
|
@@ -3380,7 +3396,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3380
3396
|
openWorldHint: false,
|
|
3381
3397
|
},
|
|
3382
3398
|
description: 'Update a campaign step — change subject, body, delay, or step_order.',
|
|
3383
|
-
inputSchema: {
|
|
3399
|
+
inputSchema: z.object({
|
|
3384
3400
|
step_id: z.string().uuid(),
|
|
3385
3401
|
subject: z.string().optional(),
|
|
3386
3402
|
body_html: z.string().optional(),
|
|
@@ -3393,6 +3409,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3393
3409
|
skip_if_property: propertyConditions.describe('Skip when ALL 1–10 conditions match current custom_properties. Missing/null keys never match. null clears.'),
|
|
3394
3410
|
send_only_if_property: propertyConditions.describe('Send only when ALL 1–10 conditions match current custom_properties. Unknown/non-match skips. null clears.'),
|
|
3395
3411
|
...multiEventFields,
|
|
3412
|
+
...absenceFields,
|
|
3396
3413
|
skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
|
|
3397
3414
|
send_only_if_event: z.string().trim().min(1).nullable().optional().describe('Event required for this step to send; null clears the rule.'),
|
|
3398
3415
|
link_actions: z.array(z.discriminatedUnion('action', [
|
|
@@ -3405,10 +3422,13 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3405
3422
|
continue_url: z.string().url().max(500).optional().describe('Optional http(s) link shown on the thank-you page.'),
|
|
3406
3423
|
}),
|
|
3407
3424
|
])).max(10).optional().describe('Link actions for this step. set_property links show a Confirm page; only the Confirm click writes the property (last click wins). Replaces the whole list.'),
|
|
3408
|
-
},
|
|
3425
|
+
}).refine(absencePairValid, { message: absencePairError }),
|
|
3409
3426
|
}, async ({ step_id, ...updates }) => {
|
|
3410
3427
|
const projectId = getProjectId();
|
|
3411
3428
|
const { survey_id, ...stepUpdates } = updates;
|
|
3429
|
+
const absence = validateStepAbsenceRule(stepUpdates);
|
|
3430
|
+
if (!absence.ok)
|
|
3431
|
+
return j({ error: absence.error, code: 'INVALID_INPUT' });
|
|
3412
3432
|
const events = validateStepMultiEventRules(stepUpdates);
|
|
3413
3433
|
if (!events.ok)
|
|
3414
3434
|
return j({ error: events.error, code: 'INVALID_INPUT' });
|
|
@@ -3441,6 +3461,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3441
3461
|
const renderedUpdates = {
|
|
3442
3462
|
...stepUpdates,
|
|
3443
3463
|
...events.rules,
|
|
3464
|
+
...absence.rules,
|
|
3444
3465
|
...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
|
|
3445
3466
|
...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
|
|
3446
3467
|
...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
|
|
@@ -3452,7 +3473,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3452
3473
|
.from('campaign_steps')
|
|
3453
3474
|
.update({ ...renderedUpdates, updated_at: new Date().toISOString() })
|
|
3454
3475
|
.eq('id', step_id)
|
|
3455
|
-
.select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, skip_if_property, send_only_if_property, updated_at')
|
|
3476
|
+
.select('id, step_order, subject, delay_days, delay_hours, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property, updated_at')
|
|
3456
3477
|
.single();
|
|
3457
3478
|
if (error)
|
|
3458
3479
|
return j(dbToolError(error));
|
|
@@ -3830,7 +3851,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3830
3851
|
total_sent: periods?.totals?.sent ?? sentCount ?? 0,
|
|
3831
3852
|
...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
|
|
3832
3853
|
...campaignPeriodFields(periods, period),
|
|
3833
|
-
steps: (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
|
|
3854
|
+
steps: (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), ...stepAbsenceRuleView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
|
|
3834
3855
|
active_enrollments: enrollCount ?? 0,
|
|
3835
3856
|
send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
|
|
3836
3857
|
queued_email_count: queuedCount,
|
package/build/tools/contacts.js
CHANGED
|
@@ -11,7 +11,7 @@ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
|
|
|
11
11
|
import { resolveSegmentContactIds } from '../lib/send-audience.js';
|
|
12
12
|
import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
|
|
13
13
|
import { logConsent, logAudit } from '../audit.js';
|
|
14
|
-
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
14
|
+
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval, markNotExecuted, isPreExecutionStatus } from '../lib/action-approvals.js';
|
|
15
15
|
import { decrypt } from '../crypto.js';
|
|
16
16
|
import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
|
|
17
17
|
import { diffCustomProperties } from '../lib/custom-properties-diff.js';
|
|
@@ -612,8 +612,19 @@ export function registerContactTools(server) {
|
|
|
612
612
|
event: z.string().trim().min(1),
|
|
613
613
|
data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
|
614
614
|
idempotency_key: z.string().min(1).optional().describe('Sent as Idempotency-Key. Reuse with the same event payload when retrying.'),
|
|
615
|
+
consent: z.object({
|
|
616
|
+
site_id: z.string().uuid(),
|
|
617
|
+
basis: z.enum(['opt_in', 'existing_customer']),
|
|
618
|
+
source: z.string().trim().min(1).max(100),
|
|
619
|
+
collected_at: z.string().optional(),
|
|
620
|
+
}).optional().describe('Optional consent record for an unknown email (see docs/api-events-and-identify.md). Omit for contacts that already exist.'),
|
|
621
|
+
traits: z.object({
|
|
622
|
+
first_name: z.string().optional(),
|
|
623
|
+
last_name: z.string().optional(),
|
|
624
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
625
|
+
}).optional().describe('Optional contact traits applied with the event (see docs/api-events-and-identify.md).'),
|
|
615
626
|
},
|
|
616
|
-
}, withApproval('emit_contact_event', async ({ email, event, data, idempotency_key }) => {
|
|
627
|
+
}, withApproval('emit_contact_event', async ({ email, event, data, idempotency_key, consent, traits }) => {
|
|
617
628
|
// Never forward raw transport, database or provider errors to the MCP caller.
|
|
618
629
|
// Only known public codes survive; even a returned message may contain internal text.
|
|
619
630
|
const publicCodes = new Set([
|
|
@@ -623,7 +634,12 @@ export function registerContactTools(server) {
|
|
|
623
634
|
try {
|
|
624
635
|
const result = await callSendinelV1('/api/v1/events', {
|
|
625
636
|
method: 'POST',
|
|
626
|
-
body: {
|
|
637
|
+
body: {
|
|
638
|
+
email, event,
|
|
639
|
+
...(data !== undefined ? { data } : {}),
|
|
640
|
+
...(consent !== undefined ? { consent } : {}),
|
|
641
|
+
...(traits !== undefined ? { traits } : {}),
|
|
642
|
+
},
|
|
627
643
|
...(idempotency_key ? { idempotencyKey: idempotency_key } : {}),
|
|
628
644
|
});
|
|
629
645
|
if (result.ok)
|
|
@@ -631,7 +647,8 @@ export function registerContactTools(server) {
|
|
|
631
647
|
const payload = result.error && typeof result.error === 'object' && 'error' in result.error
|
|
632
648
|
&& result.error.error && typeof result.error.error === 'object' ? result.error.error : result.error;
|
|
633
649
|
const code = payload && typeof payload === 'object' && 'code' in payload ? payload.code : undefined;
|
|
634
|
-
|
|
650
|
+
const failed = { ...j({ code: typeof code === 'string' && publicCodes.has(code) ? code : 'event_intake_failed', error: 'Could not emit contact event' }), isError: true };
|
|
651
|
+
return isPreExecutionStatus(result.status) ? markNotExecuted(failed) : failed;
|
|
635
652
|
}
|
|
636
653
|
catch {
|
|
637
654
|
return { ...j({ code: 'event_intake_failed', error: 'Could not emit contact event' }), isError: true };
|
|
@@ -654,7 +671,10 @@ export function registerContactTools(server) {
|
|
|
654
671
|
},
|
|
655
672
|
}, withApproval('identify_subscriber', async (params) => {
|
|
656
673
|
const result = await callSendinelV1('/api/v1/identify', { method: 'POST', body: params });
|
|
657
|
-
|
|
674
|
+
if (result.ok)
|
|
675
|
+
return j(result.data);
|
|
676
|
+
const failed = err(JSON.stringify(result.error));
|
|
677
|
+
return isPreExecutionStatus(result.status) ? markNotExecuted(failed) : failed;
|
|
658
678
|
}));
|
|
659
679
|
server.registerTool('get_subscriber_timeline', {
|
|
660
680
|
title: 'Get Subscriber Timeline',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.156",
|
|
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",
|