@sendinel/mcp-server 1.0.142 → 1.0.144
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/README.md +10 -2
- package/build/lib/action-approvals.d.ts +2 -0
- package/build/lib/action-approvals.js +11 -9
- package/build/lib/approval-exempt.js +2 -0
- package/build/lib/approval-tool-decisions.d.ts +15 -0
- package/build/lib/approval-tool-decisions.js +103 -0
- package/build/lib/sandbox-audience.d.ts +30 -0
- package/build/lib/sandbox-audience.js +48 -0
- package/build/server.js +4 -2
- package/build/tools/approvals.js +22 -2
- package/build/tools/campaigns.js +5 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
|
|
|
18
18
|
[event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
|
|
19
19
|
inputs, idempotency, subscription requirements, and conditional step examples.
|
|
20
20
|
|
|
21
|
-
Sendinel currently exposes 35 tool groups and
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 346 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
22
22
|
|
|
23
23
|
| Group | Count | Purpose |
|
|
24
24
|
|-------|-------|---------|
|
|
@@ -27,7 +27,7 @@ Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4
|
|
|
27
27
|
| `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
|
|
28
28
|
| `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
|
|
29
29
|
| `analytics` | 15 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
|
|
30
|
-
| `approvals` |
|
|
30
|
+
| `approvals` | 5 | Poll/list approvals, person OAuth approval, write-scoped cancellation, opted-in chat confirmation |
|
|
31
31
|
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
32
32
|
| `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
|
|
33
33
|
| `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
|
|
@@ -58,6 +58,14 @@ Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4
|
|
|
58
58
|
| `warmup` | 4 | IP/domain warmup schedule control |
|
|
59
59
|
| `webhooks` | 5 | Webhook subscription management |
|
|
60
60
|
|
|
61
|
+
`cancel_approval({approval_id})` rejects an unexpired, unused pending/approved
|
|
62
|
+
request in the current project for any write-scoped credential.
|
|
63
|
+
`approve_approval({approval_id})` requires the person's own live OAuth connection
|
|
64
|
+
(`connection_role: NULL`) and its own pending request in that project. Tester,
|
|
65
|
+
agent, API key, service-token and scoped-JWT credentials cannot approve. Sends
|
|
66
|
+
and deletes still need approval; the user may answer through their AI without
|
|
67
|
+
another verification prompt. See [approval tools](../../docs/mcp-tools.md#approve_approval).
|
|
68
|
+
|
|
61
69
|
## Install / run
|
|
62
70
|
|
|
63
71
|
```bash
|
|
@@ -50,6 +50,8 @@ export interface ApprovalResult {
|
|
|
50
50
|
summary: string;
|
|
51
51
|
expires_at: string;
|
|
52
52
|
approve_via: string;
|
|
53
|
+
decided_via?: 'sandbox';
|
|
54
|
+
decided_by?: null;
|
|
53
55
|
/** One-tap approval page; works on a phone and needs no project switching (#3661). */
|
|
54
56
|
approve_url: string;
|
|
55
57
|
preview_text: string;
|
|
@@ -137,7 +137,7 @@ export function approvalExcerpt(html, max = 200) {
|
|
|
137
137
|
function broadcastSummary(d) {
|
|
138
138
|
if (!d.subject)
|
|
139
139
|
return null;
|
|
140
|
-
const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : null;
|
|
140
|
+
const count = typeof d.recipient_count === 'number' ? `${d.recipient_count.toLocaleString('en-US')} recipient${d.recipient_count === 1 ? '' : 's'}` : d.recipient_count === null ? 'unknown recipients' : null;
|
|
141
141
|
const audience = d.segment_name ? `segment "${d.segment_name}"` : (d.audience_label ? String(d.audience_label) : 'the chosen audience');
|
|
142
142
|
const when = d.send_at ? ` at ${d.send_at}` : '';
|
|
143
143
|
const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
|
|
@@ -323,11 +323,13 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
323
323
|
}
|
|
324
324
|
catch { /* Missing display data omits the project line. */ }
|
|
325
325
|
const approveUrl = approvalPageUrl(data.id);
|
|
326
|
-
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary;
|
|
326
|
+
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName, ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) }).plain_summary;
|
|
327
327
|
if (sandboxApproved) {
|
|
328
328
|
return { requires_approval: true, approval_id: data.id, tier, action, summary, expires_at: expiresAt,
|
|
329
|
-
approve_via: 'sandbox', approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
330
|
-
message:
|
|
329
|
+
approve_via: 'sandbox', decided_via: 'sandbox', decided_by: null, approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
330
|
+
message: action === 'send_broadcast'
|
|
331
|
+
? 'Sandbox project: approved automatically (no person decided it). Call send_broadcast again with only approval_id to send.'
|
|
332
|
+
: 'Sandbox project: approved automatically\nCall this tool again with the same arguments plus approval_id to run it.' };
|
|
331
333
|
}
|
|
332
334
|
const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
|
|
333
335
|
? `\nAsk the user in plain chat: "${previewText.split('\n')[0].trim()}". Only after they say yes, call confirm_approval with approval_id, action, echo (the first line of preview_text) and user_reply (their exact words), then call the original tool again with approval_id.`
|
|
@@ -403,10 +405,10 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
403
405
|
}
|
|
404
406
|
if (data.used_at || data.status === 'used')
|
|
405
407
|
return { approved: false, status: 'consumed' };
|
|
406
|
-
//
|
|
407
|
-
if (data.status === 'approved' && data.decided_via
|
|
408
|
+
// Tool decisions stay bound to the same live human grant at consumption, for every action.
|
|
409
|
+
if (data.status === 'approved' && ['chat_confirm', 'mcp_person'].includes(data.decided_via) &&
|
|
408
410
|
(requestContext()?.oauthTokenId !== data.oauth_token_id || requestContext()?.actorId !== data.decided_by ||
|
|
409
|
-
!await humanApprovalEligible(data.action, getProjectId(), requestContext(),
|
|
411
|
+
!await humanApprovalEligible(data.action, getProjectId(), requestContext(), data.decided_via === 'chat_confirm'))) {
|
|
410
412
|
return { approved: false, status: 'human_decision_required' };
|
|
411
413
|
}
|
|
412
414
|
const sandboxDecision = data.decided_via === 'sandbox';
|
|
@@ -416,7 +418,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
416
418
|
}
|
|
417
419
|
if (data.status === 'approved' && !sandboxDecision && (!isHumanApprovalChannel(data.decided_via) ||
|
|
418
420
|
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
|
|
419
|
-
(!['mcp_app', 'mcp_elicitation', 'chat_confirm'].includes(data.decided_via) ||
|
|
421
|
+
(!['mcp_app', 'mcp_elicitation', 'chat_confirm', 'mcp_person'].includes(data.decided_via) ||
|
|
420
422
|
!await humanApprovalEligible(data.action, getProjectId(), { projectId: getProjectId(),
|
|
421
423
|
actorId: data.decided_by, oauthTokenId: data.oauth_token_id, authKind: 'oauth', channelVerified: true }))))) {
|
|
422
424
|
return { approved: false, status: 'human_decision_required' };
|
|
@@ -457,7 +459,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
457
459
|
.eq('decided_via', data.decided_via)
|
|
458
460
|
.eq('params', JSON.stringify(data.params))
|
|
459
461
|
.gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
|
460
|
-
if (data.decided_via
|
|
462
|
+
if (['chat_confirm', 'mcp_person'].includes(data.decided_via))
|
|
461
463
|
claimQuery.eq('oauth_token_id', requestContext().oauthTokenId).eq('decided_by', requestContext().actorId);
|
|
462
464
|
const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
|
|
463
465
|
const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
|
|
@@ -20,8 +20,10 @@ export const APPROVAL_EXEMPT = {
|
|
|
20
20
|
add_suppression: "only stops mail to an address; reduces sending",
|
|
21
21
|
add_synced_folder: "starts syncing a Drive folder into the project own media library; inbound only",
|
|
22
22
|
add_white_label_domain: "adds a pending branding domain record; reversible and sends nothing",
|
|
23
|
+
approve_approval: "is the approval step itself: only a person-role OAuth connection can decide its own pending request, bound to that token and project, audited; sends nothing",
|
|
23
24
|
approve_data_proposal: "turns a proposal into an AI draft for review; sends nothing",
|
|
24
25
|
approve_draft: "marks a draft ready; sending still requires a gated send or activation",
|
|
26
|
+
cancel_approval: "only moves an unused approval to rejected (never grants anything); reduces what can run",
|
|
25
27
|
cancel_send_queue_rows: "cancels queued mail before it sends; reduces sending",
|
|
26
28
|
clone_campaign: "creates a draft copy of a campaign; nothing sends until activated (gated)",
|
|
27
29
|
clone_sequence_template: "creates a draft campaign from a template; nothing sends until activated (gated)",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function cancelApproval(approvalId: string, transportClientName?: string): Promise<{
|
|
2
|
+
code: string;
|
|
3
|
+
error: string;
|
|
4
|
+
} | {
|
|
5
|
+
approval_id: string;
|
|
6
|
+
status: 'rejected';
|
|
7
|
+
}>;
|
|
8
|
+
export declare function approveApproval(approvalId: string, transportClientName?: string): Promise<{
|
|
9
|
+
code: string;
|
|
10
|
+
error: string;
|
|
11
|
+
} | {
|
|
12
|
+
approval_id: string;
|
|
13
|
+
decision_status: 'approved';
|
|
14
|
+
next: string;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { requestContext } from '../db.js';
|
|
2
|
+
import { getProjectId } from '../project.js';
|
|
3
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
4
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
5
|
+
const refuse = (code, error) => ({ code, error });
|
|
6
|
+
const store = () => approvalStore().schema('email');
|
|
7
|
+
/** OAuth names come from the registered client, never a model-supplied argument. */
|
|
8
|
+
async function clientName(fallback) {
|
|
9
|
+
const actor = requestContext();
|
|
10
|
+
if (!actor?.oauthTokenId)
|
|
11
|
+
return fallback ?? actor?.agentLabel ?? null;
|
|
12
|
+
const client = store();
|
|
13
|
+
const { data: grant, error: grantError } = await client.from('mcp_oauth_tokens')
|
|
14
|
+
.select('client_id').eq('id', actor.oauthTokenId).maybeSingle();
|
|
15
|
+
if (grantError || !grant?.client_id)
|
|
16
|
+
throw new Error('Unable to identify the OAuth client');
|
|
17
|
+
const { data, error } = await client.from('mcp_oauth_clients')
|
|
18
|
+
.select('client_name').eq('client_id', grant.client_id).maybeSingle();
|
|
19
|
+
if (error || typeof data?.client_name !== 'string')
|
|
20
|
+
throw new Error('Unable to identify the OAuth client');
|
|
21
|
+
return data.client_name;
|
|
22
|
+
}
|
|
23
|
+
export async function cancelApproval(approvalId, transportClientName) {
|
|
24
|
+
const actor = requestContext();
|
|
25
|
+
if (!actor?.scopes?.some(scope => scope === 'write' || scope === 'admin')) {
|
|
26
|
+
return refuse('approval_forbidden', 'Cancelling an approval requires write scope.');
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const projectId = getProjectId();
|
|
30
|
+
const name = await clientName(transportClientName);
|
|
31
|
+
const now = new Date().toISOString();
|
|
32
|
+
// A single conditional write arbitrates cancellation versus approval/consumption.
|
|
33
|
+
// Preserve the original decision provenance; cancellation never grants authority.
|
|
34
|
+
const { data, error } = await store().from('action_approvals')
|
|
35
|
+
.update({ status: 'rejected' })
|
|
36
|
+
.eq('id', approvalId).eq('project_id', projectId)
|
|
37
|
+
.in('status', ['pending', 'approved']).is('used_at', null).gt('expires_at', now)
|
|
38
|
+
.select('id').maybeSingle();
|
|
39
|
+
if (error)
|
|
40
|
+
return refuse('approval_update_failed', 'Unable to cancel the approval. Check its status before retrying.');
|
|
41
|
+
if (!data)
|
|
42
|
+
return refuse('approval_not_cancellable', 'No unexpired, unused pending or approved approval found in this project.');
|
|
43
|
+
const { error: auditError } = await store().from('audit_log').insert({
|
|
44
|
+
project_id: projectId, actor_type: actor.actorType ?? actor.authKind ?? 'api_key',
|
|
45
|
+
actor_id: actor.actorId ?? actor.keyId ?? actor.oauthTokenId ?? null,
|
|
46
|
+
action: 'approval.cancelled', resource_type: 'approval', resource_id: approvalId,
|
|
47
|
+
details: { status: 'rejected', client_name: name, auth_kind: actor.authKind ?? null,
|
|
48
|
+
oauth_token_id: actor.oauthTokenId ?? null, api_key_id: actor.keyId ?? null },
|
|
49
|
+
});
|
|
50
|
+
if (auditError)
|
|
51
|
+
return refuse('approval_audit_failed', 'Approval cancelled, but audit failed. Check its status before retrying.');
|
|
52
|
+
return { approval_id: approvalId, status: 'rejected' };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return refuse('approval_failed', 'Cancellation failed. Check approval status before retrying.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function approveApproval(approvalId, transportClientName) {
|
|
59
|
+
const actor = requestContext();
|
|
60
|
+
const projectId = getProjectId();
|
|
61
|
+
// Reuse the same live-grant identification as the app/elicitation path (#4312).
|
|
62
|
+
if (!await humanApprovalEligible('approve_approval', projectId, actor)) {
|
|
63
|
+
return refuse('approval_forbidden', 'Approval requires your own verified person-role OAuth connection in this project.');
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const client = store();
|
|
67
|
+
const { data: approval, error: loadError } = await client.from('action_approvals')
|
|
68
|
+
.select('action, params, payload_hash')
|
|
69
|
+
.eq('id', approvalId).eq('project_id', projectId).eq('oauth_token_id', actor.oauthTokenId)
|
|
70
|
+
.eq('status', 'pending').is('used_at', null).gt('expires_at', new Date().toISOString()).maybeSingle();
|
|
71
|
+
if (loadError)
|
|
72
|
+
return refuse('approval_lookup_failed', 'Unable to verify the approval. Nothing was recorded.');
|
|
73
|
+
if (!approval)
|
|
74
|
+
return refuse('approval_not_pending', 'No unexpired, unused pending approval found for this connection and project.');
|
|
75
|
+
if (!await humanApprovalEligible(approval.action, projectId, actor)) {
|
|
76
|
+
return refuse('approval_forbidden', 'This approval requires a person with the required project role.');
|
|
77
|
+
}
|
|
78
|
+
const name = await clientName(transportClientName);
|
|
79
|
+
const now = new Date().toISOString();
|
|
80
|
+
const decision = client.from('action_approvals')
|
|
81
|
+
.update({ status: 'approved', decided_via: 'mcp_person', decided_by: actor.actorId, decided_at: now })
|
|
82
|
+
.eq('id', approvalId).eq('project_id', projectId).eq('oauth_token_id', actor.oauthTokenId)
|
|
83
|
+
.eq('action', approval.action).eq('status', 'pending').is('used_at', null).gt('expires_at', now)
|
|
84
|
+
.eq('params', JSON.stringify(approval.params));
|
|
85
|
+
const bound = approval.payload_hash == null ? decision.is('payload_hash', null) : decision.eq('payload_hash', approval.payload_hash);
|
|
86
|
+
const { data, error } = await bound.select('id, payload_hash').maybeSingle();
|
|
87
|
+
if (error || !data)
|
|
88
|
+
return refuse('approval_not_pending', 'Approval is already decided, used or no longer valid. Request a new approval.');
|
|
89
|
+
const { error: auditError } = await client.from('audit_log').insert({
|
|
90
|
+
project_id: projectId, actor_type: 'user', actor_id: actor.actorId,
|
|
91
|
+
action: 'approval.submitted', resource_type: 'approval', resource_id: approvalId,
|
|
92
|
+
details: { status: 'approved', channel: 'mcp_person', client_name: name,
|
|
93
|
+
connection_id: `oauth:${actor.oauthTokenId}`, payload_hash: data.payload_hash },
|
|
94
|
+
});
|
|
95
|
+
if (auditError)
|
|
96
|
+
return refuse('approval_audit_failed', 'Approval recorded, but audit failed. Check its status before retrying.');
|
|
97
|
+
return { approval_id: approvalId, decision_status: 'approved',
|
|
98
|
+
next: `Call ${approval.action} again with the same arguments plus approval_id.` };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return refuse('approval_failed', 'Approval failed. Check approval status before retrying.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Display only: never narrow or otherwise change the frozen enrollment payload. */
|
|
2
|
+
export declare function sandboxAudience(projectId: string, contactIds: string[]): Promise<{
|
|
3
|
+
recipient_count: number;
|
|
4
|
+
audience_matched_count?: undefined;
|
|
5
|
+
sandbox?: undefined;
|
|
6
|
+
} | {
|
|
7
|
+
recipient_count: number;
|
|
8
|
+
audience_matched_count: number;
|
|
9
|
+
sandbox: {
|
|
10
|
+
active: true;
|
|
11
|
+
allowed_domains: string[] | null;
|
|
12
|
+
filtered_out: number;
|
|
13
|
+
count_unknown?: undefined;
|
|
14
|
+
};
|
|
15
|
+
} | {
|
|
16
|
+
recipient_count: null;
|
|
17
|
+
audience_matched_count: number;
|
|
18
|
+
sandbox: {
|
|
19
|
+
active: boolean | null;
|
|
20
|
+
allowed_domains: string[] | null;
|
|
21
|
+
filtered_out: null;
|
|
22
|
+
count_unknown: boolean;
|
|
23
|
+
};
|
|
24
|
+
}>;
|
|
25
|
+
export declare function broadcastApprovalNext(approval: {
|
|
26
|
+
approve_via?: string;
|
|
27
|
+
decision_status?: string;
|
|
28
|
+
}, sandbox?: {
|
|
29
|
+
allowed_domains: string[] | null;
|
|
30
|
+
}): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { chunkIds, isSandboxDomain, matchSandboxDomains, readAllRows } from '@sendinel/domain';
|
|
2
|
+
import { adminDb, db } from '../db.js';
|
|
3
|
+
/** Display only: never narrow or otherwise change the frozen enrollment payload. */
|
|
4
|
+
export async function sandboxAudience(projectId, contactIds) {
|
|
5
|
+
let active = null;
|
|
6
|
+
let allowedDomains = null;
|
|
7
|
+
try {
|
|
8
|
+
const { data, error } = await adminDb().schema('email').from('projects')
|
|
9
|
+
.select('sandbox_mode, sandbox_allowed_domains').eq('id', projectId).maybeSingle();
|
|
10
|
+
if (error || !data || typeof data.sandbox_mode !== 'boolean')
|
|
11
|
+
throw new Error('Sandbox policy unavailable');
|
|
12
|
+
active = data.sandbox_mode;
|
|
13
|
+
if (!active)
|
|
14
|
+
return { recipient_count: contactIds.length };
|
|
15
|
+
if (!Array.isArray(data.sandbox_allowed_domains) || !data.sandbox_allowed_domains.every(isSandboxDomain)) {
|
|
16
|
+
throw new Error('Sandbox domains unavailable');
|
|
17
|
+
}
|
|
18
|
+
allowedDomains = data.sandbox_allowed_domains;
|
|
19
|
+
const emails = new Map();
|
|
20
|
+
for (const chunk of chunkIds(contactIds)) {
|
|
21
|
+
const rows = await readAllRows(db().schema('email').from('subscribers').select('id, email').eq('project_id', projectId).in('id', chunk));
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
if (typeof row.email !== 'string')
|
|
24
|
+
throw new Error('Contact email unavailable');
|
|
25
|
+
emails.set(row.id, row.email);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (contactIds.some(id => !emails.has(id)))
|
|
29
|
+
throw new Error('Contact lookup incomplete');
|
|
30
|
+
const count = contactIds.filter(id => !matchSandboxDomains(allowedDomains, { to: emails.get(id) })).length;
|
|
31
|
+
return { recipient_count: count, audience_matched_count: contactIds.length,
|
|
32
|
+
sandbox: { active, allowed_domains: allowedDomains, filtered_out: contactIds.length - count } };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { recipient_count: null, audience_matched_count: contactIds.length,
|
|
36
|
+
sandbox: { active, allowed_domains: allowedDomains, filtered_out: null, count_unknown: true } };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function broadcastApprovalNext(approval, sandbox) {
|
|
40
|
+
if (approval.approve_via === 'sandbox' && approval.decision_status === 'approved') {
|
|
41
|
+
const domains = sandbox?.allowed_domains;
|
|
42
|
+
return 'Sandbox project: this was approved automatically (no person decided it). Call send_broadcast again with only approval_id to send. ' +
|
|
43
|
+
(domains ? domains.length ? `Only recipients on ${domains.join(', ')} can be delivered, subject to send-time policy checks.` : 'No sandbox domains are allowed; no recipients can be delivered.'
|
|
44
|
+
: 'Sandbox domains could not be loaded; the deliverable recipient count is unknown.') +
|
|
45
|
+
' Do not say it was sent before that call succeeds.';
|
|
46
|
+
}
|
|
47
|
+
return 'Show the user the subject, recipient count and approve_url. After a person approves, call send_broadcast with only approval_id. Do not say it was sent before that call succeeds.';
|
|
48
|
+
}
|
package/build/server.js
CHANGED
|
@@ -87,6 +87,7 @@ export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
|
|
|
87
87
|
// ── Scope enforcement ─────────────────────────────────────────────────────
|
|
88
88
|
// Maps tool names to required scope. Mirrors dashboard's WRITE_TOOLS/ADMIN_TOOLS.
|
|
89
89
|
const WRITE_TOOLS = new Set([
|
|
90
|
+
'cancel_approval', 'approve_approval',
|
|
90
91
|
'recheck_domain_dns',
|
|
91
92
|
'mark_agent_message', 'draft_agent_reply', 'create_agent_mailbox',
|
|
92
93
|
'add_subscriber', 'identify_subscriber', 'emit_contact_event', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
|
|
@@ -172,7 +173,7 @@ export function isAgentConnection(scopes) {
|
|
|
172
173
|
return !!scopes?.includes(AGENT_CONNECTION_SCOPE);
|
|
173
174
|
}
|
|
174
175
|
export function agentConnectionAllowsTool(name) {
|
|
175
|
-
return ['list_agent_inbox', 'read_agent_message', 'get_agent_thread', 'mark_agent_message',
|
|
176
|
+
return ['cancel_approval', 'list_agent_inbox', 'read_agent_message', 'get_agent_thread', 'mark_agent_message',
|
|
176
177
|
'draft_agent_reply', 'search_tools', 'describe_tool', 'invoke_tool'].includes(name);
|
|
177
178
|
}
|
|
178
179
|
export function agentConnectionRefusal(name) {
|
|
@@ -186,6 +187,7 @@ export function testerConnectionAllowsTool(name) {
|
|
|
186
187
|
// Retain the existing campaign activation path. A globally gated tool is not by itself
|
|
187
188
|
// permission for a tester: sends, deletes and access/infrastructure writes stay refused.
|
|
188
189
|
return getRequiredScope(name) === 'read'
|
|
190
|
+
|| name === 'cancel_approval'
|
|
189
191
|
|| TESTER_REQUEST_TOOLS.has(name)
|
|
190
192
|
|| name === 'activate_campaigns'
|
|
191
193
|
|| name === 'update_campaign_status';
|
|
@@ -321,7 +323,7 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
|
321
323
|
const required = getRequiredScope(name);
|
|
322
324
|
const tester = isTesterConnection(scopes);
|
|
323
325
|
const agent = isAgentConnection(scopes);
|
|
324
|
-
const allowed = () => agent ? agentConnectionAllowsTool(name) : tester
|
|
326
|
+
const allowed = () => name === 'cancel_approval' ? scopeAllows(scopes, 'write') : agent ? agentConnectionAllowsTool(name) : tester
|
|
325
327
|
? testerConnectionAllowsTool(name)
|
|
326
328
|
: scopeAllows(scopes, required) || (scopeAllows(scopes, 'read') && isHumanRequestableAdminTool(name));
|
|
327
329
|
if (!allowed()) {
|
package/build/tools/approvals.js
CHANGED
|
@@ -4,9 +4,29 @@ import { getProjectId } from '../project.js';
|
|
|
4
4
|
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
5
5
|
import { checkApproval } from '../lib/action-approvals.js';
|
|
6
6
|
import { confirmApproval, confirmApprovalInput } from '../lib/approval-chat-confirm.js';
|
|
7
|
+
import { approveApproval, cancelApproval } from '../lib/approval-tool-decisions.js';
|
|
8
|
+
import { approvalClientName } from '../lib/approval-capabilities.js';
|
|
7
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
8
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
9
11
|
export function registerApprovalTools(server, options) {
|
|
12
|
+
server.registerTool('cancel_approval', {
|
|
13
|
+
title: 'Cancel Approval',
|
|
14
|
+
description: 'Cancel an unexpired, unused pending or approved action approval in this project. Requires write scope. Sets it to rejected; cannot approve or undo cancellation.',
|
|
15
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16
|
+
inputSchema: { approval_id: z.string().uuid() },
|
|
17
|
+
}, async ({ approval_id }) => {
|
|
18
|
+
const result = await cancelApproval(approval_id, server.server && approvalClientName(server.server));
|
|
19
|
+
return { ...j(result), ...('code' in result ? { isError: true } : {}) };
|
|
20
|
+
});
|
|
21
|
+
server.registerTool('approve_approval', {
|
|
22
|
+
title: 'Approve Approval',
|
|
23
|
+
description: "Record the person's approval through their own OAuth connection for this project. Only person-role OAuth connections may approve their own pending, unexpired, unused requests. Tester, agent, API key, service token and scoped JWT credentials cannot approve. Call only when the person asks to approve; then retry the original action with approval_id.",
|
|
24
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
25
|
+
inputSchema: { approval_id: z.string().uuid() },
|
|
26
|
+
}, async ({ approval_id }) => {
|
|
27
|
+
const result = await approveApproval(approval_id, server.server && approvalClientName(server.server));
|
|
28
|
+
return { ...j(result), ...('code' in result ? { isError: true } : {}) };
|
|
29
|
+
});
|
|
10
30
|
if (options?.chatConfirmEnabled !== false && (!options?.scopes || options.chatConfirmEnabled === true) &&
|
|
11
31
|
!options?.scopes?.some(scope => scope === 'tester' || scope === 'agent')) {
|
|
12
32
|
server.registerTool('confirm_approval', {
|
|
@@ -48,7 +68,7 @@ export function registerApprovalTools(server, options) {
|
|
|
48
68
|
}, async () => {
|
|
49
69
|
const { data, error } = await db()
|
|
50
70
|
.from('action_approvals')
|
|
51
|
-
.select('id, action, tier, channel, summary, status, requested_at, expires_at')
|
|
71
|
+
.select('id, action, tier, channel, summary, status, requested_at, expires_at, decided_via, decided_by')
|
|
52
72
|
.eq('project_id', getProjectId())
|
|
53
73
|
.eq('status', 'pending')
|
|
54
74
|
.gt('expires_at', new Date().toISOString())
|
|
@@ -56,6 +76,6 @@ export function registerApprovalTools(server, options) {
|
|
|
56
76
|
.limit(25);
|
|
57
77
|
if (error)
|
|
58
78
|
return toolError(dbToolError(error));
|
|
59
|
-
return j({ pending: data ?? [], count: data?.length ?? 0 });
|
|
79
|
+
return j({ pending: (data ?? []).map((row) => ({ ...row, ...(row.decided_via === 'sandbox' ? { decision_channel: 'sandbox' } : {}) })), count: data?.length ?? 0 });
|
|
60
80
|
});
|
|
61
81
|
}
|
package/build/tools/campaigns.js
CHANGED
|
@@ -18,6 +18,7 @@ import { checkPlanLimit } from '../plan-limits.js';
|
|
|
18
18
|
import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
|
|
19
19
|
import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
20
20
|
import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
21
|
+
import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
|
|
21
22
|
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
22
23
|
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
23
24
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
@@ -1578,6 +1579,7 @@ export function registerCampaignTools(server) {
|
|
|
1578
1579
|
else {
|
|
1579
1580
|
content = { subject: subject, body_html: html, body_text: body_text ?? approvalExcerpt(html, 100000), blocks: null };
|
|
1580
1581
|
}
|
|
1582
|
+
const delivery = await sandboxAudience(projectId, resolved.contactIds);
|
|
1581
1583
|
const campaignName = name ?? `Broadcast - ${content.subject.slice(0, 120)}`;
|
|
1582
1584
|
const payload = {
|
|
1583
1585
|
site_id: site.id,
|
|
@@ -1603,18 +1605,18 @@ export function registerCampaignTools(server) {
|
|
|
1603
1605
|
subject: content.subject,
|
|
1604
1606
|
segment_name: segment_id ? segmentName : null,
|
|
1605
1607
|
audience_label: segment_id ? null : `"${segmentName}"`,
|
|
1606
|
-
recipient_count:
|
|
1608
|
+
recipient_count: delivery.recipient_count,
|
|
1607
1609
|
body_excerpt: approvalExcerpt(content.body_html),
|
|
1608
1610
|
send_at: sendAt,
|
|
1609
1611
|
});
|
|
1610
1612
|
return j({
|
|
1611
1613
|
...approval,
|
|
1612
1614
|
rendered: { subject: content.subject, body_html: stripActiveEmailContent(content.body_html).html, body_text: content.body_text },
|
|
1613
|
-
|
|
1615
|
+
...delivery,
|
|
1614
1616
|
audience: { label: segmentName, segment_id: segment_id ?? null, ...heldBackLines(resolved) },
|
|
1615
1617
|
site: { id: site.id, name: site.name, from_email: site.from_email },
|
|
1616
1618
|
send_at: sendAt ?? 'right after approval',
|
|
1617
|
-
next:
|
|
1619
|
+
next: broadcastApprovalNext(approval, delivery.sandbox),
|
|
1618
1620
|
});
|
|
1619
1621
|
});
|
|
1620
1622
|
server.registerTool('clone_campaign', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.144",
|
|
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",
|