@sendinel/mcp-server 1.0.125 → 1.0.127
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 +2 -0
- package/build/lib/action-approvals.js +32 -14
- package/build/lib/approval-chat-confirm.js +2 -2
- package/build/lib/sandbox-approvals.d.ts +31 -0
- package/build/lib/sandbox-approvals.js +55 -0
- package/build/tools/analytics.js +9 -5
- package/build/tools/sms.js +3 -1
- package/package.json +1 -1
|
@@ -70,6 +70,7 @@ type AdminApprovalAuthority = {
|
|
|
70
70
|
projectId: string;
|
|
71
71
|
phase: 'preview' | 'approved';
|
|
72
72
|
decidedBy?: string;
|
|
73
|
+
decisionChannel?: 'sandbox';
|
|
73
74
|
};
|
|
74
75
|
/** Read by the hosted bridge only; no scope or token is changed. */
|
|
75
76
|
export declare function currentAdminApprovalAuthority(): Readonly<AdminApprovalAuthority> | undefined;
|
|
@@ -85,6 +86,7 @@ export declare function checkApproval(approvalId: string, options?: {
|
|
|
85
86
|
approved: boolean;
|
|
86
87
|
status: string;
|
|
87
88
|
decided_by?: string;
|
|
89
|
+
decision_channel?: 'sandbox';
|
|
88
90
|
}>;
|
|
89
91
|
type ToolResult = {
|
|
90
92
|
content: Array<{
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sandboxApprovalEligible, trySandboxApproval } from './sandbox-approvals.js';
|
|
1
2
|
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
2
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import { GATED_ACTIONS, PROTECTED_ACTIONS } from './approval-actions.js';
|
|
@@ -290,6 +291,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
290
291
|
.single();
|
|
291
292
|
if (error)
|
|
292
293
|
throw new Error(`Failed to create approval request: ${error.message}`);
|
|
294
|
+
let projectName;
|
|
295
|
+
let sandboxDisplayRead = false;
|
|
296
|
+
const sandboxApproved = tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
|
|
293
297
|
if (isTesterRequest(action))
|
|
294
298
|
await logTesterRequestAudit('tester_request', action, data.id, null);
|
|
295
299
|
fireWebhookEvent('approval.requested', {
|
|
@@ -299,18 +303,23 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
299
303
|
requested_by: apiKeyId ?? null,
|
|
300
304
|
}, getProjectId()).catch(() => { });
|
|
301
305
|
// Display-only lookup: never add mutable project names to the frozen params/hash.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
306
|
+
if (!sandboxDisplayRead)
|
|
307
|
+
try {
|
|
308
|
+
const source = approvalStore();
|
|
309
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
310
|
+
const { data: project, error: projectError } = await client.from('projects').select('name')
|
|
311
|
+
.eq('id', getProjectId()).maybeSingle();
|
|
312
|
+
if (!projectError && typeof project?.name === 'string')
|
|
313
|
+
projectName = project.name;
|
|
314
|
+
}
|
|
315
|
+
catch { /* Missing display data omits the project line. */ }
|
|
312
316
|
const approveUrl = approvalPageUrl(data.id);
|
|
313
317
|
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary;
|
|
318
|
+
if (sandboxApproved) {
|
|
319
|
+
return { requires_approval: true, approval_id: data.id, tier, action, summary, expires_at: expiresAt,
|
|
320
|
+
approve_via: 'sandbox', approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
321
|
+
message: 'Sandbox project: approved automatically\nCall this tool again with the same arguments plus approval_id to run it.' };
|
|
322
|
+
}
|
|
314
323
|
const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
|
|
315
324
|
? `\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.`
|
|
316
325
|
: '';
|
|
@@ -352,11 +361,11 @@ function captureSnapshot(action, params) {
|
|
|
352
361
|
export async function checkApproval(approvalId, options = {}) {
|
|
353
362
|
const applied = registeredApproval.getStore();
|
|
354
363
|
if (options.consume && applied?.phase === 'approved' && applied.id === approvalId && applied.action === options.action && applied.projectId === getProjectId()) {
|
|
355
|
-
return { approved: true, status: 'approved', decided_by: applied.decidedBy };
|
|
364
|
+
return { approved: true, status: 'approved', decided_by: applied.decidedBy, ...(applied.decisionChannel ? { decision_channel: applied.decisionChannel } : {}) };
|
|
356
365
|
}
|
|
357
366
|
const { data, error } = await approvalStore()
|
|
358
367
|
.from('action_approvals')
|
|
359
|
-
.select('action, status, decided_by, decided_via, oauth_token_id, expires_at, used_at, params, payload_hash, requested_at')
|
|
368
|
+
.select('project_id, tier, action, status, decided_by, decided_via, oauth_token_id, expires_at, used_at, params, payload_hash, requested_at')
|
|
360
369
|
.eq('id', approvalId)
|
|
361
370
|
.eq('project_id', getProjectId())
|
|
362
371
|
.maybeSingle();
|
|
@@ -391,7 +400,12 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
391
400
|
!await humanApprovalEligible(data.action, getProjectId(), requestContext(), true))) {
|
|
392
401
|
return { approved: false, status: 'human_decision_required' };
|
|
393
402
|
}
|
|
394
|
-
|
|
403
|
+
const sandboxDecision = data.decided_via === 'sandbox';
|
|
404
|
+
if (sandboxDecision && (data.project_id !== getProjectId() || data.tier !== 'gated' || PROTECTED_ACTIONS.has(data.action) ||
|
|
405
|
+
!await sandboxApprovalEligible(getProjectId(), data.action))) {
|
|
406
|
+
return { approved: false, status: 'sandbox_decision_no_longer_eligible' };
|
|
407
|
+
}
|
|
408
|
+
if (data.status === 'approved' && !sandboxDecision && (!isHumanApprovalChannel(data.decided_via) ||
|
|
395
409
|
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
|
|
396
410
|
(!['mcp_app', 'mcp_elicitation', 'chat_confirm'].includes(data.decided_via) ||
|
|
397
411
|
!await humanApprovalEligible(data.action, getProjectId(), { projectId: getProjectId(),
|
|
@@ -405,6 +419,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
405
419
|
approved: data.status === 'approved',
|
|
406
420
|
status: data.status,
|
|
407
421
|
decided_by: data.decided_by,
|
|
422
|
+
...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
|
|
408
423
|
};
|
|
409
424
|
}
|
|
410
425
|
if (data.params?.__approval_snapshot) {
|
|
@@ -443,6 +458,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
443
458
|
approved: true,
|
|
444
459
|
status: 'approved',
|
|
445
460
|
decided_by: claimed.decided_by,
|
|
461
|
+
...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
|
|
446
462
|
};
|
|
447
463
|
}
|
|
448
464
|
function toolText(value) {
|
|
@@ -464,6 +480,7 @@ export function withApproval(action, handler, options = {}) {
|
|
|
464
480
|
if (!decision.allowed)
|
|
465
481
|
return toolText(decision.reason ?? 'This channel cannot request this action.');
|
|
466
482
|
let decidedBy;
|
|
483
|
+
let decisionChannel;
|
|
467
484
|
if (options.registeredRequestOnly || decision.tier !== 'autonomous') {
|
|
468
485
|
if (!approval_id) {
|
|
469
486
|
const display = options.display ? await options.display(params) : undefined;
|
|
@@ -471,12 +488,13 @@ export function withApproval(action, handler, options = {}) {
|
|
|
471
488
|
}
|
|
472
489
|
const check = await checkApproval(approval_id, { consume: true, action, params });
|
|
473
490
|
decidedBy = check.decided_by;
|
|
491
|
+
decisionChannel = check.decision_channel;
|
|
474
492
|
if (!check.approved) {
|
|
475
493
|
return toolText(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
476
494
|
}
|
|
477
495
|
}
|
|
478
496
|
if (options.registeredRequestOnly) {
|
|
479
|
-
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: 'approved', decidedBy }, () => handler(args, extra));
|
|
497
|
+
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: 'approved', decidedBy, decisionChannel }, () => handler(args, extra));
|
|
480
498
|
}
|
|
481
499
|
return handler(params, extra);
|
|
482
500
|
};
|
|
@@ -45,9 +45,9 @@ export async function confirmApproval(input) {
|
|
|
45
45
|
const now = Date.now();
|
|
46
46
|
const requested = Date.parse(approval.requested_at);
|
|
47
47
|
if (!(Date.parse(approval.expires_at) > now))
|
|
48
|
-
return refuse('chat_confirm_expired', 'Approval has expired. Request a new approval.');
|
|
48
|
+
return refuse('chat_confirm_expired', 'Approval has expired. Request a new approval, or tap the approve_url.');
|
|
49
49
|
if (!Number.isFinite(requested) || requested > now || requested < now - 600_000) {
|
|
50
|
-
return refuse('chat_confirm_too_old', 'Chat confirm requires an approval requested within the last 10 minutes. Request a new approval.');
|
|
50
|
+
return refuse('chat_confirm_too_old', 'Chat confirm requires an approval requested within the last 10 minutes. Request a new approval, or tap the approve_url.');
|
|
51
51
|
}
|
|
52
52
|
if (input.action !== approval.action)
|
|
53
53
|
return refuse('chat_confirm_action_mismatch', 'Action does not match the approval.');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Conservative classification: shared org credentials and grants require an all-sandbox org. */
|
|
2
|
+
export declare const SANDBOX_ADMIN_SCOPE: {
|
|
3
|
+
readonly action_ccpa_request: 'project';
|
|
4
|
+
readonly create_agent_mailbox: 'project';
|
|
5
|
+
readonly create_api_key: 'org';
|
|
6
|
+
readonly delete_provider_credential: 'org';
|
|
7
|
+
readonly delete_segment: 'project';
|
|
8
|
+
readonly delete_signature: 'project';
|
|
9
|
+
readonly delete_subscriber_data: 'project';
|
|
10
|
+
readonly delete_template: 'project';
|
|
11
|
+
readonly delete_webhook_subscription: 'project';
|
|
12
|
+
readonly disconnect_platform: 'org';
|
|
13
|
+
readonly identify_inactive: 'project';
|
|
14
|
+
readonly install_connector_schema: 'project';
|
|
15
|
+
readonly install_connector_view: 'project';
|
|
16
|
+
readonly invite_team_member: 'org';
|
|
17
|
+
readonly merge_subscribers: 'project';
|
|
18
|
+
readonly remove_suppression: 'project';
|
|
19
|
+
readonly remove_team_member: 'org';
|
|
20
|
+
readonly resync_connector: 'project';
|
|
21
|
+
readonly revoke_api_key: 'org';
|
|
22
|
+
readonly revoke_invitation: 'org';
|
|
23
|
+
readonly rotate_custom_webhook_secret: 'project';
|
|
24
|
+
readonly set_connector_mapping: 'project';
|
|
25
|
+
readonly set_member_project_access: 'org';
|
|
26
|
+
readonly set_team_member_role: 'org';
|
|
27
|
+
readonly tenant_export_full: 'project';
|
|
28
|
+
};
|
|
29
|
+
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
30
|
+
export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void): Promise<boolean>;
|
|
31
|
+
export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void): Promise<boolean>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { adminDb } from '../db.js';
|
|
2
|
+
import { PROTECTED_ACTIONS } from './approval-actions.js';
|
|
3
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
4
|
+
/** Conservative classification: shared org credentials and grants require an all-sandbox org. */
|
|
5
|
+
export const SANDBOX_ADMIN_SCOPE = {
|
|
6
|
+
action_ccpa_request: 'project', create_agent_mailbox: 'project', create_api_key: 'org',
|
|
7
|
+
delete_provider_credential: 'org', delete_segment: 'project', delete_signature: 'project',
|
|
8
|
+
delete_subscriber_data: 'project', delete_template: 'project', delete_webhook_subscription: 'project',
|
|
9
|
+
disconnect_platform: 'org', identify_inactive: 'project', install_connector_schema: 'project',
|
|
10
|
+
install_connector_view: 'project', invite_team_member: 'org', merge_subscribers: 'project',
|
|
11
|
+
remove_suppression: 'project', remove_team_member: 'org', resync_connector: 'project',
|
|
12
|
+
revoke_api_key: 'org', revoke_invitation: 'org', rotate_custom_webhook_secret: 'project',
|
|
13
|
+
set_connector_mapping: 'project', set_member_project_access: 'org', set_team_member_role: 'org',
|
|
14
|
+
tenant_export_full: 'project',
|
|
15
|
+
};
|
|
16
|
+
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
17
|
+
export async function sandboxApprovalEligible(projectId, action, onProjectRead) {
|
|
18
|
+
if (PROTECTED_ACTIONS.has(action))
|
|
19
|
+
return false;
|
|
20
|
+
try {
|
|
21
|
+
const source = adminDb().schema('email');
|
|
22
|
+
const { data, error } = await source.from('projects').select('id, org_id, sandbox_mode, name')
|
|
23
|
+
.eq('id', projectId).maybeSingle();
|
|
24
|
+
onProjectRead?.(!error && typeof data?.name === 'string' ? data.name : undefined);
|
|
25
|
+
if (error || data?.id !== projectId || data.sandbox_mode !== true)
|
|
26
|
+
return false;
|
|
27
|
+
if (!isHumanRequestableAdminTool(action))
|
|
28
|
+
return true;
|
|
29
|
+
const scope = SANDBOX_ADMIN_SCOPE[action];
|
|
30
|
+
if (scope === 'project')
|
|
31
|
+
return true;
|
|
32
|
+
if (scope !== 'org' || typeof data.org_id !== 'string')
|
|
33
|
+
return false;
|
|
34
|
+
const others = await source.from('projects').select('id').eq('org_id', data.org_id)
|
|
35
|
+
.eq('sandbox_mode', false).limit(1);
|
|
36
|
+
return !others.error && Array.isArray(others.data) && others.data.length === 0;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead) {
|
|
43
|
+
if (!await sandboxApprovalEligible(projectId, action, onProjectRead))
|
|
44
|
+
return false;
|
|
45
|
+
try {
|
|
46
|
+
// The RPC performs one conditional UPDATE + audit INSERT in one transaction.
|
|
47
|
+
const { data, error } = await adminDb().schema('email').rpc('approve_sandbox_action', {
|
|
48
|
+
p_approval_id: id, p_project_id: projectId, p_action: action, p_payload_hash: payloadHash,
|
|
49
|
+
});
|
|
50
|
+
return !error && data === true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/build/tools/analytics.js
CHANGED
|
@@ -292,10 +292,10 @@ export function registerAnalyticsTools(server) {
|
|
|
292
292
|
const ownedOrgIds = Array.from(new Set((ownedOrgs ?? []).map((row) => row.org_id).filter(Boolean)));
|
|
293
293
|
const [{ data: directProjects, error: directProjectsError }, { data: ownedProjects, error: ownedProjectsError }] = await Promise.all([
|
|
294
294
|
directProjectIds.length
|
|
295
|
-
? adminDb().from('projects').select('id, name, org_id').in('id', directProjectIds)
|
|
295
|
+
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('id', directProjectIds)
|
|
296
296
|
: Promise.resolve({ data: [], error: null }),
|
|
297
297
|
ownedOrgIds.length
|
|
298
|
-
? adminDb().from('projects').select('id, name, org_id').in('org_id', ownedOrgIds)
|
|
298
|
+
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('org_id', ownedOrgIds)
|
|
299
299
|
: Promise.resolve({ data: [], error: null }),
|
|
300
300
|
]);
|
|
301
301
|
if (directProjectsError)
|
|
@@ -323,6 +323,8 @@ export function registerAnalyticsTools(server) {
|
|
|
323
323
|
name: project.name,
|
|
324
324
|
org_id: project.org_id,
|
|
325
325
|
org_name: project.org_id ? orgNames.get(project.org_id) ?? null : null,
|
|
326
|
+
sandbox_mode: project.sandbox_mode,
|
|
327
|
+
sandbox_allowed_domains: project.sandbox_allowed_domains,
|
|
326
328
|
active: project.id === ctx.projectId,
|
|
327
329
|
})),
|
|
328
330
|
});
|
|
@@ -332,19 +334,19 @@ export function registerAnalyticsTools(server) {
|
|
|
332
334
|
// Look up the org for this project, then list all projects in that org.
|
|
333
335
|
const { data: project, error: projErr } = await adminDb()
|
|
334
336
|
.from('projects')
|
|
335
|
-
.select('id, name, org_id')
|
|
337
|
+
.select('id, name, org_id, sandbox_mode, sandbox_allowed_domains')
|
|
336
338
|
.eq('id', projectId)
|
|
337
339
|
.maybeSingle();
|
|
338
340
|
if (projErr || !project)
|
|
339
341
|
return txt(`Error: could not find project ${projectId}`);
|
|
340
342
|
if (!project.org_id) {
|
|
341
343
|
// No org — return just this project
|
|
342
|
-
return j({ projects: [{ id: project.id, name: project.name, current: true }] });
|
|
344
|
+
return j({ projects: [{ id: project.id, name: project.name, sandbox_mode: project.sandbox_mode, sandbox_allowed_domains: project.sandbox_allowed_domains, current: true }] });
|
|
343
345
|
}
|
|
344
346
|
// List all projects in the same organization
|
|
345
347
|
const { data: projects, error: listErr } = await adminDb()
|
|
346
348
|
.from('projects')
|
|
347
|
-
.select('id, name')
|
|
349
|
+
.select('id, name, sandbox_mode, sandbox_allowed_domains')
|
|
348
350
|
.eq('org_id', project.org_id)
|
|
349
351
|
.order('name');
|
|
350
352
|
if (listErr)
|
|
@@ -360,6 +362,8 @@ export function registerAnalyticsTools(server) {
|
|
|
360
362
|
projects: (projects ?? []).map((p) => ({
|
|
361
363
|
id: p.id,
|
|
362
364
|
name: p.name,
|
|
365
|
+
sandbox_mode: p.sandbox_mode,
|
|
366
|
+
sandbox_allowed_domains: p.sandbox_allowed_domains,
|
|
363
367
|
current: p.id === projectId,
|
|
364
368
|
})),
|
|
365
369
|
});
|
package/build/tools/sms.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { assertProjectAllowsSms } from "@sendinel/domain";
|
|
1
2
|
import { randomUUID } from 'crypto';
|
|
2
3
|
import { withApproval } from '../lib/action-approvals.js';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { decrypt } from '../crypto.js';
|
|
5
|
-
import { db } from '../db.js';
|
|
6
|
+
import { adminDb, db } from '../db.js';
|
|
6
7
|
import { getProjectId } from '../project.js';
|
|
7
8
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
8
9
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
@@ -91,6 +92,7 @@ async function readJsonSafe(res) {
|
|
|
91
92
|
return res.json().catch(() => ({}));
|
|
92
93
|
}
|
|
93
94
|
async function sendViaProvider(provider, credentialsEnc, fromNumber, toNumber, body, webhookUrl, trackingToken) {
|
|
95
|
+
await assertProjectAllowsSms(adminDb(), getProjectId());
|
|
94
96
|
const credentials = parseCredentials(credentialsEnc);
|
|
95
97
|
const meta = countSmsSegments(body);
|
|
96
98
|
if (provider === 'twilio') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.127",
|
|
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",
|