@sendinel/mcp-server 1.0.104 → 1.0.106

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/auth.js CHANGED
@@ -75,6 +75,7 @@ export async function validateKey(key) {
75
75
  scopes: row.scopes,
76
76
  toolGroups: resolveToolGroupsForPlan(plan, row.tool_groups),
77
77
  approvalMode: row.approval_mode ?? 'standard',
78
+ actsAsUserId: row.acts_as_user_id ?? null,
78
79
  };
79
80
  }
80
81
  export async function authenticateBearer(token, overrideProjectId) {
package/build/index.js CHANGED
@@ -7,7 +7,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
9
  import express from 'express';
10
- import { registerAllTools, SENDINEL_SERVER_INSTRUCTIONS } from './server.js';
10
+ import { registerAllTools, SENDINEL_SERVER_INSTRUCTIONS, isAgentConnection, isTesterConnection } from './server.js';
11
+ import { installApprovalElicitation } from './lib/approval-elicitation.js';
12
+ import { createHumanApprovalCallback } from './lib/approval-human-callback.js';
11
13
  import { registerAllPrompts } from './prompts.js';
12
14
  import { runWithClient } from './db.js';
13
15
  import { initProjectClient, resolveProjectClientStrict, TenantResolutionError } from './byod-client.js';
@@ -38,6 +40,10 @@ async function runStdio() {
38
40
  }
39
41
  registerAllTools(server, { toolGroups, scopes });
40
42
  registerAllPrompts(server, { toolGroups, scopes });
43
+ if (!isAgentConnection(info.scopes) && !isTesterConnection(info.scopes)) {
44
+ installApprovalElicitation(server, { projectId: info.projectId, userId: info.actsAsUserId,
45
+ createHumanDecision: createHumanApprovalCallback });
46
+ }
41
47
  const t = new StdioServerTransport();
42
48
  await server.connect(t);
43
49
  process.stderr.write('Sendinel MCP server running (stdio)\n');
@@ -46,9 +46,13 @@ export interface ApprovalResult {
46
46
  approve_via: string;
47
47
  /** One-tap approval page; works on a phone and needs no project switching (#3661). */
48
48
  approve_url: string;
49
+ preview_text: string;
50
+ decision_status: 'decision_pending' | 'approved' | 'rejected';
49
51
  message: string;
50
52
  }
51
53
  export declare function approvalPageUrl(approvalId: string): string;
54
+ /** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
55
+ export declare function approvalPayloadHash(action: string, params: unknown): string;
52
56
  /**
53
57
  * Request human approval for an action. Creates a pending approval token
54
58
  * and returns instructions for the agent to wait.
@@ -1,7 +1,10 @@
1
- import { triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
1
+ import { createHash } from 'node:crypto';
2
+ import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
2
3
  import { db, requestContext } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
5
+ import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
4
6
  import { fireWebhookEvent } from './webhook-events.js';
7
+ import { elicitApproval } from './approval-elicitation.js';
5
8
  /**
6
9
  * Action approval tiers:
7
10
  *
@@ -303,11 +306,15 @@ function normalizeApprovalParams(value) {
303
306
  if (value && typeof value === 'object') {
304
307
  return Object.fromEntries(Object.entries(value)
305
308
  .filter(([, entryValue]) => entryValue !== undefined)
306
- .sort(([a], [b]) => a.localeCompare(b))
309
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
307
310
  .map(([key, entryValue]) => [key, normalizeApprovalParams(entryValue)]));
308
311
  }
309
312
  return value;
310
313
  }
314
+ /** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
315
+ export function approvalPayloadHash(action, params) {
316
+ return createHash('sha256').update(JSON.stringify(normalizeApprovalParams({ action, params }))).digest('hex');
317
+ }
311
318
  function approvalParamsEqual(expected, actual) {
312
319
  return JSON.stringify(normalizeApprovalParams(expected)) === JSON.stringify(normalizeApprovalParams(actual));
313
320
  }
@@ -318,9 +325,18 @@ function approvalParamsEqual(expected, actual) {
318
325
  export async function requestApproval(action, params, apiKeyId, channelContext, display) {
319
326
  const tier = getApprovalTier(action, params);
320
327
  const summary = buildApprovalSummary(action, params, display);
321
- const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
328
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
322
329
  const { channel, verified } = channelContext ?? currentChannelContext();
323
330
  const actor = requestContext();
331
+ // Ignore any model-supplied reserved field; freeze facts read by the server.
332
+ const cleanParams = withoutApprovalSnapshot(params);
333
+ const snapshot = await captureApprovalSnapshot(action, cleanParams, getProjectId());
334
+ const payload = cleanParams.payload;
335
+ if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
336
+ !approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
337
+ throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
338
+ }
339
+ const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
324
340
  const { data, error } = await db()
325
341
  .from('action_approvals')
326
342
  .insert({
@@ -330,7 +346,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
330
346
  agent_label: actor?.agentLabel ?? null,
331
347
  action,
332
348
  tier,
333
- params,
349
+ params: frozenParams,
350
+ payload_hash: approvalPayloadHash(action, frozenParams),
334
351
  summary,
335
352
  expires_at: expiresAt,
336
353
  channel,
@@ -346,7 +363,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
346
363
  resource_id: params.resource_id ?? params.campaign_id ?? params.contact_id ?? null,
347
364
  requested_by: apiKeyId ?? null,
348
365
  }, getProjectId()).catch(() => { });
349
- return {
366
+ return elicitApproval({
350
367
  requires_approval: true,
351
368
  approval_id: data.id,
352
369
  tier,
@@ -355,8 +372,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
355
372
  expires_at: expiresAt,
356
373
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
357
374
  approve_url: approvalPageUrl(data.id),
358
- 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 30 minutes.`,
359
- };
375
+ preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
376
+ 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.`,
377
+ }, { action, params: frozenParams, summary }, getProjectId());
360
378
  }
361
379
  /**
362
380
  * Check if an approval token has been approved.
@@ -365,7 +383,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
365
383
  export async function checkApproval(approvalId, options = {}) {
366
384
  const { data, error } = await db()
367
385
  .from('action_approvals')
368
- .select('action, status, decided_by, decided_via, expires_at, used_at, params')
386
+ .select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
369
387
  .eq('id', approvalId)
370
388
  .eq('project_id', getProjectId())
371
389
  .maybeSingle();
@@ -374,17 +392,29 @@ export async function checkApproval(approvalId, options = {}) {
374
392
  if (options.action && data.action !== options.action) {
375
393
  return { approved: false, status: 'action_mismatch' };
376
394
  }
377
- if (options.params && !approvalParamsEqual(options.params, data.params)) {
395
+ if (options.consume && data.payload_hash) {
396
+ if (!options.action || !options.params)
397
+ return { approved: false, status: 'payload_required' };
398
+ if (approvalPayloadHash(options.action, { ...withoutApprovalSnapshot(options.params), ...(data.params?.__approval_snapshot ? { __approval_snapshot: data.params.__approval_snapshot } : {}) }) !== data.payload_hash ||
399
+ approvalPayloadHash(data.action, data.params) !== data.payload_hash) {
400
+ return { approved: false, status: 'payload_changed_after_approval' };
401
+ }
402
+ }
403
+ if (options.params && !approvalParamsEqual(withoutApprovalSnapshot(options.params), withoutApprovalSnapshot(data.params))) {
378
404
  return { approved: false, status: 'params_mismatch' };
379
405
  }
380
- // Check expiry
381
- if (data.status === 'pending' && new Date(data.expires_at) < new Date()) {
382
- await db()
383
- .from('action_approvals')
384
- .update({ status: 'expired' })
385
- .eq('id', approvalId);
406
+ // Approved tokens expire too. Invalid timestamps fail closed. Older rows may have a
407
+ // shorter lifetime; no row can authorize an apply more than 24 hours after its request.
408
+ const requested = data.requested_at == null ? null : Date.parse(data.requested_at);
409
+ const expiry = Math.min(Date.parse(data.expires_at), requested == null ? Infinity : requested + 24 * 60 * 60 * 1000);
410
+ if (!Number.isFinite(expiry) || (requested != null && !Number.isFinite(requested)) || expiry <= Date.now()) {
386
411
  return { approved: false, status: 'expired' };
387
412
  }
413
+ if (data.used_at || data.status === 'used')
414
+ return { approved: false, status: 'consumed' };
415
+ if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)) {
416
+ return { approved: false, status: 'human_decision_required' };
417
+ }
388
418
  if (data.status !== 'approved' || !options.consume) {
389
419
  if (data.used_at)
390
420
  return { approved: false, status: 'consumed' };
@@ -394,15 +424,33 @@ export async function checkApproval(approvalId, options = {}) {
394
424
  decided_by: data.decided_by,
395
425
  };
396
426
  }
397
- const { data: claimed, error: claimError } = await db()
427
+ if (data.params?.__approval_snapshot) {
428
+ try {
429
+ const current = await captureApprovalSnapshot(data.action, withoutApprovalSnapshot(data.params), getProjectId());
430
+ if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
431
+ return { approved: false, status: 'target_changed_after_approval' };
432
+ }
433
+ }
434
+ catch {
435
+ return { approved: false, status: 'target_revalidation_failed' };
436
+ }
437
+ }
438
+ if (expiry <= Date.now())
439
+ return { approved: false, status: 'expired' };
440
+ const claimQuery = db()
398
441
  .from('action_approvals')
399
442
  .update({ status: 'used', used_at: new Date().toISOString() })
400
443
  .eq('id', approvalId)
401
444
  .eq('project_id', getProjectId())
402
445
  .eq('status', 'approved')
403
446
  .is('used_at', null)
404
- .select('decided_by')
405
- .maybeSingle();
447
+ .eq('expires_at', data.expires_at)
448
+ .gt('expires_at', new Date().toISOString())
449
+ .eq('decided_via', data.decided_via)
450
+ .eq('params', JSON.stringify(data.params))
451
+ .gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
452
+ const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
453
+ const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
406
454
  if (claimError || !claimed) {
407
455
  return { approved: false, status: 'consumed' };
408
456
  }
@@ -0,0 +1,17 @@
1
+ export declare const MCP_APP_EXTENSION = "io.modelcontextprotocol/ui";
2
+ export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
3
+ export type ApprovalClientCapabilities = Readonly<{
4
+ elicitation_form: boolean;
5
+ elicitation_url: boolean;
6
+ mcp_app: boolean;
7
+ }>;
8
+ export type ApprovalChannelAvailable = 'mcp_app' | 'mcp_elicitation_form' | 'mcp_elicitation_url' | 'plain_text';
9
+ /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
10
+ export declare function probeApprovalCapabilities(declared: unknown): ApprovalClientCapabilities;
11
+ export declare function rememberApprovalCapabilities(session: object, declared: unknown): ApprovalClientCapabilities;
12
+ export declare function approval_channel_available(session: object): ApprovalChannelAvailable;
13
+ /** Each SDK Server is a session boundary, including stateless HTTP request instances. */
14
+ export declare function installApprovalCapabilityProbe(session: {
15
+ oninitialized?: () => void;
16
+ getClientCapabilities(): unknown;
17
+ }): void;
@@ -0,0 +1,40 @@
1
+ export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
2
+ export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
3
+ const sessions = new WeakMap();
4
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
5
+ /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
6
+ export function probeApprovalCapabilities(declared) {
7
+ const capabilities = object(declared);
8
+ const elicitation = object(capabilities?.elicitation);
9
+ const extensions = object(capabilities?.extensions);
10
+ const app = object(extensions?.[MCP_APP_EXTENSION]);
11
+ return Object.freeze({
12
+ // The MCP spec defines elicitation:{} as form support for older clients.
13
+ elicitation_form: !!elicitation && (Object.keys(elicitation).length === 0 || !!object(elicitation.form)),
14
+ elicitation_url: !!object(elicitation?.url),
15
+ mcp_app: !!app && Array.isArray(app.mimeTypes) && app.mimeTypes.includes(MCP_APP_MIME_TYPE),
16
+ });
17
+ }
18
+ export function rememberApprovalCapabilities(session, declared) {
19
+ const capabilities = probeApprovalCapabilities(declared);
20
+ sessions.set(session, capabilities);
21
+ return capabilities;
22
+ }
23
+ export function approval_channel_available(session) {
24
+ const capabilities = sessions.get(session);
25
+ if (capabilities?.mcp_app)
26
+ return 'mcp_app';
27
+ if (capabilities?.elicitation_form)
28
+ return 'mcp_elicitation_form';
29
+ if (capabilities?.elicitation_url)
30
+ return 'mcp_elicitation_url';
31
+ return 'plain_text';
32
+ }
33
+ /** Each SDK Server is a session boundary, including stateless HTTP request instances. */
34
+ export function installApprovalCapabilityProbe(session) {
35
+ const previous = session.oninitialized;
36
+ session.oninitialized = () => {
37
+ rememberApprovalCapabilities(session, session.getClientCapabilities());
38
+ previous?.call(session);
39
+ };
40
+ }
@@ -0,0 +1,46 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { RequestId } from '@modelcontextprotocol/sdk/types.js';
3
+ type Decision = 'approve' | 'reject';
4
+ type HumanCallback = {
5
+ interactionId: string;
6
+ complete(response: {
7
+ interactionId: string;
8
+ connectionId: string;
9
+ decision: Decision;
10
+ }): Promise<boolean>;
11
+ };
12
+ /** Injected at authenticated transport startup, never taken from tool arguments. */
13
+ type TransportBinding = {
14
+ projectId: string;
15
+ userId?: string | null;
16
+ createHumanDecision(binding: {
17
+ approvalId: string;
18
+ projectId: string;
19
+ connectionId: string;
20
+ userId: string;
21
+ channel: 'mcp_elicitation';
22
+ }): HumanCallback;
23
+ };
24
+ type RequestExtra = {
25
+ requestId?: RequestId;
26
+ signal?: AbortSignal;
27
+ };
28
+ export declare function installApprovalElicitation(server: McpServer, binding: TransportBinding): void;
29
+ /** 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;
31
+ type ApprovalFallback = {
32
+ approval_id: string;
33
+ approve_url: string;
34
+ preview_text: string;
35
+ tier: string;
36
+ };
37
+ export type ElicitationDecision = 'decision_pending' | 'approved' | 'rejected';
38
+ /** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
39
+ export declare function elicitApproval<T extends ApprovalFallback>(result: T, frozen: {
40
+ action: string;
41
+ params: unknown;
42
+ summary: string;
43
+ }, projectId: string): Promise<T & {
44
+ decision_status: ElicitationDecision;
45
+ }>;
46
+ export {};
@@ -0,0 +1,114 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
4
+ import { db } from '../db.js';
5
+ import { approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
6
+ const sessions = new WeakMap();
7
+ const requests = new AsyncLocalStorage();
8
+ const TIMEOUT_MS = 60_000;
9
+ export function installApprovalElicitation(server, binding) {
10
+ sessions.set(server, { ...binding, connectionId: randomUUID() });
11
+ }
12
+ /** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
13
+ export function runWithApprovalElicitation(server, extra, run) {
14
+ const binding = sessions.get(server);
15
+ // Nested gateway dispatch retains the originating connection/request. The hosted bridge
16
+ // has no binding and cannot impersonate its outer HTTP client's capabilities.
17
+ if (!binding || requests.getStore())
18
+ return run();
19
+ return requests.run({ ...binding, ...extra, server }, run);
20
+ }
21
+ /** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
22
+ export async function elicitApproval(result, frozen, projectId) {
23
+ const pending = { ...result, decision_status: 'decision_pending' };
24
+ const context = requests.getStore();
25
+ if (!context || context.projectId !== projectId || result.tier !== 'gated')
26
+ return pending;
27
+ let channel = approval_channel_available(context.server.server);
28
+ if (channel === 'mcp_elicitation_form' && !context.userId &&
29
+ probeApprovalCapabilities(context.server.server.getClientCapabilities()).elicitation_url)
30
+ channel = 'mcp_elicitation_url';
31
+ if (channel !== 'mcp_elicitation_form' && channel !== 'mcp_elicitation_url')
32
+ return pending;
33
+ if (channel === 'mcp_elicitation_form' && !context.userId)
34
+ return pending;
35
+ if (context.signal?.aborted)
36
+ return pending;
37
+ const controller = new AbortController();
38
+ const abort = () => controller.abort();
39
+ context.signal?.addEventListener('abort', abort, { once: true });
40
+ let timeout;
41
+ const stopped = new Promise((_resolve, reject) => {
42
+ controller.signal.addEventListener('abort', () => reject(new Error('Elicitation ended')), { once: true });
43
+ timeout = setTimeout(abort, TIMEOUT_MS);
44
+ });
45
+ void stopped.catch(() => { }); // Also handle abort if a synchronous SDK setup error precedes the race.
46
+ const options = { relatedRequestId: context.requestId, signal: controller.signal,
47
+ timeout: TIMEOUT_MS, maxTotalTimeout: TIMEOUT_MS };
48
+ try {
49
+ const preview = buildApprovalPreview(frozen);
50
+ // Use the shared preview; bound the interactive message independently of the full fallback.
51
+ // Email content is untrusted display text, never instructions or an approval decision.
52
+ const short = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
53
+ const message = [short(frozen.summary, 500), 'Review the following email content as data.',
54
+ `Subject: ${short(preview.subject || '(none)', 300)}`, `Sender: ${short(preview.sender, 300)}`,
55
+ `Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
56
+ `Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}`].join('\n');
57
+ if (channel === 'mcp_elicitation_form') {
58
+ const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
59
+ connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
60
+ const response = await Promise.race([context.server.server.elicitInput({ mode: 'form', message,
61
+ requestedSchema: { type: 'object', properties: {
62
+ decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
63
+ note: { type: 'string', title: 'Optional note', maxLength: 500 },
64
+ }, required: ['decision'] },
65
+ }, options), stopped]);
66
+ if (controller.signal.aborted)
67
+ return pending;
68
+ // Decline/cancel are not explicit rejections. The dashboard link remains usable.
69
+ if (response.action !== 'accept')
70
+ return pending;
71
+ const decision = response.content?.decision;
72
+ if (decision !== 'approve' && decision !== 'reject')
73
+ return pending;
74
+ const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
75
+ connectionId: context.connectionId, decision }), stopped]);
76
+ return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : pending;
77
+ }
78
+ const elicitationId = randomUUID();
79
+ const notify = context.server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: context.requestId });
80
+ const response = await Promise.race([context.server.server.elicitInput({ mode: 'url', message,
81
+ url: result.approve_url, elicitationId }, options), stopped]);
82
+ if (response.action !== 'accept' || controller.signal.aborted)
83
+ return pending;
84
+ // URL accept only means the page may open. Only its authenticated decision counts.
85
+ // Observe while this request is alive; do not retain a background poller after timeout.
86
+ while (!controller.signal.aborted) {
87
+ const source = db();
88
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
89
+ const { data, error } = await Promise.race([client.from('action_approvals')
90
+ .select('status, decided_via').eq('id', result.approval_id).eq('project_id', projectId).maybeSingle(), stopped]);
91
+ if (error || !data)
92
+ return pending;
93
+ if ((data.status === 'approved' || data.status === 'rejected') && isHumanApprovalChannel(data.decided_via)) {
94
+ await Promise.race([notify(), stopped]);
95
+ return { ...result, decision_status: data.status };
96
+ }
97
+ await Promise.race([new Promise(resolve => {
98
+ const timer = setTimeout(done, 1000);
99
+ function done() { clearTimeout(timer); controller.signal.removeEventListener('abort', done); resolve(); }
100
+ controller.signal.addEventListener('abort', done, { once: true });
101
+ }), stopped]);
102
+ }
103
+ }
104
+ catch {
105
+ // Unsupported SDK modes, disconnections, timeouts and refused DB writes fail to the link.
106
+ // No continuation consumes a late elicitation response or grants model decision authority.
107
+ }
108
+ finally {
109
+ clearTimeout(timeout);
110
+ context.signal?.removeEventListener('abort', abort);
111
+ controller.abort();
112
+ }
113
+ return pending;
114
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Server integration only. The transport creates this closure for one authenticated human
3
+ * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
4
+ * argument. Front ends must call complete only from the matching elicitation/app response.
5
+ */
6
+ export declare function createHumanApprovalCallback(binding: {
7
+ approvalId: string;
8
+ projectId: string;
9
+ connectionId: string;
10
+ userId: string;
11
+ channel: 'mcp_elicitation' | 'mcp_app';
12
+ }): {
13
+ interactionId: `${string}-${string}-${string}-${string}-${string}`;
14
+ complete(response: {
15
+ interactionId: string;
16
+ connectionId: string;
17
+ decision: 'approve' | 'reject';
18
+ }): Promise<boolean>;
19
+ };
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { db } from '../db.js';
3
+ /**
4
+ * Server integration only. The transport creates this closure for one authenticated human
5
+ * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
6
+ * argument. Front ends must call complete only from the matching elicitation/app response.
7
+ */
8
+ export function createHumanApprovalCallback(binding) {
9
+ const interactionId = randomUUID();
10
+ let completed = false;
11
+ return {
12
+ interactionId,
13
+ async complete(response) {
14
+ if (completed || response.interactionId !== interactionId || response.connectionId !== binding.connectionId)
15
+ return false;
16
+ if (response.decision !== 'approve' && response.decision !== 'reject')
17
+ return false;
18
+ completed = true;
19
+ const source = db();
20
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
21
+ const now = new Date().toISOString();
22
+ const { data, error } = await client.from('action_approvals')
23
+ .update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
24
+ decided_by: binding.userId, decided_at: now })
25
+ .eq('id', binding.approvalId).eq('project_id', binding.projectId)
26
+ .eq('status', 'pending').is('used_at', null).gt('expires_at', now)
27
+ .select('id, payload_hash').maybeSingle();
28
+ if (error || !data)
29
+ return false;
30
+ const { error: auditError } = await client.from('audit_log').insert({
31
+ project_id: binding.projectId, actor_type: 'user', actor_id: binding.userId,
32
+ action: 'approval.submitted', resource_type: 'approval', resource_id: binding.approvalId,
33
+ details: { status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
34
+ connection_id: binding.connectionId, interaction_id: interactionId, payload_hash: data.payload_hash },
35
+ });
36
+ if (auditError)
37
+ throw new Error('Approval recorded, but decision audit failed');
38
+ return true;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,5 @@
1
+ type Params = Record<string, any>;
2
+ /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
3
+ export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
4
+ export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
5
+ export {};
@@ -0,0 +1,106 @@
1
+ import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
2
+ import { db } from '../db.js';
3
+ import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
4
+ const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
5
+ 'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
6
+ 'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
7
+ /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
8
+ export async function captureApprovalSnapshot(action, params, projectId) {
9
+ const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
10
+ if (!TARGET_ACTIONS.has(action))
11
+ return snapshot;
12
+ const source = db();
13
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
14
+ const body = params.payload ?? params;
15
+ const ids = [...new Set((Array.isArray(params.campaign_ids) ? params.campaign_ids :
16
+ typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
17
+ if (ids.length) {
18
+ snapshot.campaigns = await readAllRows(client.from('campaigns')
19
+ .select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
20
+ .eq('project_id', projectId).in('id', ids));
21
+ if (snapshot.campaigns.length !== ids.length)
22
+ throw new Error('Approval target not found');
23
+ snapshot.steps = await readAllRows(client.from('campaign_steps')
24
+ .select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
25
+ .in('campaign_id', ids));
26
+ let count = 0;
27
+ for (const id of ids) {
28
+ const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', id);
29
+ if (result.error || result.count == null)
30
+ throw new Error('Approval audience count unavailable');
31
+ count += result.count;
32
+ }
33
+ snapshot.recipient_count = count;
34
+ }
35
+ if (typeof params.issue_id === 'string') {
36
+ const issue = await client.from('newsletter_issues').select('id, newsletter_id, subject, content, preheader, status, scheduled_at')
37
+ .eq('id', params.issue_id).eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
38
+ const newsletter = await client.from('newsletters').select('id, name, site_id, status, sender_name, sender_email')
39
+ .eq('id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
40
+ if (issue.error || !issue.data || newsletter.error || !newsletter.data)
41
+ throw new Error('Approval newsletter unavailable');
42
+ snapshot.issue = issue.data;
43
+ snapshot.newsletter = newsletter.data;
44
+ snapshot.subscriptions = await readAllRows(client.from('newsletter_subscriptions').select('id, subscriber_id')
45
+ .eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).eq('status', 'active').not('subscriber_id', 'is', null));
46
+ snapshot.subscriptions.sort((a, b) => String(a.id).localeCompare(String(b.id)));
47
+ snapshot.recipient_count = snapshot.subscriptions.length;
48
+ }
49
+ const siteIds = [...new Set([body.site_id, snapshot.newsletter?.site_id, ...snapshot.campaigns.map((c) => c.site_id)]
50
+ .filter((id) => typeof id === 'string' && id.length > 0))].sort();
51
+ for (const id of siteIds) {
52
+ const { data, error } = await client.from('sites')
53
+ .select('id, name, from_name, from_email, sending_domain, physical_address, unsub_link_enabled, unsub_footer_template')
54
+ .eq('id', id).eq('project_id', projectId).maybeSingle();
55
+ if (error || !data)
56
+ throw new Error('Approval sender/footer unavailable');
57
+ snapshot.sites.push(data);
58
+ }
59
+ async function readFrozenContacts(contacts) {
60
+ const found = [];
61
+ for (const chunk of chunkIds([...new Set(contacts.map(String))])) {
62
+ const values = await readAllRows(client.from('subscribers').select('id')
63
+ .eq('project_id', projectId).in('id', chunk));
64
+ found.push(...values.map(value => value.id));
65
+ }
66
+ if (found.length !== new Set(contacts).size)
67
+ throw new Error('Approval recipients changed or unavailable');
68
+ return found.sort();
69
+ }
70
+ if (typeof body.segment_id === 'string') {
71
+ const { data, error } = await client.from('segments').select('id, name, rules, site_id')
72
+ .eq('id', body.segment_id).eq('project_id', projectId).maybeSingle();
73
+ if (error || !data)
74
+ throw new Error('Approval segment unavailable');
75
+ snapshot.segment = data;
76
+ // launch_campaign executes a frozen list, so new segment matches cannot expand its audience.
77
+ // The saved segment definition is still compared; deleted original recipients invalidate it.
78
+ const rules = scopeSegmentRulesToSite(data.rules, data.site_id);
79
+ snapshot.contact_ids = action === 'launch_campaign' && Array.isArray(params.generated?.contact_ids)
80
+ ? await readFrozenContacts(params.generated.contact_ids)
81
+ : typeof body.site_id === 'string'
82
+ ? (await resolveSendAudience(projectId, body.site_id, rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort()
83
+ : (await resolveSegmentContactIds(projectId, rules)).sort();
84
+ snapshot.recipient_count = snapshot.contact_ids.length;
85
+ }
86
+ else if (body.rules && typeof body.site_id === 'string') {
87
+ snapshot.contact_ids = (await resolveSendAudience(projectId, body.site_id, body.rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort();
88
+ snapshot.recipient_count = snapshot.contact_ids.length;
89
+ }
90
+ else {
91
+ const contacts = body.contact_ids ?? params.generated?.contact_ids;
92
+ if (Array.isArray(contacts)) {
93
+ snapshot.contact_ids = await readFrozenContacts(contacts);
94
+ snapshot.recipient_count = snapshot.contact_ids.length;
95
+ }
96
+ else if (typeof body.to === 'string')
97
+ snapshot.recipient_count = 1;
98
+ }
99
+ snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
100
+ snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
101
+ return snapshot;
102
+ }
103
+ export function withoutApprovalSnapshot(params) {
104
+ const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
105
+ return rest;
106
+ }
package/build/server.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
2
+ import { runWithApprovalElicitation } from './lib/approval-elicitation.js';
1
3
  import { runWithToolContext } from './db.js';
2
4
  import { isApprovalGatedAction } from './lib/action-approvals.js';
3
5
  import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
@@ -353,11 +355,11 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
353
355
  const text = agent ? agentConnectionRefusal(name) : tester ? testerConnectionRefusal(name) : `Forbidden: this action requires "${required}" scope.`;
354
356
  return { content: [{ type: 'text', text }] };
355
357
  }
356
- return runWithToolContext(toolGroup, name, () => handler(...args));
358
+ return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)));
357
359
  };
358
360
  return target.registerTool(name, config, guardedHandler);
359
361
  }
360
- return target.registerTool(name, config, (...args) => runWithToolContext(toolGroup, name, () => handler(...args)));
362
+ return target.registerTool(name, config, (...args) => runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args))));
361
363
  };
362
364
  }
363
365
  return Reflect.get(target, prop, receiver);
@@ -377,6 +379,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
377
379
  'On the hosted connection, any tool call (and invoke_tool) also accepts an optional project_id to act on one project for that call only; it is checked each time, and approval-gated tools require it when the connection was switched to another project. In gateway mode, find tools with search_tools (plain words work, for example "send email" or "last campaign"), check arguments with describe_tool, then call invoke_tool.',
378
380
  ].join(' ');
379
381
  export function registerAllTools(server, options) {
382
+ if (server.server)
383
+ installApprovalCapabilityProbe(server.server);
380
384
  const rawScopes = options?.scopes ?? [];
381
385
  if (rawScopes.includes('gateway')) {
382
386
  const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.104",
3
+ "version": "1.0.106",
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",