@sendinel/mcp-server 1.0.118 → 1.0.120
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 +37 -94
- 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 -3
- package/build/lib/approval-elicitation-relay.d.ts +19 -0
- package/build/lib/approval-elicitation-relay.js +124 -0
- package/build/lib/approval-elicitation.d.ts +13 -3
- package/build/lib/approval-elicitation.js +17 -7
- package/build/lib/approval-human-callback.js +9 -1
- package/build/lib/approval-human-eligibility.d.ts +13 -0
- package/build/lib/approval-human-eligibility.js +39 -0
- package/build/server.js +7 -28
- 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,8 @@
|
|
|
1
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { GATED_ACTIONS, PROTECTED_ACTIONS } from './approval-actions.js';
|
|
4
|
+
export { isApprovalGatedAction } from './approval-actions.js';
|
|
5
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
1
6
|
import { isTesterRequest, isApplyingTesterRequest } from './tester-policy.js';
|
|
2
7
|
import { logTesterRequestAudit } from './tester-request-audit.js';
|
|
3
8
|
import { approvalStore } from './tester-requests-store.js';
|
|
@@ -9,97 +14,12 @@ import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-sna
|
|
|
9
14
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
10
15
|
import { offerApprovalApp } from './approval-app.js';
|
|
11
16
|
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
17
|
export function getApprovalTier(action, params) {
|
|
18
|
+
// #4308 / #4214: a request-only connection cannot bypass approval via autonomous/preview modes.
|
|
19
|
+
const scopes = requestContext()?.scopes;
|
|
20
|
+
if (scopes && !scopes.includes('admin') && !scopes.includes('tester') && !scopes.includes('agent') &&
|
|
21
|
+
isHumanRequestableAdminTool(action))
|
|
22
|
+
return PROTECTED_ACTIONS.has(action) ? 'protected' : 'gated';
|
|
103
23
|
if (isTesterRequest(action))
|
|
104
24
|
return isApplyingTesterRequest(action) ? 'autonomous' : 'gated';
|
|
105
25
|
// CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
|
|
@@ -342,7 +262,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
342
262
|
const actor = requestContext();
|
|
343
263
|
// Ignore any model-supplied reserved field; freeze facts read by the server.
|
|
344
264
|
const cleanParams = withoutApprovalSnapshot(params);
|
|
345
|
-
const snapshot = await
|
|
265
|
+
const snapshot = await captureSnapshot(action, cleanParams);
|
|
346
266
|
const payload = cleanParams.payload;
|
|
347
267
|
if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
|
|
348
268
|
!approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
|
|
@@ -393,11 +313,25 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
393
313
|
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.`,
|
|
394
314
|
}, { action, params: frozenParams, summary }, getProjectId());
|
|
395
315
|
}
|
|
316
|
+
const registeredApproval = new AsyncLocalStorage();
|
|
317
|
+
/** Read by the hosted bridge only; no scope or token is changed. */
|
|
318
|
+
export function currentAdminApprovalAuthority() {
|
|
319
|
+
return registeredApproval.getStore();
|
|
320
|
+
}
|
|
321
|
+
function captureSnapshot(action, params) {
|
|
322
|
+
if (!isHumanRequestableAdminTool(action))
|
|
323
|
+
return captureApprovalSnapshot(action, params, getProjectId());
|
|
324
|
+
return registeredApproval.run({ action, projectId: getProjectId(), phase: 'preview' }, () => captureApprovalSnapshot(action, params, getProjectId()));
|
|
325
|
+
}
|
|
396
326
|
/**
|
|
397
327
|
* Check if an approval token has been approved.
|
|
398
328
|
* Returns the approval if approved, null if still pending, throws if rejected/expired.
|
|
399
329
|
*/
|
|
400
330
|
export async function checkApproval(approvalId, options = {}) {
|
|
331
|
+
const applied = registeredApproval.getStore();
|
|
332
|
+
if (options.consume && applied?.phase === 'approved' && applied.id === approvalId && applied.action === options.action && applied.projectId === getProjectId()) {
|
|
333
|
+
return { approved: true, status: 'approved', decided_by: applied.decidedBy };
|
|
334
|
+
}
|
|
401
335
|
const { data, error } = await approvalStore()
|
|
402
336
|
.from('action_approvals')
|
|
403
337
|
.select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
|
|
@@ -429,7 +363,11 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
429
363
|
}
|
|
430
364
|
if (data.used_at || data.status === 'used')
|
|
431
365
|
return { approved: false, status: 'consumed' };
|
|
432
|
-
if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)
|
|
366
|
+
if (data.status === 'approved' && (!isHumanApprovalChannel(data.decided_via) ||
|
|
367
|
+
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
|
|
368
|
+
(!['mcp_app', 'mcp_elicitation'].includes(data.decided_via) ||
|
|
369
|
+
!await humanApprovalEligible(data.action, getProjectId(), { projectId: getProjectId(),
|
|
370
|
+
actorId: data.decided_by, oauthTokenId: data.oauth_token_id, authKind: 'oauth', channelVerified: true }))))) {
|
|
433
371
|
return { approved: false, status: 'human_decision_required' };
|
|
434
372
|
}
|
|
435
373
|
if (data.status !== 'approved' || !options.consume) {
|
|
@@ -443,7 +381,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
443
381
|
}
|
|
444
382
|
if (data.params?.__approval_snapshot) {
|
|
445
383
|
try {
|
|
446
|
-
const current = await
|
|
384
|
+
const current = await captureSnapshot(data.action, withoutApprovalSnapshot(data.params));
|
|
447
385
|
if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
|
|
448
386
|
return { approved: false, status: 'target_changed_after_approval' };
|
|
449
387
|
}
|
|
@@ -495,16 +433,21 @@ export function withApproval(action, handler, options = {}) {
|
|
|
495
433
|
const decision = assertChannelCanExecute(action, params, channelContext);
|
|
496
434
|
if (!decision.allowed)
|
|
497
435
|
return toolText(decision.reason ?? 'This channel cannot request this action.');
|
|
498
|
-
|
|
436
|
+
let decidedBy;
|
|
437
|
+
if (options.registeredRequestOnly || decision.tier !== 'autonomous') {
|
|
499
438
|
if (!approval_id) {
|
|
500
439
|
const display = options.display ? await options.display(params) : undefined;
|
|
501
440
|
return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
|
|
502
441
|
}
|
|
503
442
|
const check = await checkApproval(approval_id, { consume: true, action, params });
|
|
443
|
+
decidedBy = check.decided_by;
|
|
504
444
|
if (!check.approved) {
|
|
505
445
|
return toolText(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
506
446
|
}
|
|
507
447
|
}
|
|
448
|
+
if (options.registeredRequestOnly) {
|
|
449
|
+
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: 'approved', decidedBy }, () => handler(args, extra));
|
|
450
|
+
}
|
|
508
451
|
return handler(params, extra);
|
|
509
452
|
};
|
|
510
453
|
}
|
|
@@ -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 { humanApprovalEligible } from './approval-human-eligibility.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';
|
|
@@ -67,7 +68,8 @@ export async function offerApprovalApp(approval) {
|
|
|
67
68
|
const runtime = calls.getStore(), actor = identity();
|
|
68
69
|
if (!runtime || !runtime.canDecide || !actor || !approvalAppEnabled() || approval.tier === 'protected' || !live(approval))
|
|
69
70
|
return {};
|
|
70
|
-
if (await resolveApprovalChannel(runtime.server.server) !== 'mcp_app'
|
|
71
|
+
if (await resolveApprovalChannel(runtime.server.server) !== 'mcp_app' ||
|
|
72
|
+
!await humanApprovalEligible(approval.action, actor.projectId))
|
|
71
73
|
return {};
|
|
72
74
|
return { _meta: { ui: { [APPROVAL_APP.resultUriKey]: resourceUri(approval.id) } } };
|
|
73
75
|
}
|
|
@@ -92,7 +94,7 @@ export function installApprovalApp(server, options = {}, approvalTool = () => fa
|
|
|
92
94
|
if (!bound.canDecide || !actor || !approvalAppEnabled() || await resolveApprovalChannel(server.server) !== 'mcp_app')
|
|
93
95
|
throw unavailable();
|
|
94
96
|
const approval = await loadOwnedPendingApproval(id, actor);
|
|
95
|
-
if (!approval)
|
|
97
|
+
if (!approval || !await humanApprovalEligible(approval.action, actor.projectId))
|
|
96
98
|
throw unavailable();
|
|
97
99
|
// One outstanding render session per approval. Re-render invalidates the old view.
|
|
98
100
|
const token = await mintApprovalAppSession(approval, actor);
|
|
@@ -121,7 +123,7 @@ export function installApprovalApp(server, options = {}, approvalTool = () => fa
|
|
|
121
123
|
return refused();
|
|
122
124
|
const approval = await loadOwnedPendingApproval(approval_id, actor);
|
|
123
125
|
// Single guarded claim before the callback, so concurrent clicks cannot reuse a session.
|
|
124
|
-
if (!approval || !await claimApprovalAppSession(app_session, approval, actor))
|
|
126
|
+
if (!approval || !await humanApprovalEligible(approval.action, actor.projectId) || !await claimApprovalAppSession(app_session, approval, actor))
|
|
125
127
|
return refused();
|
|
126
128
|
const connectionId = `oauth:${actor.oauthTokenId}`;
|
|
127
129
|
const callback = createHumanApprovalCallback({ approvalId: approval.id, projectId: actor.projectId,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ElicitRequest, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
|
|
3
|
+
export declare function isElicitationResponse(body: unknown): boolean;
|
|
4
|
+
/** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
|
|
5
|
+
* No decision can enter this path via a tools/call, model text, or nested tool arguments.
|
|
6
|
+
*/
|
|
7
|
+
export declare function recordElicitationResponse(body: unknown, identity: HumanDecisionIdentity): Promise<void>;
|
|
8
|
+
/** Insert before sending, using the exact wire id (a UUID, since each stateless SDK starts
|
|
9
|
+
* its request counter at zero). Poll the persisted prompt; never await a response on the old
|
|
10
|
+
* SDK Server, because the client's answer necessarily arrives at a fresh Server via POST.
|
|
11
|
+
*/
|
|
12
|
+
export declare function relayElicitation(options: {
|
|
13
|
+
approvalId: string;
|
|
14
|
+
action: string;
|
|
15
|
+
identity: HumanDecisionIdentity;
|
|
16
|
+
params: ElicitRequest['params'];
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
send(message: JSONRPCRequest): Promise<void>;
|
|
19
|
+
}): Promise<'approved' | 'rejected' | 'decision_pending'>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
3
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
4
|
+
const TABLE = 'mcp_elicitation_prompts';
|
|
5
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
6
|
+
function email() {
|
|
7
|
+
const source = approvalStore();
|
|
8
|
+
return typeof source.schema === 'function' ? source.schema('email') : source;
|
|
9
|
+
}
|
|
10
|
+
export function isElicitationResponse(body) {
|
|
11
|
+
const message = object(body);
|
|
12
|
+
return !!message && !('method' in message) && 'id' in message && ('result' in message || 'error' in message);
|
|
13
|
+
}
|
|
14
|
+
/** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
|
|
15
|
+
* No decision can enter this path via a tools/call, model text, or nested tool arguments.
|
|
16
|
+
*/
|
|
17
|
+
export async function recordElicitationResponse(body, identity) {
|
|
18
|
+
const message = object(body);
|
|
19
|
+
if (!isElicitationResponse(message) || message?.jsonrpc !== '2.0' ||
|
|
20
|
+
(typeof message.id !== 'string' && typeof message.id !== 'number') ||
|
|
21
|
+
('error' in message) || !identity.oauthTokenId || !identity.actorId)
|
|
22
|
+
return;
|
|
23
|
+
const result = object(message.result), content = object(result?.content);
|
|
24
|
+
if (result?.action !== 'accept' || (content?.decision !== 'approve' && content?.decision !== 'reject'))
|
|
25
|
+
return;
|
|
26
|
+
try {
|
|
27
|
+
const client = email(), now = new Date().toISOString();
|
|
28
|
+
const { data: prompt, error } = await client.from(TABLE).select('*')
|
|
29
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('jsonrpc_request_id', String(message.id))
|
|
30
|
+
.eq('status', 'pending').gt('expires_at', now).maybeSingle();
|
|
31
|
+
if (error || !prompt || prompt.oauth_token_id !== identity.oauthTokenId || prompt.user_id !== identity.actorId ||
|
|
32
|
+
prompt.jsonrpc_request_id !== String(message.id) || prompt.status !== 'pending' ||
|
|
33
|
+
!(Date.parse(prompt.expires_at) > Date.now()) || prompt.channel !== 'mcp_elicitation')
|
|
34
|
+
return;
|
|
35
|
+
const { data: approval, error: approvalError } = await client.from('action_approvals')
|
|
36
|
+
.select('id, project_id, oauth_token_id, action, tier, status, used_at, expires_at')
|
|
37
|
+
.eq('id', prompt.approval_id).eq('project_id', prompt.project_id)
|
|
38
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('status', 'pending').is('used_at', null)
|
|
39
|
+
.gt('expires_at', now).maybeSingle();
|
|
40
|
+
if (approvalError || !approval || approval.id !== prompt.approval_id || approval.project_id !== prompt.project_id ||
|
|
41
|
+
approval.oauth_token_id !== prompt.oauth_token_id || approval.status !== 'pending' || approval.used_at != null ||
|
|
42
|
+
approval.tier !== 'gated' || typeof approval.action !== 'string' || !approval.action ||
|
|
43
|
+
!(Date.parse(approval.expires_at) > Date.now()))
|
|
44
|
+
return;
|
|
45
|
+
// All-project grants' answer POSTs have no project argument. The authenticated token/user
|
|
46
|
+
// must match the stored prompt; authorize against THAT approval's project, never a body field.
|
|
47
|
+
if (!await humanApprovalEligible(approval.action, prompt.project_id, { ...identity, projectId: prompt.project_id }))
|
|
48
|
+
return;
|
|
49
|
+
const { data, error: rpcError } = await client.rpc('record_elicitation_decision', {
|
|
50
|
+
p_prompt_id: prompt.id, p_oauth_token_id: identity.oauthTokenId, p_user_id: identity.actorId,
|
|
51
|
+
p_decision: content.decision, p_project_id: prompt.project_id, p_action: approval.action,
|
|
52
|
+
});
|
|
53
|
+
if (rpcError || data !== true)
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Never reveal whether a prompt exists, and never fall back to non-atomic REST writes.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Insert before sending, using the exact wire id (a UUID, since each stateless SDK starts
|
|
61
|
+
* its request counter at zero). Poll the persisted prompt; never await a response on the old
|
|
62
|
+
* SDK Server, because the client's answer necessarily arrives at a fresh Server via POST.
|
|
63
|
+
*/
|
|
64
|
+
export async function relayElicitation(options) {
|
|
65
|
+
const pending = 'decision_pending';
|
|
66
|
+
const { identity, signal } = options;
|
|
67
|
+
if (signal.aborted || !identity.projectId || !identity.oauthTokenId || !identity.actorId ||
|
|
68
|
+
!await humanApprovalEligible(options.action, identity.projectId, identity))
|
|
69
|
+
return pending;
|
|
70
|
+
const client = email(), now = Date.now(), requestId = randomUUID();
|
|
71
|
+
const promptId = randomUUID();
|
|
72
|
+
try {
|
|
73
|
+
const { error: expireError } = await client.from(TABLE).update({ status: 'expired' })
|
|
74
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('status', 'pending').lt('expires_at', new Date(now).toISOString());
|
|
75
|
+
if (expireError)
|
|
76
|
+
return pending;
|
|
77
|
+
// Bounded per-token purge; an old backlog is drained in chunks, never one unbounded delete.
|
|
78
|
+
const { data: old, error: oldError } = await client.from(TABLE).select('id')
|
|
79
|
+
.eq('oauth_token_id', identity.oauthTokenId).lt('created_at', new Date(now - 3_600_000).toISOString()).limit(100);
|
|
80
|
+
if (oldError)
|
|
81
|
+
return pending;
|
|
82
|
+
if (old?.length) {
|
|
83
|
+
const { error: deleteError } = await client.from(TABLE).delete().eq('oauth_token_id', identity.oauthTokenId)
|
|
84
|
+
.in('id', old.map((row) => row.id));
|
|
85
|
+
if (deleteError)
|
|
86
|
+
return pending;
|
|
87
|
+
}
|
|
88
|
+
const { data: approval, error: approvalError } = await client.from('action_approvals')
|
|
89
|
+
.select('id, expires_at').eq('id', options.approvalId).eq('project_id', identity.projectId)
|
|
90
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('action', options.action)
|
|
91
|
+
.eq('tier', 'gated').eq('status', 'pending').is('used_at', null).gt('expires_at', new Date(now).toISOString()).maybeSingle();
|
|
92
|
+
if (approvalError || !approval || !(Date.parse(approval.expires_at) > Date.now()) || signal.aborted)
|
|
93
|
+
return pending;
|
|
94
|
+
const { error } = await client.from(TABLE).insert({ id: promptId, approval_id: options.approvalId,
|
|
95
|
+
project_id: identity.projectId, oauth_token_id: identity.oauthTokenId, user_id: identity.actorId,
|
|
96
|
+
jsonrpc_request_id: requestId, channel: 'mcp_elicitation', status: 'pending',
|
|
97
|
+
created_at: new Date(now).toISOString(), expires_at: new Date(Math.min(now + 120_000, Date.parse(approval.expires_at))).toISOString() });
|
|
98
|
+
if (error || signal.aborted)
|
|
99
|
+
return pending;
|
|
100
|
+
// The id is created here and passed unchanged both to the row and the actual transport.
|
|
101
|
+
await options.send({ jsonrpc: '2.0', id: requestId, method: 'elicitation/create', params: options.params });
|
|
102
|
+
while (!signal.aborted && Date.now() < now + 60_000) {
|
|
103
|
+
const { data: prompt, error: readError } = await client.from(TABLE).select('status, answer, expires_at')
|
|
104
|
+
.eq('id', promptId).eq('oauth_token_id', identity.oauthTokenId).eq('user_id', identity.actorId).maybeSingle();
|
|
105
|
+
if (signal.aborted || readError || !prompt || !(Date.parse(prompt.expires_at) > Date.now()))
|
|
106
|
+
return pending;
|
|
107
|
+
if (prompt.status === 'answered')
|
|
108
|
+
return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : pending;
|
|
109
|
+
if (prompt.status !== 'pending')
|
|
110
|
+
return pending;
|
|
111
|
+
await new Promise(resolve => {
|
|
112
|
+
const timer = setTimeout(done, 750);
|
|
113
|
+
function done() { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); }
|
|
114
|
+
signal.addEventListener('abort', done, { once: true });
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
done();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Refused inserts/updates, missing migrations and disconnected streams keep approve_url.
|
|
122
|
+
}
|
|
123
|
+
return pending;
|
|
124
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
|
|
2
|
+
import type { ElicitRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
1
3
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
4
|
import type { RequestId } from '@modelcontextprotocol/sdk/types.js';
|
|
3
5
|
type Decision = 'approve' | 'reject';
|
|
@@ -10,9 +12,17 @@ type HumanCallback = {
|
|
|
10
12
|
}): Promise<boolean>;
|
|
11
13
|
};
|
|
12
14
|
/** Injected at authenticated transport startup, never taken from tool arguments. */
|
|
13
|
-
type TransportBinding = {
|
|
15
|
+
export type TransportBinding = {
|
|
14
16
|
projectId: string;
|
|
15
17
|
userId?: string | null;
|
|
18
|
+
oauthTokenId?: string | null;
|
|
19
|
+
identity?: HumanDecisionIdentity;
|
|
20
|
+
relay?(options: {
|
|
21
|
+
approvalId: string;
|
|
22
|
+
action: string;
|
|
23
|
+
params: ElicitRequest['params'];
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
}): Promise<ElicitationDecision>;
|
|
16
26
|
createHumanDecision(binding: {
|
|
17
27
|
approvalId: string;
|
|
18
28
|
projectId: string;
|
|
@@ -25,9 +35,9 @@ type RequestExtra = {
|
|
|
25
35
|
requestId?: RequestId;
|
|
26
36
|
signal?: AbortSignal;
|
|
27
37
|
};
|
|
28
|
-
export declare function installApprovalElicitation(server: McpServer, binding: TransportBinding): void;
|
|
38
|
+
export declare function installApprovalElicitation(server: Pick<McpServer, 'server'>, binding: TransportBinding): void;
|
|
29
39
|
/** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
|
|
30
|
-
export declare function runWithApprovalElicitation<T>(server: McpServer, extra: RequestExtra | undefined, run: () => T): T;
|
|
40
|
+
export declare function runWithApprovalElicitation<T>(server: Pick<McpServer, 'server'>, extra: RequestExtra | undefined, run: () => T): T;
|
|
31
41
|
type ApprovalFallback = {
|
|
32
42
|
approval_id: string;
|
|
33
43
|
approve_url: string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
2
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
1
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
4
|
import { randomUUID } from 'node:crypto';
|
|
3
5
|
import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
|
|
@@ -33,7 +35,11 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
33
35
|
channel = 'mcp_elicitation_url';
|
|
34
36
|
if (channel !== 'mcp_elicitation_form' && channel !== 'mcp_elicitation_url')
|
|
35
37
|
return pending;
|
|
36
|
-
if (channel === 'mcp_elicitation_form' && !context.userId
|
|
38
|
+
if (channel === 'mcp_elicitation_form' && (!context.userId ||
|
|
39
|
+
!await humanApprovalEligible(frozen.action, projectId, context.identity)))
|
|
40
|
+
return pending;
|
|
41
|
+
if (channel === 'mcp_elicitation_url' && isHumanRequestableAdminTool(frozen.action) &&
|
|
42
|
+
!await humanApprovalEligible(frozen.action, projectId, context.identity))
|
|
37
43
|
return pending;
|
|
38
44
|
if (context.signal?.aborted)
|
|
39
45
|
return pending;
|
|
@@ -58,14 +64,18 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
58
64
|
`Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
|
|
59
65
|
`Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}`].join('\n');
|
|
60
66
|
if (channel === 'mcp_elicitation_form') {
|
|
67
|
+
const params = { mode: 'form', message, requestedSchema: { type: 'object', properties: {
|
|
68
|
+
decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
|
|
69
|
+
note: { type: 'string', title: 'Optional note', maxLength: 500 },
|
|
70
|
+
}, required: ['decision'] } };
|
|
71
|
+
if (context.relay) {
|
|
72
|
+
const status = await Promise.race([context.relay({ approvalId: result.approval_id,
|
|
73
|
+
action: frozen.action, params, signal: controller.signal }), stopped]);
|
|
74
|
+
return controller.signal.aborted ? pending : { ...result, decision_status: status };
|
|
75
|
+
}
|
|
61
76
|
const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
|
|
62
77
|
connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
|
|
63
|
-
const response = await Promise.race([context.server.server.elicitInput(
|
|
64
|
-
requestedSchema: { type: 'object', properties: {
|
|
65
|
-
decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
|
|
66
|
-
note: { type: 'string', title: 'Optional note', maxLength: 500 },
|
|
67
|
-
}, required: ['decision'] },
|
|
68
|
-
}, options), stopped]);
|
|
78
|
+
const response = await Promise.race([context.server.server.elicitInput(params, options), stopped]);
|
|
69
79
|
if (controller.signal.aborted)
|
|
70
80
|
return pending;
|
|
71
81
|
// Decline/cancel are not explicit rejections. The dashboard link remains usable.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
2
|
+
import { requestContext } from '../db.js';
|
|
1
3
|
import { randomUUID } from 'node:crypto';
|
|
2
4
|
import { approvalStore } from './tester-requests-store.js';
|
|
3
5
|
/**
|
|
@@ -18,11 +20,17 @@ export function createHumanApprovalCallback(binding) {
|
|
|
18
20
|
completed = true;
|
|
19
21
|
const source = approvalStore();
|
|
20
22
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
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 ||
|
|
26
|
+
requestContext()?.actorId !== binding.userId ||
|
|
27
|
+
!await humanApprovalEligible(approval.action, binding.projectId))
|
|
28
|
+
return false;
|
|
21
29
|
const now = new Date().toISOString();
|
|
22
30
|
const { data, error } = await client.from('action_approvals')
|
|
23
31
|
.update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
|
|
24
32
|
decided_by: binding.userId, decided_at: now })
|
|
25
|
-
.eq('id', binding.approvalId).eq('project_id', binding.projectId)
|
|
33
|
+
.eq('id', binding.approvalId).eq('project_id', binding.projectId).eq('action', approval.action)
|
|
26
34
|
.eq('status', 'pending').is('used_at', null).gt('expires_at', now)
|
|
27
35
|
.select('id, payload_hash').maybeSingle();
|
|
28
36
|
if (error || !data)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type HumanDecisionIdentity = {
|
|
2
|
+
projectId?: string;
|
|
3
|
+
actorId?: string | null;
|
|
4
|
+
oauthTokenId?: string | null;
|
|
5
|
+
authKind?: string | null;
|
|
6
|
+
channelVerified?: boolean;
|
|
7
|
+
scopes?: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
/** Shared transport boundary, never populated from tool arguments. Re-read the grant and
|
|
10
|
+
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
11
|
+
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
12
|
+
*/
|
|
13
|
+
export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined): Promise<boolean>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { requestContext } from '../db.js';
|
|
2
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
3
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
4
|
+
/** Shared transport boundary, never populated from tool arguments. Re-read the grant and
|
|
5
|
+
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
6
|
+
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
7
|
+
*/
|
|
8
|
+
export async function humanApprovalEligible(action, projectId, identity = requestContext()) {
|
|
9
|
+
if (!identity || identity.channelVerified !== true || identity.authKind !== 'oauth' ||
|
|
10
|
+
!identity.actorId || !identity.oauthTokenId || identity.projectId !== projectId || !projectId ||
|
|
11
|
+
identity.scopes?.some(scope => scope === 'tester' || scope === 'agent'))
|
|
12
|
+
return false;
|
|
13
|
+
try {
|
|
14
|
+
const source = approvalStore();
|
|
15
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
16
|
+
const { data: grant, error } = await client.from('mcp_oauth_tokens')
|
|
17
|
+
.select('id, user_id, connection_role, scopes, revoked_at, expires_at')
|
|
18
|
+
.eq('id', identity.oauthTokenId).maybeSingle();
|
|
19
|
+
if (error || !grant || grant.id !== identity.oauthTokenId || grant.user_id !== identity.actorId ||
|
|
20
|
+
grant.connection_role !== null || grant.revoked_at !== null ||
|
|
21
|
+
!(Date.parse(grant.expires_at) > Date.now()) || !Array.isArray(grant.scopes) ||
|
|
22
|
+
grant.scopes.some((scope) => scope === 'tester' || scope === 'agent'))
|
|
23
|
+
return false;
|
|
24
|
+
if (!isHumanRequestableAdminTool(action))
|
|
25
|
+
return true;
|
|
26
|
+
// Same lookup as dashboard getProjectOrgRole: projects.org_id -> org_members.role.
|
|
27
|
+
// Unlike permissive mocks, PostgREST errors (including {data, error}) grant nothing.
|
|
28
|
+
const { data: project, error: projectError } = await client.from('projects')
|
|
29
|
+
.select('org_id').eq('id', projectId).maybeSingle();
|
|
30
|
+
if (projectError || !project?.org_id)
|
|
31
|
+
return false;
|
|
32
|
+
const { data: member, error: roleError } = await client.from('org_members')
|
|
33
|
+
.select('role').eq('org_id', project.org_id).eq('user_id', identity.actorId).maybeSingle();
|
|
34
|
+
return !roleError && (member?.role === 'owner' || member?.role === 'admin');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.120",
|
|
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",
|