@sendinel/mcp-server 1.0.126 → 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.
@@ -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
- let projectName;
303
- try {
304
- const source = approvalStore();
305
- const client = typeof source.schema === 'function' ? source.schema('email') : source;
306
- const { data: project, error: projectError } = await client.from('projects').select('name')
307
- .eq('id', getProjectId()).maybeSingle();
308
- if (!projectError && typeof project?.name === 'string')
309
- projectName = project.name;
310
- }
311
- catch { /* Missing display data omits the project line. */ }
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
- if (data.status === 'approved' && (!isHumanApprovalChannel(data.decided_via) ||
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
  };
@@ -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
+ }
@@ -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
  });
@@ -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.126",
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",