@sendinel/mcp-server 1.0.104 → 1.0.105
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/lib/action-approvals.d.ts +3 -0
- package/build/lib/action-approvals.js +63 -16
- package/build/lib/approval-capabilities.d.ts +17 -0
- package/build/lib/approval-capabilities.js +40 -0
- package/build/lib/approval-human-callback.d.ts +19 -0
- package/build/lib/approval-human-callback.js +41 -0
- package/build/lib/approval-snapshot.d.ts +5 -0
- package/build/lib/approval-snapshot.js +106 -0
- package/build/server.js +3 -0
- package/package.json +1 -1
|
@@ -46,9 +46,12 @@ export interface ApprovalResult {
|
|
|
46
46
|
approve_via: string;
|
|
47
47
|
/** One-tap approval page; works on a phone and needs no project switching (#3661). */
|
|
48
48
|
approve_url: string;
|
|
49
|
+
preview_text: string;
|
|
49
50
|
message: string;
|
|
50
51
|
}
|
|
51
52
|
export declare function approvalPageUrl(approvalId: string): string;
|
|
53
|
+
/** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
|
|
54
|
+
export declare function approvalPayloadHash(action: string, params: unknown): string;
|
|
52
55
|
/**
|
|
53
56
|
* Request human approval for an action. Creates a pending approval token
|
|
54
57
|
* and returns instructions for the agent to wait.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
2
3
|
import { db, requestContext } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
5
|
+
import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
|
|
4
6
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
5
7
|
/**
|
|
6
8
|
* Action approval tiers:
|
|
@@ -303,11 +305,15 @@ function normalizeApprovalParams(value) {
|
|
|
303
305
|
if (value && typeof value === 'object') {
|
|
304
306
|
return Object.fromEntries(Object.entries(value)
|
|
305
307
|
.filter(([, entryValue]) => entryValue !== undefined)
|
|
306
|
-
.sort(([a], [b]) => a
|
|
308
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
307
309
|
.map(([key, entryValue]) => [key, normalizeApprovalParams(entryValue)]));
|
|
308
310
|
}
|
|
309
311
|
return value;
|
|
310
312
|
}
|
|
313
|
+
/** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
|
|
314
|
+
export function approvalPayloadHash(action, params) {
|
|
315
|
+
return createHash('sha256').update(JSON.stringify(normalizeApprovalParams({ action, params }))).digest('hex');
|
|
316
|
+
}
|
|
311
317
|
function approvalParamsEqual(expected, actual) {
|
|
312
318
|
return JSON.stringify(normalizeApprovalParams(expected)) === JSON.stringify(normalizeApprovalParams(actual));
|
|
313
319
|
}
|
|
@@ -318,9 +324,18 @@ function approvalParamsEqual(expected, actual) {
|
|
|
318
324
|
export async function requestApproval(action, params, apiKeyId, channelContext, display) {
|
|
319
325
|
const tier = getApprovalTier(action, params);
|
|
320
326
|
const summary = buildApprovalSummary(action, params, display);
|
|
321
|
-
const expiresAt = new Date(Date.now() +
|
|
327
|
+
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
322
328
|
const { channel, verified } = channelContext ?? currentChannelContext();
|
|
323
329
|
const actor = requestContext();
|
|
330
|
+
// Ignore any model-supplied reserved field; freeze facts read by the server.
|
|
331
|
+
const cleanParams = withoutApprovalSnapshot(params);
|
|
332
|
+
const snapshot = await captureApprovalSnapshot(action, cleanParams, getProjectId());
|
|
333
|
+
const payload = cleanParams.payload;
|
|
334
|
+
if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
|
|
335
|
+
!approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
|
|
336
|
+
throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
|
|
337
|
+
}
|
|
338
|
+
const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
|
|
324
339
|
const { data, error } = await db()
|
|
325
340
|
.from('action_approvals')
|
|
326
341
|
.insert({
|
|
@@ -330,7 +345,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
330
345
|
agent_label: actor?.agentLabel ?? null,
|
|
331
346
|
action,
|
|
332
347
|
tier,
|
|
333
|
-
params,
|
|
348
|
+
params: frozenParams,
|
|
349
|
+
payload_hash: approvalPayloadHash(action, frozenParams),
|
|
334
350
|
summary,
|
|
335
351
|
expires_at: expiresAt,
|
|
336
352
|
channel,
|
|
@@ -355,7 +371,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
355
371
|
expires_at: expiresAt,
|
|
356
372
|
approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
|
|
357
373
|
approve_url: approvalPageUrl(data.id),
|
|
358
|
-
|
|
374
|
+
preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
|
|
375
|
+
message: `This action requires human approval. A notification has been sent to your dashboard. Approval ID: ${data.id}. Check status with: poll this approval_id, or wait for the webhook callback (event: approval.decided). Token expires in 24 hours.`,
|
|
359
376
|
};
|
|
360
377
|
}
|
|
361
378
|
/**
|
|
@@ -365,7 +382,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
365
382
|
export async function checkApproval(approvalId, options = {}) {
|
|
366
383
|
const { data, error } = await db()
|
|
367
384
|
.from('action_approvals')
|
|
368
|
-
.select('action, status, decided_by, decided_via, expires_at, used_at, params')
|
|
385
|
+
.select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
|
|
369
386
|
.eq('id', approvalId)
|
|
370
387
|
.eq('project_id', getProjectId())
|
|
371
388
|
.maybeSingle();
|
|
@@ -374,17 +391,29 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
374
391
|
if (options.action && data.action !== options.action) {
|
|
375
392
|
return { approved: false, status: 'action_mismatch' };
|
|
376
393
|
}
|
|
377
|
-
if (options.
|
|
394
|
+
if (options.consume && data.payload_hash) {
|
|
395
|
+
if (!options.action || !options.params)
|
|
396
|
+
return { approved: false, status: 'payload_required' };
|
|
397
|
+
if (approvalPayloadHash(options.action, { ...withoutApprovalSnapshot(options.params), ...(data.params?.__approval_snapshot ? { __approval_snapshot: data.params.__approval_snapshot } : {}) }) !== data.payload_hash ||
|
|
398
|
+
approvalPayloadHash(data.action, data.params) !== data.payload_hash) {
|
|
399
|
+
return { approved: false, status: 'payload_changed_after_approval' };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (options.params && !approvalParamsEqual(withoutApprovalSnapshot(options.params), withoutApprovalSnapshot(data.params))) {
|
|
378
403
|
return { approved: false, status: 'params_mismatch' };
|
|
379
404
|
}
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
.eq('id', approvalId);
|
|
405
|
+
// Approved tokens expire too. Invalid timestamps fail closed. Older rows may have a
|
|
406
|
+
// shorter lifetime; no row can authorize an apply more than 24 hours after its request.
|
|
407
|
+
const requested = data.requested_at == null ? null : Date.parse(data.requested_at);
|
|
408
|
+
const expiry = Math.min(Date.parse(data.expires_at), requested == null ? Infinity : requested + 24 * 60 * 60 * 1000);
|
|
409
|
+
if (!Number.isFinite(expiry) || (requested != null && !Number.isFinite(requested)) || expiry <= Date.now()) {
|
|
386
410
|
return { approved: false, status: 'expired' };
|
|
387
411
|
}
|
|
412
|
+
if (data.used_at || data.status === 'used')
|
|
413
|
+
return { approved: false, status: 'consumed' };
|
|
414
|
+
if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)) {
|
|
415
|
+
return { approved: false, status: 'human_decision_required' };
|
|
416
|
+
}
|
|
388
417
|
if (data.status !== 'approved' || !options.consume) {
|
|
389
418
|
if (data.used_at)
|
|
390
419
|
return { approved: false, status: 'consumed' };
|
|
@@ -394,15 +423,33 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
394
423
|
decided_by: data.decided_by,
|
|
395
424
|
};
|
|
396
425
|
}
|
|
397
|
-
|
|
426
|
+
if (data.params?.__approval_snapshot) {
|
|
427
|
+
try {
|
|
428
|
+
const current = await captureApprovalSnapshot(data.action, withoutApprovalSnapshot(data.params), getProjectId());
|
|
429
|
+
if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
|
|
430
|
+
return { approved: false, status: 'target_changed_after_approval' };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return { approved: false, status: 'target_revalidation_failed' };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (expiry <= Date.now())
|
|
438
|
+
return { approved: false, status: 'expired' };
|
|
439
|
+
const claimQuery = db()
|
|
398
440
|
.from('action_approvals')
|
|
399
441
|
.update({ status: 'used', used_at: new Date().toISOString() })
|
|
400
442
|
.eq('id', approvalId)
|
|
401
443
|
.eq('project_id', getProjectId())
|
|
402
444
|
.eq('status', 'approved')
|
|
403
445
|
.is('used_at', null)
|
|
404
|
-
.
|
|
405
|
-
.
|
|
446
|
+
.eq('expires_at', data.expires_at)
|
|
447
|
+
.gt('expires_at', new Date().toISOString())
|
|
448
|
+
.eq('decided_via', data.decided_via)
|
|
449
|
+
.eq('params', JSON.stringify(data.params))
|
|
450
|
+
.gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
|
451
|
+
const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
|
|
452
|
+
const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
|
|
406
453
|
if (claimError || !claimed) {
|
|
407
454
|
return { approved: false, status: 'consumed' };
|
|
408
455
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const MCP_APP_EXTENSION = "io.modelcontextprotocol/ui";
|
|
2
|
+
export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
|
|
3
|
+
export type ApprovalClientCapabilities = Readonly<{
|
|
4
|
+
elicitation_form: boolean;
|
|
5
|
+
elicitation_url: boolean;
|
|
6
|
+
mcp_app: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
export type ApprovalChannelAvailable = 'mcp_app' | 'mcp_elicitation_form' | 'mcp_elicitation_url' | 'plain_text';
|
|
9
|
+
/** Reads only declared initialize capabilities. No user agent or client-name guessing. */
|
|
10
|
+
export declare function probeApprovalCapabilities(declared: unknown): ApprovalClientCapabilities;
|
|
11
|
+
export declare function rememberApprovalCapabilities(session: object, declared: unknown): ApprovalClientCapabilities;
|
|
12
|
+
export declare function approval_channel_available(session: object): ApprovalChannelAvailable;
|
|
13
|
+
/** Each SDK Server is a session boundary, including stateless HTTP request instances. */
|
|
14
|
+
export declare function installApprovalCapabilityProbe(session: {
|
|
15
|
+
oninitialized?: () => void;
|
|
16
|
+
getClientCapabilities(): unknown;
|
|
17
|
+
}): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
|
|
2
|
+
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
|
|
3
|
+
const sessions = new WeakMap();
|
|
4
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
/** Reads only declared initialize capabilities. No user agent or client-name guessing. */
|
|
6
|
+
export function probeApprovalCapabilities(declared) {
|
|
7
|
+
const capabilities = object(declared);
|
|
8
|
+
const elicitation = object(capabilities?.elicitation);
|
|
9
|
+
const extensions = object(capabilities?.extensions);
|
|
10
|
+
const app = object(extensions?.[MCP_APP_EXTENSION]);
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
// The MCP spec defines elicitation:{} as form support for older clients.
|
|
13
|
+
elicitation_form: !!elicitation && (Object.keys(elicitation).length === 0 || !!object(elicitation.form)),
|
|
14
|
+
elicitation_url: !!object(elicitation?.url),
|
|
15
|
+
mcp_app: !!app && Array.isArray(app.mimeTypes) && app.mimeTypes.includes(MCP_APP_MIME_TYPE),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function rememberApprovalCapabilities(session, declared) {
|
|
19
|
+
const capabilities = probeApprovalCapabilities(declared);
|
|
20
|
+
sessions.set(session, capabilities);
|
|
21
|
+
return capabilities;
|
|
22
|
+
}
|
|
23
|
+
export function approval_channel_available(session) {
|
|
24
|
+
const capabilities = sessions.get(session);
|
|
25
|
+
if (capabilities?.mcp_app)
|
|
26
|
+
return 'mcp_app';
|
|
27
|
+
if (capabilities?.elicitation_form)
|
|
28
|
+
return 'mcp_elicitation_form';
|
|
29
|
+
if (capabilities?.elicitation_url)
|
|
30
|
+
return 'mcp_elicitation_url';
|
|
31
|
+
return 'plain_text';
|
|
32
|
+
}
|
|
33
|
+
/** Each SDK Server is a session boundary, including stateless HTTP request instances. */
|
|
34
|
+
export function installApprovalCapabilityProbe(session) {
|
|
35
|
+
const previous = session.oninitialized;
|
|
36
|
+
session.oninitialized = () => {
|
|
37
|
+
rememberApprovalCapabilities(session, session.getClientCapabilities());
|
|
38
|
+
previous?.call(session);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server integration only. The transport creates this closure for one authenticated human
|
|
3
|
+
* interaction and retains it outside the tool registry. Neither its id nor decision is a tool
|
|
4
|
+
* argument. Front ends must call complete only from the matching elicitation/app response.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createHumanApprovalCallback(binding: {
|
|
7
|
+
approvalId: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
connectionId: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
channel: 'mcp_elicitation' | 'mcp_app';
|
|
12
|
+
}): {
|
|
13
|
+
interactionId: `${string}-${string}-${string}-${string}-${string}`;
|
|
14
|
+
complete(response: {
|
|
15
|
+
interactionId: string;
|
|
16
|
+
connectionId: string;
|
|
17
|
+
decision: 'approve' | 'reject';
|
|
18
|
+
}): Promise<boolean>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
/**
|
|
4
|
+
* Server integration only. The transport creates this closure for one authenticated human
|
|
5
|
+
* interaction and retains it outside the tool registry. Neither its id nor decision is a tool
|
|
6
|
+
* argument. Front ends must call complete only from the matching elicitation/app response.
|
|
7
|
+
*/
|
|
8
|
+
export function createHumanApprovalCallback(binding) {
|
|
9
|
+
const interactionId = randomUUID();
|
|
10
|
+
let completed = false;
|
|
11
|
+
return {
|
|
12
|
+
interactionId,
|
|
13
|
+
async complete(response) {
|
|
14
|
+
if (completed || response.interactionId !== interactionId || response.connectionId !== binding.connectionId)
|
|
15
|
+
return false;
|
|
16
|
+
if (response.decision !== 'approve' && response.decision !== 'reject')
|
|
17
|
+
return false;
|
|
18
|
+
completed = true;
|
|
19
|
+
const source = db();
|
|
20
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
21
|
+
const now = new Date().toISOString();
|
|
22
|
+
const { data, error } = await client.from('action_approvals')
|
|
23
|
+
.update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
|
|
24
|
+
decided_by: binding.userId, decided_at: now })
|
|
25
|
+
.eq('id', binding.approvalId).eq('project_id', binding.projectId)
|
|
26
|
+
.eq('status', 'pending').is('used_at', null).gt('expires_at', now)
|
|
27
|
+
.select('id, payload_hash').maybeSingle();
|
|
28
|
+
if (error || !data)
|
|
29
|
+
return false;
|
|
30
|
+
const { error: auditError } = await client.from('audit_log').insert({
|
|
31
|
+
project_id: binding.projectId, actor_type: 'user', actor_id: binding.userId,
|
|
32
|
+
action: 'approval.submitted', resource_type: 'approval', resource_id: binding.approvalId,
|
|
33
|
+
details: { status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
|
|
34
|
+
connection_id: binding.connectionId, interaction_id: interactionId, payload_hash: data.payload_hash },
|
|
35
|
+
});
|
|
36
|
+
if (auditError)
|
|
37
|
+
throw new Error('Approval recorded, but decision audit failed');
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type Params = Record<string, any>;
|
|
2
|
+
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
3
|
+
export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
|
|
4
|
+
export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
4
|
+
const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
|
|
5
|
+
'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
|
|
6
|
+
'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
|
|
7
|
+
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
8
|
+
export async function captureApprovalSnapshot(action, params, projectId) {
|
|
9
|
+
const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
10
|
+
if (!TARGET_ACTIONS.has(action))
|
|
11
|
+
return snapshot;
|
|
12
|
+
const source = db();
|
|
13
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
14
|
+
const body = params.payload ?? params;
|
|
15
|
+
const ids = [...new Set((Array.isArray(params.campaign_ids) ? params.campaign_ids :
|
|
16
|
+
typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
|
|
17
|
+
if (ids.length) {
|
|
18
|
+
snapshot.campaigns = await readAllRows(client.from('campaigns')
|
|
19
|
+
.select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
|
|
20
|
+
.eq('project_id', projectId).in('id', ids));
|
|
21
|
+
if (snapshot.campaigns.length !== ids.length)
|
|
22
|
+
throw new Error('Approval target not found');
|
|
23
|
+
snapshot.steps = await readAllRows(client.from('campaign_steps')
|
|
24
|
+
.select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
|
|
25
|
+
.in('campaign_id', ids));
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const id of ids) {
|
|
28
|
+
const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', id);
|
|
29
|
+
if (result.error || result.count == null)
|
|
30
|
+
throw new Error('Approval audience count unavailable');
|
|
31
|
+
count += result.count;
|
|
32
|
+
}
|
|
33
|
+
snapshot.recipient_count = count;
|
|
34
|
+
}
|
|
35
|
+
if (typeof params.issue_id === 'string') {
|
|
36
|
+
const issue = await client.from('newsletter_issues').select('id, newsletter_id, subject, content, preheader, status, scheduled_at')
|
|
37
|
+
.eq('id', params.issue_id).eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
38
|
+
const newsletter = await client.from('newsletters').select('id, name, site_id, status, sender_name, sender_email')
|
|
39
|
+
.eq('id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
40
|
+
if (issue.error || !issue.data || newsletter.error || !newsletter.data)
|
|
41
|
+
throw new Error('Approval newsletter unavailable');
|
|
42
|
+
snapshot.issue = issue.data;
|
|
43
|
+
snapshot.newsletter = newsletter.data;
|
|
44
|
+
snapshot.subscriptions = await readAllRows(client.from('newsletter_subscriptions').select('id, subscriber_id')
|
|
45
|
+
.eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).eq('status', 'active').not('subscriber_id', 'is', null));
|
|
46
|
+
snapshot.subscriptions.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
47
|
+
snapshot.recipient_count = snapshot.subscriptions.length;
|
|
48
|
+
}
|
|
49
|
+
const siteIds = [...new Set([body.site_id, snapshot.newsletter?.site_id, ...snapshot.campaigns.map((c) => c.site_id)]
|
|
50
|
+
.filter((id) => typeof id === 'string' && id.length > 0))].sort();
|
|
51
|
+
for (const id of siteIds) {
|
|
52
|
+
const { data, error } = await client.from('sites')
|
|
53
|
+
.select('id, name, from_name, from_email, sending_domain, physical_address, unsub_link_enabled, unsub_footer_template')
|
|
54
|
+
.eq('id', id).eq('project_id', projectId).maybeSingle();
|
|
55
|
+
if (error || !data)
|
|
56
|
+
throw new Error('Approval sender/footer unavailable');
|
|
57
|
+
snapshot.sites.push(data);
|
|
58
|
+
}
|
|
59
|
+
async function readFrozenContacts(contacts) {
|
|
60
|
+
const found = [];
|
|
61
|
+
for (const chunk of chunkIds([...new Set(contacts.map(String))])) {
|
|
62
|
+
const values = await readAllRows(client.from('subscribers').select('id')
|
|
63
|
+
.eq('project_id', projectId).in('id', chunk));
|
|
64
|
+
found.push(...values.map(value => value.id));
|
|
65
|
+
}
|
|
66
|
+
if (found.length !== new Set(contacts).size)
|
|
67
|
+
throw new Error('Approval recipients changed or unavailable');
|
|
68
|
+
return found.sort();
|
|
69
|
+
}
|
|
70
|
+
if (typeof body.segment_id === 'string') {
|
|
71
|
+
const { data, error } = await client.from('segments').select('id, name, rules, site_id')
|
|
72
|
+
.eq('id', body.segment_id).eq('project_id', projectId).maybeSingle();
|
|
73
|
+
if (error || !data)
|
|
74
|
+
throw new Error('Approval segment unavailable');
|
|
75
|
+
snapshot.segment = data;
|
|
76
|
+
// launch_campaign executes a frozen list, so new segment matches cannot expand its audience.
|
|
77
|
+
// The saved segment definition is still compared; deleted original recipients invalidate it.
|
|
78
|
+
const rules = scopeSegmentRulesToSite(data.rules, data.site_id);
|
|
79
|
+
snapshot.contact_ids = action === 'launch_campaign' && Array.isArray(params.generated?.contact_ids)
|
|
80
|
+
? await readFrozenContacts(params.generated.contact_ids)
|
|
81
|
+
: typeof body.site_id === 'string'
|
|
82
|
+
? (await resolveSendAudience(projectId, body.site_id, rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort()
|
|
83
|
+
: (await resolveSegmentContactIds(projectId, rules)).sort();
|
|
84
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
85
|
+
}
|
|
86
|
+
else if (body.rules && typeof body.site_id === 'string') {
|
|
87
|
+
snapshot.contact_ids = (await resolveSendAudience(projectId, body.site_id, body.rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort();
|
|
88
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const contacts = body.contact_ids ?? params.generated?.contact_ids;
|
|
92
|
+
if (Array.isArray(contacts)) {
|
|
93
|
+
snapshot.contact_ids = await readFrozenContacts(contacts);
|
|
94
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
95
|
+
}
|
|
96
|
+
else if (typeof body.to === 'string')
|
|
97
|
+
snapshot.recipient_count = 1;
|
|
98
|
+
}
|
|
99
|
+
snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
100
|
+
snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
101
|
+
return snapshot;
|
|
102
|
+
}
|
|
103
|
+
export function withoutApprovalSnapshot(params) {
|
|
104
|
+
const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
|
|
105
|
+
return rest;
|
|
106
|
+
}
|
package/build/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
|
|
1
2
|
import { runWithToolContext } from './db.js';
|
|
2
3
|
import { isApprovalGatedAction } from './lib/action-approvals.js';
|
|
3
4
|
import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
|
|
@@ -377,6 +378,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
|
|
|
377
378
|
'On the hosted connection, any tool call (and invoke_tool) also accepts an optional project_id to act on one project for that call only; it is checked each time, and approval-gated tools require it when the connection was switched to another project. In gateway mode, find tools with search_tools (plain words work, for example "send email" or "last campaign"), check arguments with describe_tool, then call invoke_tool.',
|
|
378
379
|
].join(' ');
|
|
379
380
|
export function registerAllTools(server, options) {
|
|
381
|
+
if (server.server)
|
|
382
|
+
installApprovalCapabilityProbe(server.server);
|
|
380
383
|
const rawScopes = options?.scopes ?? [];
|
|
381
384
|
if (rawScopes.includes('gateway')) {
|
|
382
385
|
const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.105",
|
|
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",
|