@sendinel/mcp-server 1.0.155 → 1.0.157
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;
|
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/build/tools/sites.js
CHANGED
|
@@ -193,6 +193,7 @@ export function registerSiteTools(server) {
|
|
|
193
193
|
name: z.string().optional(),
|
|
194
194
|
from_email: z.string().email().optional(),
|
|
195
195
|
from_name: z.string().optional(),
|
|
196
|
+
reply_to: z.string().email().nullable().optional().describe('Where replies to this site\'s email go; null clears it (replies then go to the From address). The previous value is returned as previous_reply_to.'),
|
|
196
197
|
active: z.boolean().optional(),
|
|
197
198
|
physical_address: z.string().trim().min(1).optional().describe('Physical mailing address; unchanged unless explicitly supplied.'),
|
|
198
199
|
unsub_link_enabled: z.boolean().optional().describe('Enable the unsubscribe link in the email footer.'),
|
|
@@ -203,7 +204,7 @@ export function registerSiteTools(server) {
|
|
|
203
204
|
unsub_brand_logo_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe page logo URL; null or blank clears it.'),
|
|
204
205
|
unsub_brand_color: z.string().nullable().optional().describe('Six-digit hex unsubscribe page color; null or blank clears it.'),
|
|
205
206
|
},
|
|
206
|
-
}, async ({ site_id, name, from_email, from_name, active, physical_address, ...unsubscribeInput }) => {
|
|
207
|
+
}, async ({ site_id, name, from_email, from_name, reply_to, active, physical_address, ...unsubscribeInput }) => {
|
|
207
208
|
const updates = {};
|
|
208
209
|
if (name)
|
|
209
210
|
updates.name = name;
|
|
@@ -211,6 +212,8 @@ export function registerSiteTools(server) {
|
|
|
211
212
|
updates.from_email = from_email;
|
|
212
213
|
if (from_name)
|
|
213
214
|
updates.from_name = from_name;
|
|
215
|
+
if (reply_to !== undefined)
|
|
216
|
+
updates.reply_to = reply_to;
|
|
214
217
|
if (active !== undefined)
|
|
215
218
|
updates.active = active;
|
|
216
219
|
if (physical_address !== undefined)
|
|
@@ -238,6 +241,16 @@ export function registerSiteTools(server) {
|
|
|
238
241
|
return j({ error: 'Site not found', code: 'NOT_FOUND' });
|
|
239
242
|
previousTemplate = current.unsub_footer_template;
|
|
240
243
|
}
|
|
244
|
+
// Read the old reply-to so the response carries the exact undo value.
|
|
245
|
+
let previousReplyTo;
|
|
246
|
+
if (reply_to !== undefined) {
|
|
247
|
+
const { data: current, error } = await sb.from('sites').select('reply_to').eq('id', site_id).eq('project_id', projectId).maybeSingle();
|
|
248
|
+
if (error)
|
|
249
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
250
|
+
if (!current)
|
|
251
|
+
return j({ error: 'Site not found', code: 'NOT_FOUND' });
|
|
252
|
+
previousReplyTo = current.reply_to ?? null;
|
|
253
|
+
}
|
|
241
254
|
updates.updated_at = new Date().toISOString();
|
|
242
255
|
let query = sb
|
|
243
256
|
.from('sites')
|
|
@@ -259,6 +272,7 @@ export function registerSiteTools(server) {
|
|
|
259
272
|
});
|
|
260
273
|
return j({
|
|
261
274
|
...data,
|
|
275
|
+
...(previousReplyTo !== undefined ? { previous_reply_to: previousReplyTo } : {}),
|
|
262
276
|
...(previousTemplate !== undefined && previousTemplate !== data.unsub_footer_template
|
|
263
277
|
? { previous_unsub_footer_template: previousTemplate } : {}),
|
|
264
278
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.157",
|
|
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",
|