@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.
@@ -1,5 +1,11 @@
1
- /** True when the action name is on the gated or protected list (the default-deny test uses this). */
2
- export declare function isApprovalGatedAction(action: string): boolean;
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 captureApprovalSnapshot(action, cleanParams, getProjectId());
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 captureApprovalSnapshot(data.action, withoutApprovalSnapshot(data.params), getProjectId());
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
- if (decision.tier !== 'autonomous') {
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,3 @@
1
+ export declare const ADMIN_TOOLS: Set<string>;
2
+ /** #4308 / #4214: request access is derived, never an admin scope grant or a second list. */
3
+ export declare function isHumanRequestableAdminTool(name: string): boolean;
@@ -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 { isApprovalGatedAction } from './lib/action-approvals.js';
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()) {
@@ -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: 'Create a team invitation for the current project organization.',
178
- inputSchema: { 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.'), email: z.string().email(), role: z.enum(['owner', 'admin', 'member']).default('member') },
179
- }, withApproval('invite_team_member', async ({ email, role }) => {
180
- const { data: project } = await db().from('projects').select('org_id').eq('id', getProjectId()).maybeSingle();
181
- if (!project?.org_id)
182
- return j({ error: 'Project has no organization', code: 'NO_ORG' });
183
- const token = randomBytes(16).toString('hex');
184
- const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString();
185
- const { data, error } = await db().from('org_invitations')
186
- .insert({ org_id: project.org_id, email, role, token, expires_at: expiresAt })
187
- .select('id, expires_at')
188
- .single();
189
- if (error)
190
- return j({ error: error.message });
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. Does not send an email from the MCP server -- relay invite_url to the invitee directly.",
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.117",
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",