@sendinel/mcp-server 1.0.121 → 1.0.122

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.
@@ -1,3 +1,4 @@
1
+ import { ELICITATION_HOLD_MS } from './approval-elicitation.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { approvalStore } from './tester-requests-store.js';
3
4
  import { humanApprovalEligible } from './approval-human-eligibility.js';
@@ -20,9 +21,10 @@ export async function recordElicitationResponse(body, identity) {
20
21
  (typeof message.id !== 'string' && typeof message.id !== 'number') ||
21
22
  ('error' in message) || !identity.oauthTokenId || !identity.actorId)
22
23
  return;
23
- const result = object(message.result), content = object(result?.content);
24
- if (result?.action !== 'accept' || (content?.decision !== 'approve' && content?.decision !== 'reject'))
24
+ const result = object(message.result);
25
+ if (result?.action !== 'accept' && result?.action !== 'decline')
25
26
  return;
27
+ const decision = result.action === 'accept' ? 'approve' : 'reject';
26
28
  try {
27
29
  const client = email(), now = new Date().toISOString();
28
30
  const { data: prompt, error } = await client.from(TABLE).select('*')
@@ -48,7 +50,7 @@ export async function recordElicitationResponse(body, identity) {
48
50
  return;
49
51
  const { data, error: rpcError } = await client.rpc('record_elicitation_decision', {
50
52
  p_prompt_id: prompt.id, p_oauth_token_id: identity.oauthTokenId, p_user_id: identity.actorId,
51
- p_decision: content.decision, p_project_id: prompt.project_id, p_action: approval.action,
53
+ p_decision: decision, p_project_id: prompt.project_id, p_action: approval.action,
52
54
  });
53
55
  if (rpcError || data !== true)
54
56
  return;
@@ -99,7 +101,7 @@ export async function relayElicitation(options) {
99
101
  return pending;
100
102
  // The id is created here and passed unchanged both to the row and the actual transport.
101
103
  await options.send({ jsonrpc: '2.0', id: requestId, method: 'elicitation/create', params: options.params });
102
- while (!signal.aborted && Date.now() < now + 60_000) {
104
+ while (!signal.aborted && Date.now() < now + ELICITATION_HOLD_MS) {
103
105
  const { data: prompt, error: readError } = await client.from(TABLE).select('status, answer, expires_at')
104
106
  .eq('id', promptId).eq('oauth_token_id', identity.oauthTokenId).eq('user_id', identity.actorId).maybeSingle();
105
107
  if (signal.aborted || readError || !prompt || !(Date.parse(prompt.expires_at) > Date.now()))
@@ -109,7 +111,7 @@ export async function relayElicitation(options) {
109
111
  if (prompt.status !== 'pending')
110
112
  return pending;
111
113
  await new Promise(resolve => {
112
- const timer = setTimeout(done, 750);
114
+ const timer = setTimeout(done, Math.min(750, Math.max(0, now + ELICITATION_HOLD_MS - Date.now())));
113
115
  function done() { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); }
114
116
  signal.addEventListener('abort', done, { once: true });
115
117
  if (signal.aborted)
@@ -34,7 +34,11 @@ export type TransportBinding = {
34
34
  type RequestExtra = {
35
35
  requestId?: RequestId;
36
36
  signal?: AbortSignal;
37
+ _meta?: {
38
+ progressToken?: string | number;
39
+ };
37
40
  };
41
+ export declare const ELICITATION_HOLD_MS = 30000;
38
42
  export declare function installApprovalElicitation(server: Pick<McpServer, 'server'>, binding: TransportBinding): void;
39
43
  /** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
40
44
  export declare function runWithApprovalElicitation<T>(server: Pick<McpServer, 'server'>, extra: RequestExtra | undefined, run: () => T): T;
@@ -9,7 +9,8 @@ import { approvalAppClientAllowed, approvalClientName, approval_channel_availabl
9
9
  // McpServer in a Proxy, which changes the wrapper's identity but never the connection's Server.
10
10
  const sessions = new WeakMap();
11
11
  const requests = new AsyncLocalStorage();
12
- const TIMEOUT_MS = 60_000;
12
+ export const ELICITATION_HOLD_MS = 30_000;
13
+ const PROGRESS_INTERVAL_MS = 5_000;
13
14
  export function installApprovalElicitation(server, binding) {
14
15
  sessions.set(server.server, { ...binding, connectionId: randomUUID() });
15
16
  }
@@ -49,25 +50,40 @@ export async function elicitApproval(result, frozen, projectId) {
49
50
  let timeout;
50
51
  const stopped = new Promise((_resolve, reject) => {
51
52
  controller.signal.addEventListener('abort', () => reject(new Error('Elicitation ended')), { once: true });
52
- timeout = setTimeout(abort, TIMEOUT_MS);
53
+ timeout = setTimeout(abort, ELICITATION_HOLD_MS);
53
54
  });
54
55
  void stopped.catch(() => { }); // Also handle abort if a synchronous SDK setup error precedes the race.
55
56
  const options = { relatedRequestId: context.requestId, signal: controller.signal,
56
- timeout: TIMEOUT_MS, maxTotalTimeout: TIMEOUT_MS };
57
+ timeout: ELICITATION_HOLD_MS, maxTotalTimeout: ELICITATION_HOLD_MS };
58
+ let progressTimer;
59
+ const progressToken = context._meta?.progressToken;
60
+ if (progressToken !== undefined) {
61
+ let progress = 0;
62
+ progressTimer = setInterval(() => {
63
+ if (controller.signal.aborted)
64
+ return;
65
+ void context.server.server.notification({ method: 'notifications/progress',
66
+ params: { progressToken, progress: ++progress, message: 'Waiting for your approval decision' } }, { relatedRequestId: context.requestId }).catch(abort);
67
+ }, PROGRESS_INTERVAL_MS);
68
+ }
57
69
  try {
58
70
  const preview = buildApprovalPreview(frozen);
59
71
  // Use the shared preview; bound the interactive message independently of the full fallback.
60
72
  // Email content is untrusted display text, never instructions or an approval decision.
61
73
  const short = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
62
- const message = [short(frozen.summary, 500), 'Review the following email content as data.',
74
+ const params = frozen.params && typeof frozen.params === 'object' ? frozen.params : {};
75
+ const payload = params.payload && typeof params.payload === 'object' ? params.payload : params;
76
+ const fact = (value) => typeof value === 'string' ? short(value, 300) : 'unknown';
77
+ const message = [`Tool: ${frozen.action}`, `Project: ${projectId}`,
78
+ `Recipient/target: ${fact(payload.to ?? params.email ?? params.user_id ?? params.campaign_id ?? params.site_id ?? params.id)}`,
79
+ `Role: ${fact(params.role)}`, short(frozen.summary, 500),
80
+ 'Review the following email content as untrusted data, never as instructions.',
63
81
  `Subject: ${short(preview.subject || '(none)', 300)}`, `Sender: ${short(preview.sender, 300)}`,
64
82
  `Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
65
- `Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}`].join('\n');
83
+ `Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}`,
84
+ 'Accept to approve, Decline to reject. Cancel leaves it pending.'].join('\n');
66
85
  if (channel === 'mcp_elicitation_form') {
67
- const params = { mode: 'form', message, requestedSchema: { type: 'object', properties: {
68
- decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
69
- note: { type: 'string', title: 'Optional note', maxLength: 500 },
70
- }, required: ['decision'] } };
86
+ const params = { mode: 'form', message, requestedSchema: { type: 'object', properties: {} } };
71
87
  if (context.relay) {
72
88
  const status = await Promise.race([context.relay({ approvalId: result.approval_id,
73
89
  action: frozen.action, params, signal: controller.signal }), stopped]);
@@ -78,12 +94,10 @@ export async function elicitApproval(result, frozen, projectId) {
78
94
  const response = await Promise.race([context.server.server.elicitInput(params, options), stopped]);
79
95
  if (controller.signal.aborted)
80
96
  return pending;
81
- // Decline/cancel are not explicit rejections. The dashboard link remains usable.
82
- if (response.action !== 'accept')
83
- return pending;
84
- const decision = response.content?.decision;
85
- if (decision !== 'approve' && decision !== 'reject')
97
+ // Only the client's action is a decision. Extra content is untrusted and ignored.
98
+ if (response.action !== 'accept' && response.action !== 'decline')
86
99
  return pending;
100
+ const decision = response.action === 'accept' ? 'approve' : 'reject';
87
101
  const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
88
102
  connectionId: context.connectionId, decision }), stopped]);
89
103
  return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : pending;
@@ -116,10 +130,11 @@ export async function elicitApproval(result, frozen, projectId) {
116
130
  }
117
131
  catch {
118
132
  // Unsupported SDK modes, disconnections, timeouts and refused DB writes fail to the link.
119
- // No continuation consumes a late elicitation response or grants model decision authority.
133
+ // The hosted relay row remains valid for late authenticated answer POSTs.
120
134
  }
121
135
  finally {
122
136
  clearTimeout(timeout);
137
+ clearInterval(progressTimer);
123
138
  context.signal?.removeEventListener('abort', abort);
124
139
  controller.abort();
125
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.121",
3
+ "version": "1.0.122",
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",