@sendinel/mcp-server 1.0.117 → 1.0.119
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 +18 -3
- package/build/lib/action-approvals.js +38 -95
- package/build/lib/admin-tools.d.ts +3 -0
- package/build/lib/admin-tools.js +31 -0
- package/build/lib/approval-actions.d.ts +4 -0
- package/build/lib/approval-actions.js +83 -0
- package/build/lib/approval-app.js +5 -2
- package/build/lib/approval-elicitation.js +3 -0
- package/build/lib/approval-human-callback.js +7 -1
- package/build/lib/approval-snapshot.js +9 -0
- package/build/server.js +7 -28
- package/build/tools/org.js +15 -17
- package/package.json +1 -1
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export { isApprovalGatedAction } from './approval-actions.js';
|
|
2
|
+
/**
|
|
3
|
+
* Action approval tiers:
|
|
4
|
+
*
|
|
5
|
+
* Tier 0 (autonomous) — No approval needed. Reads, low-risk writes.
|
|
6
|
+
* Tier 1 (gated) — Requires dashboard/Slack approval. Sends to real people, bulk ops.
|
|
7
|
+
* Tier 2 (protected) — Requires dashboard approval + typed confirmation. Irreversible data ops.
|
|
8
|
+
*/
|
|
3
9
|
export type ApprovalTier = 'autonomous' | 'gated' | 'protected';
|
|
4
10
|
export declare function getApprovalTier(action: string, params?: Record<string, unknown>): ApprovalTier;
|
|
5
11
|
/**
|
|
@@ -58,6 +64,15 @@ export declare function approvalPayloadHash(action: string, params: unknown): st
|
|
|
58
64
|
* and returns instructions for the agent to wait.
|
|
59
65
|
*/
|
|
60
66
|
export declare function requestApproval(action: string, params: Record<string, unknown>, apiKeyId?: string, channelContext?: ChannelExecutionContext, display?: ApprovalDisplay): Promise<ApprovalResult>;
|
|
67
|
+
type AdminApprovalAuthority = {
|
|
68
|
+
id?: string;
|
|
69
|
+
action: string;
|
|
70
|
+
projectId: string;
|
|
71
|
+
phase: 'preview' | 'approved';
|
|
72
|
+
decidedBy?: string;
|
|
73
|
+
};
|
|
74
|
+
/** Read by the hosted bridge only; no scope or token is changed. */
|
|
75
|
+
export declare function currentAdminApprovalAuthority(): Readonly<AdminApprovalAuthority> | undefined;
|
|
61
76
|
/**
|
|
62
77
|
* Check if an approval token has been approved.
|
|
63
78
|
* Returns the approval if approved, null if still pending, throws if rejected/expired.
|
|
@@ -87,7 +102,7 @@ type ToolResult = {
|
|
|
87
102
|
*/
|
|
88
103
|
export declare function withApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R, options?: {
|
|
89
104
|
display?: (args: A) => Promise<ApprovalDisplay> | ApprovalDisplay;
|
|
105
|
+
registeredRequestOnly?: boolean;
|
|
90
106
|
}): (args: A & {
|
|
91
107
|
approval_id?: string;
|
|
92
108
|
}, extra?: unknown) => Promise<R | ToolResult>;
|
|
93
|
-
export {};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { GATED_ACTIONS, PROTECTED_ACTIONS } from './approval-actions.js';
|
|
3
|
+
export { isApprovalGatedAction } from './approval-actions.js';
|
|
4
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
1
5
|
import { isTesterRequest, isApplyingTesterRequest } from './tester-policy.js';
|
|
2
6
|
import { logTesterRequestAudit } from './tester-request-audit.js';
|
|
3
7
|
import { approvalStore } from './tester-requests-store.js';
|
|
@@ -9,97 +13,12 @@ import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-sna
|
|
|
9
13
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
10
14
|
import { offerApprovalApp } from './approval-app.js';
|
|
11
15
|
import { elicitApproval } from './approval-elicitation.js';
|
|
12
|
-
/**
|
|
13
|
-
* Action approval tiers:
|
|
14
|
-
*
|
|
15
|
-
* Tier 0 (autonomous) — No approval needed. Reads, low-risk writes.
|
|
16
|
-
* Tier 1 (gated) — Requires dashboard/Slack approval. Sends to real people, bulk ops.
|
|
17
|
-
* Tier 2 (protected) — Requires dashboard approval + typed confirmation. Irreversible data ops.
|
|
18
|
-
*/
|
|
19
|
-
const GATED_ACTIONS = new Set([
|
|
20
|
-
'recheck_domain_dns', // live provider verification may request an EE certificate
|
|
21
|
-
'draft_agent_reply',
|
|
22
|
-
'create_agent_mailbox',
|
|
23
|
-
'launch_campaign', // sends real emails
|
|
24
|
-
'install_connector_schema', // installs schema into a customer-owned database
|
|
25
|
-
'install_connector_view', // reinstalls the customer-side snapshot view
|
|
26
|
-
'set_connector_mapping', // rewrites how customer data maps in
|
|
27
|
-
'resync_connector', // forces a full resync
|
|
28
|
-
'publish_dmarc_record', // writes a DNS record into a customer-owned Cloudflare zone
|
|
29
|
-
'broadcast_to_segment', // creates + activates a real broadcast
|
|
30
|
-
'send_broadcast', // one-call broadcast; executes a frozen, approved payload (#3669)
|
|
31
|
-
'activate_campaigns', // turns on a set of campaigns behind one approval (#3669)
|
|
32
|
-
'rotate_custom_webhook_secret', // immediately invalidates an inbound signing secret
|
|
33
|
-
'create_campaign_with_content', // can activate + send in one call
|
|
34
|
-
'enroll_segment', // bulk enrollment
|
|
35
|
-
'import_subscribers', // bulk data ingestion
|
|
36
|
-
'subscribe_webhook', // outbound data channel
|
|
37
|
-
'send_transactional', // sends a real email
|
|
38
|
-
'update_campaign', // live trigger edits only; the domain rule gates callers
|
|
39
|
-
'set_campaign_trigger', // changes who starts an active or paused campaign
|
|
40
|
-
'update_campaign_status', // activation sends real emails (see special case below)
|
|
41
|
-
'add_list_members', // bulk contact-list membership write
|
|
42
|
-
'remove_list_members', // bulk contact-list membership write
|
|
43
|
-
'add_list_members_by_filter', // bulk membership write, unbounded match set
|
|
44
|
-
'remove_team_member', // revokes workspace access
|
|
45
|
-
'set_team_member_role', // changes workspace access privileges
|
|
46
|
-
'delete_provider_credential', // permanently removes or revokes stored provider access
|
|
47
|
-
// #4005: every outward or destructive tool is approval-gated unless it sits on APPROVAL_EXEMPT
|
|
48
|
-
// (src/lib/approval-exempt.ts). These are wrapped with withApproval() in their tool files.
|
|
49
|
-
'send_sms', // texts a real phone number
|
|
50
|
-
'send_sms_campaign', // can activate an SMS broadcast
|
|
51
|
-
'send_newsletter_issue', // sends an issue to every subscriber
|
|
52
|
-
'schedule_newsletter_issue', // queues an issue to every subscriber
|
|
53
|
-
'reply_to_comment', // queues a public LinkedIn reply
|
|
54
|
-
'send_awaiting_reply', // sends a real email
|
|
55
|
-
'send_test_email', // sends a real email to any address
|
|
56
|
-
'remove_suppression', // re-enables mail to a suppressed address
|
|
57
|
-
'schedule_social_post', // publishes to a connected social account
|
|
58
|
-
'invite_team_member', // emails an outside person and grants access
|
|
59
|
-
'set_member_project_access', // changes workspace access
|
|
60
|
-
'revoke_invitation', // cancels a pending teammate invitation
|
|
61
|
-
'create_api_key', // mints new access to the project
|
|
62
|
-
'revoke_api_key', // breaks every integration using the key
|
|
63
|
-
'tenant_export_full', // copies all customer data out of Sendinel
|
|
64
|
-
'enroll_contact', // starts real emails to a contact
|
|
65
|
-
'identify_subscriber', // may enroll a new signup in sending campaigns
|
|
66
|
-
'emit_contact_event', // may enroll a real contact in active sending campaigns
|
|
67
|
-
'enroll_automation', // starts automated messages to a contact
|
|
68
|
-
'enroll_in_escalation_ladder', // starts email/SMS escalation to a contact
|
|
69
|
-
'set_contact_sms_consent', // changes the consent record that allows texting
|
|
70
|
-
'bulk_update_subscribers', // bulk contact data write
|
|
71
|
-
'update_webhook_subscription', // can redirect event data to a new URL
|
|
72
|
-
'delete_webhook_subscription', // removes an outbound data channel
|
|
73
|
-
'delete_campaign', // deletes a campaign and its steps
|
|
74
|
-
'delete_campaign_step', // deletes an email from a campaign
|
|
75
|
-
'delete_content_block', // deletes customer content
|
|
76
|
-
'delete_custom_field', // deletes a field and its values
|
|
77
|
-
'delete_landing_page', // takes a public page down and deletes it
|
|
78
|
-
'delete_list', // deletes a contact list
|
|
79
|
-
'delete_signature', // deletes a saved signature
|
|
80
|
-
'delete_social_post', // deletes a social post record
|
|
81
|
-
'delete_subscriber_view', // deletes a saved view
|
|
82
|
-
'delete_template', // deletes an email template
|
|
83
|
-
'import_brand_kit_text', // overwrites the brand kit
|
|
84
|
-
'disconnect_platform', // deletes stored platform credentials
|
|
85
|
-
'start_provider_switch', // moves a sending domain to another provider
|
|
86
|
-
'update_landing_page', // publishing makes the page public (see special case below)
|
|
87
|
-
'create_blog_post', // auto_publish posts to the customer's own site (see special case below)
|
|
88
|
-
'replay_enrollment_dlq_rows', // confirmed replay sends real emails (see special case below)
|
|
89
|
-
]);
|
|
90
|
-
const PROTECTED_ACTIONS = new Set([
|
|
91
|
-
'delete_subscriber_data', // GDPR delete — irreversible
|
|
92
|
-
'merge_subscribers', // data consolidation — irreversible
|
|
93
|
-
'delete_segment', // affects campaign targeting
|
|
94
|
-
'identify_inactive', // bulk suppression (when not dry_run)
|
|
95
|
-
'promote_ab_winner', // deletes losing variants
|
|
96
|
-
'action_ccpa_request', // CCPA delete completion is irreversible
|
|
97
|
-
]);
|
|
98
|
-
/** True when the action name is on the gated or protected list (the default-deny test uses this). */
|
|
99
|
-
export function isApprovalGatedAction(action) {
|
|
100
|
-
return GATED_ACTIONS.has(action) || PROTECTED_ACTIONS.has(action);
|
|
101
|
-
}
|
|
102
16
|
export function getApprovalTier(action, params) {
|
|
17
|
+
// #4308 / #4214: a request-only connection cannot bypass approval via autonomous/preview modes.
|
|
18
|
+
const scopes = requestContext()?.scopes;
|
|
19
|
+
if (scopes && !scopes.includes('admin') && !scopes.includes('tester') && !scopes.includes('agent') &&
|
|
20
|
+
isHumanRequestableAdminTool(action))
|
|
21
|
+
return PROTECTED_ACTIONS.has(action) ? 'protected' : 'gated';
|
|
103
22
|
if (isTesterRequest(action))
|
|
104
23
|
return isApplyingTesterRequest(action) ? 'autonomous' : 'gated';
|
|
105
24
|
// CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
|
|
@@ -117,7 +36,7 @@ export function getApprovalTier(action, params) {
|
|
|
117
36
|
}
|
|
118
37
|
// send_broadcast exists for agents and always needs a person's approval, even in autonomous
|
|
119
38
|
// mode: it must never approve itself (#3669, AI-worker boundary in CLAUDE.md).
|
|
120
|
-
if (action === 'draft_agent_reply' || action === 'create_agent_mailbox' || action === 'send_broadcast' || action === 'activate_campaigns')
|
|
39
|
+
if (action === 'draft_agent_reply' || action === 'create_agent_mailbox' || action === 'send_broadcast' || action === 'activate_campaigns' || action === 'invite_team_member')
|
|
121
40
|
return 'gated';
|
|
122
41
|
// Autonomous mode: user explicitly opted out of approval gates for gated actions
|
|
123
42
|
// (request-scoped under HTTP — env is only trustworthy for single-project stdio)
|
|
@@ -289,6 +208,10 @@ export function buildApprovalSummary(action, params, display = {}) {
|
|
|
289
208
|
add_list_members: (p) => `Add ${p.contact_ids?.length ?? 0} contact(s) to list ${p.list_id}`,
|
|
290
209
|
remove_list_members: (p) => `Remove ${p.contact_ids?.length ?? 0} contact(s) from list ${p.list_id}`,
|
|
291
210
|
add_list_members_by_filter: (p) => `Add contacts matching filter rules to list ${p.list_id}`,
|
|
211
|
+
invite_team_member: (p) => {
|
|
212
|
+
const invite = p.__approval_snapshot?.invitation;
|
|
213
|
+
return `Send invitation to ${p.email} as ${p.role}; project_ids: ${JSON.stringify(p.project_ids ?? null)} (null = org-wide, [] = no projects); organization: ${invite?.org_name ?? 'unavailable'} (${invite?.org_id ?? 'unavailable'}); subject: "${invite?.subject ?? 'unavailable'}"`;
|
|
214
|
+
},
|
|
292
215
|
remove_team_member: (p) => `Remove team member ${p.user_id} from the organization`,
|
|
293
216
|
set_team_member_role: (p) => `Change team member ${p.user_id}'s role to ${p.role}`,
|
|
294
217
|
delete_provider_credential: (p) => `Delete saved ${p.provider ?? 'provider'} credential ${p.credential_id} (${p.source})`,
|
|
@@ -338,7 +261,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
338
261
|
const actor = requestContext();
|
|
339
262
|
// Ignore any model-supplied reserved field; freeze facts read by the server.
|
|
340
263
|
const cleanParams = withoutApprovalSnapshot(params);
|
|
341
|
-
const snapshot = await
|
|
264
|
+
const snapshot = await captureSnapshot(action, cleanParams);
|
|
342
265
|
const payload = cleanParams.payload;
|
|
343
266
|
if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
|
|
344
267
|
!approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
|
|
@@ -389,11 +312,25 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
389
312
|
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.`,
|
|
390
313
|
}, { action, params: frozenParams, summary }, getProjectId());
|
|
391
314
|
}
|
|
315
|
+
const registeredApproval = new AsyncLocalStorage();
|
|
316
|
+
/** Read by the hosted bridge only; no scope or token is changed. */
|
|
317
|
+
export function currentAdminApprovalAuthority() {
|
|
318
|
+
return registeredApproval.getStore();
|
|
319
|
+
}
|
|
320
|
+
function captureSnapshot(action, params) {
|
|
321
|
+
if (!isHumanRequestableAdminTool(action))
|
|
322
|
+
return captureApprovalSnapshot(action, params, getProjectId());
|
|
323
|
+
return registeredApproval.run({ action, projectId: getProjectId(), phase: 'preview' }, () => captureApprovalSnapshot(action, params, getProjectId()));
|
|
324
|
+
}
|
|
392
325
|
/**
|
|
393
326
|
* Check if an approval token has been approved.
|
|
394
327
|
* Returns the approval if approved, null if still pending, throws if rejected/expired.
|
|
395
328
|
*/
|
|
396
329
|
export async function checkApproval(approvalId, options = {}) {
|
|
330
|
+
const applied = registeredApproval.getStore();
|
|
331
|
+
if (options.consume && applied?.phase === 'approved' && applied.id === approvalId && applied.action === options.action && applied.projectId === getProjectId()) {
|
|
332
|
+
return { approved: true, status: 'approved', decided_by: applied.decidedBy };
|
|
333
|
+
}
|
|
397
334
|
const { data, error } = await approvalStore()
|
|
398
335
|
.from('action_approvals')
|
|
399
336
|
.select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
|
|
@@ -425,7 +362,8 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
425
362
|
}
|
|
426
363
|
if (data.used_at || data.status === 'used')
|
|
427
364
|
return { approved: false, status: 'consumed' };
|
|
428
|
-
if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)
|
|
365
|
+
if (data.status === 'approved' && (!isHumanApprovalChannel(data.decided_via) ||
|
|
366
|
+
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard'))) {
|
|
429
367
|
return { approved: false, status: 'human_decision_required' };
|
|
430
368
|
}
|
|
431
369
|
if (data.status !== 'approved' || !options.consume) {
|
|
@@ -439,7 +377,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
439
377
|
}
|
|
440
378
|
if (data.params?.__approval_snapshot) {
|
|
441
379
|
try {
|
|
442
|
-
const current = await
|
|
380
|
+
const current = await captureSnapshot(data.action, withoutApprovalSnapshot(data.params));
|
|
443
381
|
if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
|
|
444
382
|
return { approved: false, status: 'target_changed_after_approval' };
|
|
445
383
|
}
|
|
@@ -491,16 +429,21 @@ export function withApproval(action, handler, options = {}) {
|
|
|
491
429
|
const decision = assertChannelCanExecute(action, params, channelContext);
|
|
492
430
|
if (!decision.allowed)
|
|
493
431
|
return toolText(decision.reason ?? 'This channel cannot request this action.');
|
|
494
|
-
|
|
432
|
+
let decidedBy;
|
|
433
|
+
if (options.registeredRequestOnly || decision.tier !== 'autonomous') {
|
|
495
434
|
if (!approval_id) {
|
|
496
435
|
const display = options.display ? await options.display(params) : undefined;
|
|
497
436
|
return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
|
|
498
437
|
}
|
|
499
438
|
const check = await checkApproval(approval_id, { consume: true, action, params });
|
|
439
|
+
decidedBy = check.decided_by;
|
|
500
440
|
if (!check.approved) {
|
|
501
441
|
return toolText(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
502
442
|
}
|
|
503
443
|
}
|
|
444
|
+
if (options.registeredRequestOnly) {
|
|
445
|
+
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: 'approved', decidedBy }, () => handler(args, extra));
|
|
446
|
+
}
|
|
504
447
|
return handler(params, extra);
|
|
505
448
|
};
|
|
506
449
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { isApprovalGatedAction } from './approval-actions.js';
|
|
2
|
+
export const ADMIN_TOOLS = new Set([
|
|
3
|
+
'create_agent_mailbox',
|
|
4
|
+
// Connector mutations write schema into a customer-owned database and
|
|
5
|
+
// rewrite how their data maps in (admin, not write: Kevin, 2026-09-11).
|
|
6
|
+
// NOTE: no apostrophes in comments inside these Sets -- tool-scopes.test.ts
|
|
7
|
+
// parses the block by matching single-quoted strings.
|
|
8
|
+
'install_connector_schema', 'install_connector_view', 'set_connector_mapping', 'resync_connector',
|
|
9
|
+
'delete_subscriber_data', 'list_deletion_log', 'list_ccpa_requests', 'action_ccpa_request',
|
|
10
|
+
'delete_subscriber_data', 'list_deletion_log', 'list_audit_log', 'get_audit_stats',
|
|
11
|
+
'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
|
|
12
|
+
'create_site', 'update_site',
|
|
13
|
+
'delete_template', 'register_domain', 'onboard_new_site',
|
|
14
|
+
'create_api_key', 'revoke_api_key', 'invite_team_member',
|
|
15
|
+
'connect_platform', 'disconnect_platform', 'configure_platform_trigger',
|
|
16
|
+
'create_custom_webhook', 'rotate_custom_webhook_secret',
|
|
17
|
+
'remove_team_member', 'set_team_member_role', 'set_member_project_access',
|
|
18
|
+
'list_invitations', 'resend_invitation', 'revoke_invitation',
|
|
19
|
+
'connect_platform', 'disconnect_platform', 'configure_platform_trigger', 'delete_provider_credential',
|
|
20
|
+
'abuse_monitor_override',
|
|
21
|
+
'tenant_export_full',
|
|
22
|
+
'identify_inactive', 'delete_segment',
|
|
23
|
+
'delete_signature',
|
|
24
|
+
'merge_subscribers', 'delete_webhook_subscription',
|
|
25
|
+
'set_white_label_settings', 'add_white_label_domain',
|
|
26
|
+
'post_ticket_reply',
|
|
27
|
+
]);
|
|
28
|
+
/** #4308 / #4214: request access is derived, never an admin scope grant or a second list. */
|
|
29
|
+
export function isHumanRequestableAdminTool(name) {
|
|
30
|
+
return ADMIN_TOOLS.has(name) && isApprovalGatedAction(name);
|
|
31
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const GATED_ACTIONS: Set<string>;
|
|
2
|
+
export declare const PROTECTED_ACTIONS: Set<string>;
|
|
3
|
+
/** True when the action name is on the gated or protected list (the default-deny test uses this). */
|
|
4
|
+
export declare function isApprovalGatedAction(action: string): boolean;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const GATED_ACTIONS = new Set([
|
|
2
|
+
'recheck_domain_dns', // live provider verification may request an EE certificate
|
|
3
|
+
'draft_agent_reply',
|
|
4
|
+
'create_agent_mailbox',
|
|
5
|
+
'launch_campaign', // sends real emails
|
|
6
|
+
'install_connector_schema', // installs schema into a customer-owned database
|
|
7
|
+
'install_connector_view', // reinstalls the customer-side snapshot view
|
|
8
|
+
'set_connector_mapping', // rewrites how customer data maps in
|
|
9
|
+
'resync_connector', // forces a full resync
|
|
10
|
+
'publish_dmarc_record', // writes a DNS record into a customer-owned Cloudflare zone
|
|
11
|
+
'broadcast_to_segment', // creates + activates a real broadcast
|
|
12
|
+
'send_broadcast', // one-call broadcast; executes a frozen, approved payload (#3669)
|
|
13
|
+
'activate_campaigns', // turns on a set of campaigns behind one approval (#3669)
|
|
14
|
+
'rotate_custom_webhook_secret', // immediately invalidates an inbound signing secret
|
|
15
|
+
'create_campaign_with_content', // can activate + send in one call
|
|
16
|
+
'enroll_segment', // bulk enrollment
|
|
17
|
+
'import_subscribers', // bulk data ingestion
|
|
18
|
+
'subscribe_webhook', // outbound data channel
|
|
19
|
+
'send_transactional', // sends a real email
|
|
20
|
+
'update_campaign', // live trigger edits only; the domain rule gates callers
|
|
21
|
+
'set_campaign_trigger', // changes who starts an active or paused campaign
|
|
22
|
+
'update_campaign_status', // activation sends real emails (see special case below)
|
|
23
|
+
'add_list_members', // bulk contact-list membership write
|
|
24
|
+
'remove_list_members', // bulk contact-list membership write
|
|
25
|
+
'add_list_members_by_filter', // bulk membership write, unbounded match set
|
|
26
|
+
'remove_team_member', // revokes workspace access
|
|
27
|
+
'set_team_member_role', // changes workspace access privileges
|
|
28
|
+
'delete_provider_credential', // permanently removes or revokes stored provider access
|
|
29
|
+
// #4005: every outward or destructive tool is approval-gated unless it sits on APPROVAL_EXEMPT
|
|
30
|
+
// (src/lib/approval-exempt.ts). These are wrapped with withApproval() in their tool files.
|
|
31
|
+
'send_sms', // texts a real phone number
|
|
32
|
+
'send_sms_campaign', // can activate an SMS broadcast
|
|
33
|
+
'send_newsletter_issue', // sends an issue to every subscriber
|
|
34
|
+
'schedule_newsletter_issue', // queues an issue to every subscriber
|
|
35
|
+
'reply_to_comment', // queues a public LinkedIn reply
|
|
36
|
+
'send_awaiting_reply', // sends a real email
|
|
37
|
+
'send_test_email', // sends a real email to any address
|
|
38
|
+
'remove_suppression', // re-enables mail to a suppressed address
|
|
39
|
+
'schedule_social_post', // publishes to a connected social account
|
|
40
|
+
'invite_team_member', // emails an outside person and grants access
|
|
41
|
+
'set_member_project_access', // changes workspace access
|
|
42
|
+
'revoke_invitation', // cancels a pending teammate invitation
|
|
43
|
+
'create_api_key', // mints new access to the project
|
|
44
|
+
'revoke_api_key', // breaks every integration using the key
|
|
45
|
+
'tenant_export_full', // copies all customer data out of Sendinel
|
|
46
|
+
'enroll_contact', // starts real emails to a contact
|
|
47
|
+
'identify_subscriber', // may enroll a new signup in sending campaigns
|
|
48
|
+
'emit_contact_event', // may enroll a real contact in active sending campaigns
|
|
49
|
+
'enroll_automation', // starts automated messages to a contact
|
|
50
|
+
'enroll_in_escalation_ladder', // starts email/SMS escalation to a contact
|
|
51
|
+
'set_contact_sms_consent', // changes the consent record that allows texting
|
|
52
|
+
'bulk_update_subscribers', // bulk contact data write
|
|
53
|
+
'update_webhook_subscription', // can redirect event data to a new URL
|
|
54
|
+
'delete_webhook_subscription', // removes an outbound data channel
|
|
55
|
+
'delete_campaign', // deletes a campaign and its steps
|
|
56
|
+
'delete_campaign_step', // deletes an email from a campaign
|
|
57
|
+
'delete_content_block', // deletes customer content
|
|
58
|
+
'delete_custom_field', // deletes a field and its values
|
|
59
|
+
'delete_landing_page', // takes a public page down and deletes it
|
|
60
|
+
'delete_list', // deletes a contact list
|
|
61
|
+
'delete_signature', // deletes a saved signature
|
|
62
|
+
'delete_social_post', // deletes a social post record
|
|
63
|
+
'delete_subscriber_view', // deletes a saved view
|
|
64
|
+
'delete_template', // deletes an email template
|
|
65
|
+
'import_brand_kit_text', // overwrites the brand kit
|
|
66
|
+
'disconnect_platform', // deletes stored platform credentials
|
|
67
|
+
'start_provider_switch', // moves a sending domain to another provider
|
|
68
|
+
'update_landing_page', // publishing makes the page public (see special case below)
|
|
69
|
+
'create_blog_post', // auto_publish posts to the customer's own site (see special case below)
|
|
70
|
+
'replay_enrollment_dlq_rows', // confirmed replay sends real emails (see special case below)
|
|
71
|
+
]);
|
|
72
|
+
export const PROTECTED_ACTIONS = new Set([
|
|
73
|
+
'delete_subscriber_data', // GDPR delete — irreversible
|
|
74
|
+
'merge_subscribers', // data consolidation — irreversible
|
|
75
|
+
'delete_segment', // affects campaign targeting
|
|
76
|
+
'identify_inactive', // bulk suppression (when not dry_run)
|
|
77
|
+
'promote_ab_winner', // deletes losing variants
|
|
78
|
+
'action_ccpa_request', // CCPA delete completion is irreversible
|
|
79
|
+
]);
|
|
80
|
+
/** True when the action name is on the gated or protected list (the default-deny test uses this). */
|
|
81
|
+
export function isApprovalGatedAction(action) {
|
|
82
|
+
return GATED_ACTIONS.has(action) || PROTECTED_ACTIONS.has(action);
|
|
83
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
1
2
|
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { buildApprovalPreview } from '@sendinel/domain';
|
|
3
4
|
import { APPROVAL_APP, APPROVAL_APP_SESSION_META } from './approval-app-conventions.js';
|
|
@@ -65,6 +66,8 @@ function live(approval) {
|
|
|
65
66
|
*/
|
|
66
67
|
export async function offerApprovalApp(approval) {
|
|
67
68
|
const runtime = calls.getStore(), actor = identity();
|
|
69
|
+
if (isHumanRequestableAdminTool(approval.action))
|
|
70
|
+
return {};
|
|
68
71
|
if (!runtime || !runtime.canDecide || !actor || !approvalAppEnabled() || approval.tier === 'protected' || !live(approval))
|
|
69
72
|
return {};
|
|
70
73
|
if (await resolveApprovalChannel(runtime.server.server) !== 'mcp_app')
|
|
@@ -92,7 +95,7 @@ export function installApprovalApp(server, options = {}, approvalTool = () => fa
|
|
|
92
95
|
if (!bound.canDecide || !actor || !approvalAppEnabled() || await resolveApprovalChannel(server.server) !== 'mcp_app')
|
|
93
96
|
throw unavailable();
|
|
94
97
|
const approval = await loadOwnedPendingApproval(id, actor);
|
|
95
|
-
if (!approval)
|
|
98
|
+
if (!approval || isHumanRequestableAdminTool(approval.action))
|
|
96
99
|
throw unavailable();
|
|
97
100
|
// One outstanding render session per approval. Re-render invalidates the old view.
|
|
98
101
|
const token = await mintApprovalAppSession(approval, actor);
|
|
@@ -121,7 +124,7 @@ export function installApprovalApp(server, options = {}, approvalTool = () => fa
|
|
|
121
124
|
return refused();
|
|
122
125
|
const approval = await loadOwnedPendingApproval(approval_id, actor);
|
|
123
126
|
// Single guarded claim before the callback, so concurrent clicks cannot reuse a session.
|
|
124
|
-
if (!approval || !await claimApprovalAppSession(app_session, approval, actor))
|
|
127
|
+
if (!approval || isHumanRequestableAdminTool(approval.action) || !await claimApprovalAppSession(app_session, approval, actor))
|
|
125
128
|
return refused();
|
|
126
129
|
const connectionId = `oauth:${actor.oauthTokenId}`;
|
|
127
130
|
const callback = createHumanApprovalCallback({ approvalId: approval.id, projectId: actor.projectId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
1
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
|
|
@@ -23,6 +24,8 @@ export function runWithApprovalElicitation(server, extra, run) {
|
|
|
23
24
|
/** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
|
|
24
25
|
export async function elicitApproval(result, frozen, projectId) {
|
|
25
26
|
const pending = { ...result, decision_status: 'decision_pending' };
|
|
27
|
+
if (isHumanRequestableAdminTool(frozen.action))
|
|
28
|
+
return pending;
|
|
26
29
|
const context = requests.getStore();
|
|
27
30
|
if (!context || context.projectId !== projectId || result.tier !== 'gated')
|
|
28
31
|
return pending;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { approvalStore } from './tester-requests-store.js';
|
|
3
4
|
/**
|
|
@@ -18,11 +19,16 @@ export function createHumanApprovalCallback(binding) {
|
|
|
18
19
|
completed = true;
|
|
19
20
|
const source = approvalStore();
|
|
20
21
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
22
|
+
// #4308 / #4214: admin requests can only be decided in a human dashboard session.
|
|
23
|
+
const { data: approval, error: loadError } = await client.from('action_approvals')
|
|
24
|
+
.select('action').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
|
|
25
|
+
if (loadError || typeof approval?.action !== 'string' || !approval.action || isHumanRequestableAdminTool(approval.action))
|
|
26
|
+
return false;
|
|
21
27
|
const now = new Date().toISOString();
|
|
22
28
|
const { data, error } = await client.from('action_approvals')
|
|
23
29
|
.update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
|
|
24
30
|
decided_by: binding.userId, decided_at: now })
|
|
25
|
-
.eq('id', binding.approvalId).eq('project_id', binding.projectId)
|
|
31
|
+
.eq('id', binding.approvalId).eq('project_id', binding.projectId).eq('action', approval.action)
|
|
26
32
|
.eq('status', 'pending').is('used_at', null).gt('expires_at', now)
|
|
27
33
|
.select('id, payload_hash').maybeSingle();
|
|
28
34
|
if (error || !data)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isTesterRequest } from './tester-policy.js';
|
|
2
2
|
import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
|
|
3
3
|
import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
|
|
4
|
+
import { callSendinelV1 } from './v1-client.js';
|
|
4
5
|
import { db } from '../db.js';
|
|
5
6
|
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
6
7
|
const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
|
|
@@ -8,6 +9,14 @@ const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launc
|
|
|
8
9
|
'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
|
|
9
10
|
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
10
11
|
export async function captureApprovalSnapshot(action, params, projectId) {
|
|
12
|
+
if (action === 'invite_team_member') {
|
|
13
|
+
const result = await callSendinelV1('/api/v1/org/invitations', { method: 'POST', body: { ...params, preview_only: true } });
|
|
14
|
+
const preview = result.ok ? result.data.preview : null;
|
|
15
|
+
if (!preview || typeof preview.org_id !== 'string' || typeof preview.org_name !== 'string' || typeof preview.subject !== 'string') {
|
|
16
|
+
throw new Error('Invitation approval preview unavailable; no email was sent.');
|
|
17
|
+
}
|
|
18
|
+
return { version: 1, invitation: preview, recipient_count: 1 };
|
|
19
|
+
}
|
|
11
20
|
const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
|
|
12
21
|
const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
13
22
|
if (!TARGET_ACTIONS.has(action))
|
package/build/server.js
CHANGED
|
@@ -5,7 +5,8 @@ import { runWithToolContext } from './db.js';
|
|
|
5
5
|
import { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
|
|
6
6
|
import { testerRequestTool } from './lib/tester-requests.js';
|
|
7
7
|
export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
|
|
8
|
-
import {
|
|
8
|
+
import { ADMIN_TOOLS, isHumanRequestableAdminTool } from './lib/admin-tools.js';
|
|
9
|
+
import { withApproval, isApprovalGatedAction } from './lib/action-approvals.js';
|
|
9
10
|
import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
|
|
10
11
|
import { registerAnalyticsTools } from './tools/analytics.js';
|
|
11
12
|
import { registerCampaignTools } from './tools/campaigns.js';
|
|
@@ -149,32 +150,6 @@ const WRITE_TOOLS = new Set([
|
|
|
149
150
|
'commit_staged_import', 'discard_staged_import', 'add_synced_folder', 'remove_synced_folder',
|
|
150
151
|
'retry_send_queue_rows', 'cancel_send_queue_rows', 'add_blackout_date', 'remove_blackout_date',
|
|
151
152
|
]);
|
|
152
|
-
const ADMIN_TOOLS = new Set([
|
|
153
|
-
'create_agent_mailbox',
|
|
154
|
-
// Connector mutations write schema into a customer-owned database and
|
|
155
|
-
// rewrite how their data maps in (admin, not write: Kevin, 2026-09-11).
|
|
156
|
-
// NOTE: no apostrophes in comments inside these Sets -- tool-scopes.test.ts
|
|
157
|
-
// parses the block by matching single-quoted strings.
|
|
158
|
-
'install_connector_schema', 'install_connector_view', 'set_connector_mapping', 'resync_connector',
|
|
159
|
-
'delete_subscriber_data', 'list_deletion_log', 'list_ccpa_requests', 'action_ccpa_request',
|
|
160
|
-
'delete_subscriber_data', 'list_deletion_log', 'list_audit_log', 'get_audit_stats',
|
|
161
|
-
'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
|
|
162
|
-
'create_site', 'update_site',
|
|
163
|
-
'delete_template', 'register_domain', 'onboard_new_site',
|
|
164
|
-
'create_api_key', 'revoke_api_key', 'invite_team_member',
|
|
165
|
-
'connect_platform', 'disconnect_platform', 'configure_platform_trigger',
|
|
166
|
-
'create_custom_webhook', 'rotate_custom_webhook_secret',
|
|
167
|
-
'remove_team_member', 'set_team_member_role', 'set_member_project_access',
|
|
168
|
-
'list_invitations', 'resend_invitation', 'revoke_invitation',
|
|
169
|
-
'connect_platform', 'disconnect_platform', 'configure_platform_trigger', 'delete_provider_credential',
|
|
170
|
-
'abuse_monitor_override',
|
|
171
|
-
'tenant_export_full',
|
|
172
|
-
'identify_inactive', 'delete_segment',
|
|
173
|
-
'delete_signature',
|
|
174
|
-
'merge_subscribers', 'delete_webhook_subscription',
|
|
175
|
-
'set_white_label_settings', 'add_white_label_domain',
|
|
176
|
-
'post_ticket_reply',
|
|
177
|
-
]);
|
|
178
153
|
const SCOPE_HIERARCHY = { read: 1, write: 2, admin: 3 };
|
|
179
154
|
export function getRequiredScope(name) {
|
|
180
155
|
if (ADMIN_TOOLS.has(name))
|
|
@@ -348,7 +323,7 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
|
348
323
|
const agent = isAgentConnection(scopes);
|
|
349
324
|
const allowed = () => agent ? agentConnectionAllowsTool(name) : tester
|
|
350
325
|
? testerConnectionAllowsTool(name)
|
|
351
|
-
: scopeAllows(scopes, required);
|
|
326
|
+
: scopeAllows(scopes, required) || (scopeAllows(scopes, 'read') && isHumanRequestableAdminTool(name));
|
|
352
327
|
if (!allowed()) {
|
|
353
328
|
// Don't register tools the key can't access — they won't appear in tool list
|
|
354
329
|
return;
|
|
@@ -358,6 +333,10 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
|
358
333
|
config = wrapped.config;
|
|
359
334
|
handler = wrapped.handler;
|
|
360
335
|
}
|
|
336
|
+
if (!tester && !agent && !scopeAllows(scopes, 'admin') && isHumanRequestableAdminTool(name)) {
|
|
337
|
+
config = { ...config, description: `${config.description ?? ''} Request-only: needs a human org admin to approve.` };
|
|
338
|
+
handler = withApproval(name, handler, { registeredRequestOnly: true });
|
|
339
|
+
}
|
|
361
340
|
// Wrap handler with runtime scope check (defense in depth)
|
|
362
341
|
const guardedHandler = (...args) => {
|
|
363
342
|
if (!allowed()) {
|
package/build/tools/org.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { readPlanUsage, readAllRows } from "@sendinel/domain";
|
|
2
|
-
import { randomBytes } from 'crypto';
|
|
3
2
|
import { z } from 'zod';
|
|
4
3
|
import { logAudit } from '../audit.js';
|
|
5
4
|
import { adminDb, db } from '../db.js';
|
|
@@ -174,21 +173,20 @@ export function registerOrgTools(server) {
|
|
|
174
173
|
idempotentHint: false,
|
|
175
174
|
openWorldHint: false,
|
|
176
175
|
},
|
|
177
|
-
description: '
|
|
178
|
-
inputSchema: {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return j({ invitation_id: data.id, expires_at: data.expires_at });
|
|
176
|
+
description: 'Invite a team member by email to the current project organization using the system invitation template. Reuses a matching pending invitation. Always requires human approval; request once, then repeat identical email, role and project_ids with approval_id. Owner invitations are unsupported by the API.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
|
|
179
|
+
email: z.string().email(),
|
|
180
|
+
role: z.enum(['owner', 'admin', 'member', 'tester']).default('member'),
|
|
181
|
+
project_ids: z.array(z.string().uuid()).optional().describe('Same-org project IDs. Omit for org-wide access; [] grants no projects. Must match an existing pending invitation.'),
|
|
182
|
+
},
|
|
183
|
+
}, withApproval('invite_team_member', async (args) => {
|
|
184
|
+
const result = await callSendinelV1('/api/v1/org/invitations', { method: 'POST', body: args });
|
|
185
|
+
if (!result.ok)
|
|
186
|
+
return j({ error: result.error, status: result.status });
|
|
187
|
+
const data = result.data;
|
|
188
|
+
// Preserve the original top-level response fields; add the API's send outcome.
|
|
189
|
+
return j({ ...data, invitation_id: data.invitation.id, expires_at: data.invitation.expires_at });
|
|
192
190
|
}));
|
|
193
191
|
server.registerTool('remove_team_member', {
|
|
194
192
|
title: 'Remove Team Member',
|
|
@@ -443,7 +441,7 @@ export function registerOrgTools(server) {
|
|
|
443
441
|
idempotentHint: false,
|
|
444
442
|
openWorldHint: false,
|
|
445
443
|
},
|
|
446
|
-
description: "Extend a pending invitation's expiry and return its invite URL.
|
|
444
|
+
description: "Extend a pending invitation's expiry and return its invite URL. Link-only: sends no email. Use invite_team_member with the same email, role and project_ids to request an approval-gated template email.",
|
|
447
445
|
inputSchema: {
|
|
448
446
|
invitation_id: z.string().uuid(),
|
|
449
447
|
org_id: z.string().uuid().optional(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.119",
|
|
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",
|