@sendinel/mcp-server 1.0.105 → 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');
@@ -47,6 +47,7 @@ export interface ApprovalResult {
47
47
  /** One-tap approval page; works on a phone and needs no project switching (#3661). */
48
48
  approve_url: string;
49
49
  preview_text: string;
50
+ decision_status: 'decision_pending' | 'approved' | 'rejected';
50
51
  message: string;
51
52
  }
52
53
  export declare function approvalPageUrl(approvalId: string): string;
@@ -4,6 +4,7 @@ import { db, requestContext } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
6
6
  import { fireWebhookEvent } from './webhook-events.js';
7
+ import { elicitApproval } from './approval-elicitation.js';
7
8
  /**
8
9
  * Action approval tiers:
9
10
  *
@@ -362,7 +363,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
362
363
  resource_id: params.resource_id ?? params.campaign_id ?? params.contact_id ?? null,
363
364
  requested_by: apiKeyId ?? null,
364
365
  }, getProjectId()).catch(() => { });
365
- return {
366
+ return elicitApproval({
366
367
  requires_approval: true,
367
368
  approval_id: data.id,
368
369
  tier,
@@ -373,7 +374,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
373
374
  approve_url: approvalPageUrl(data.id),
374
375
  preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
375
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.`,
376
- };
377
+ }, { action, params: frozenParams, summary }, getProjectId());
377
378
  }
378
379
  /**
379
380
  * Check if an approval token has been approved.
@@ -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
+ }
package/build/server.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
2
+ import { runWithApprovalElicitation } from './lib/approval-elicitation.js';
2
3
  import { runWithToolContext } from './db.js';
3
4
  import { isApprovalGatedAction } from './lib/action-approvals.js';
4
5
  import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
@@ -354,11 +355,11 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
354
355
  const text = agent ? agentConnectionRefusal(name) : tester ? testerConnectionRefusal(name) : `Forbidden: this action requires "${required}" scope.`;
355
356
  return { content: [{ type: 'text', text }] };
356
357
  }
357
- return runWithToolContext(toolGroup, name, () => handler(...args));
358
+ return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)));
358
359
  };
359
360
  return target.registerTool(name, config, guardedHandler);
360
361
  }
361
- 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))));
362
363
  };
363
364
  }
364
365
  return Reflect.get(target, prop, receiver);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.105",
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",