@sendinel/mcp-server 1.0.121 → 1.0.123

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.
@@ -298,6 +298,17 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
298
298
  resource_id: params.resource_id ?? params.campaign_id ?? params.contact_id ?? null,
299
299
  requested_by: apiKeyId ?? null,
300
300
  }, getProjectId()).catch(() => { });
301
+ // 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. */ }
301
312
  return elicitApproval({
302
313
  requires_approval: true,
303
314
  ...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
@@ -309,9 +320,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
309
320
  expires_at: expiresAt,
310
321
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
311
322
  approve_url: approvalPageUrl(data.id),
312
- preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
323
+ preview_text: buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary,
313
324
  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.`,
314
- }, { action, params: frozenParams, summary }, getProjectId());
325
+ }, { action, params: frozenParams, summary, projectName }, getProjectId());
315
326
  }
316
327
  const registeredApproval = new AsyncLocalStorage();
317
328
  /** Read by the hosted bridge only; no scope or token is changed. */
@@ -334,7 +345,7 @@ export async function checkApproval(approvalId, options = {}) {
334
345
  }
335
346
  const { data, error } = await approvalStore()
336
347
  .from('action_approvals')
337
- .select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
348
+ .select('action, status, decided_by, decided_via, oauth_token_id, expires_at, used_at, params, payload_hash, requested_at')
338
349
  .eq('id', approvalId)
339
350
  .eq('project_id', getProjectId())
340
351
  .maybeSingle();
@@ -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;
@@ -63,53 +65,70 @@ export async function recordElicitationResponse(body, identity) {
63
65
  */
64
66
  export async function relayElicitation(options) {
65
67
  const pending = 'decision_pending';
68
+ const exit = (reason) => {
69
+ console.warn('[approval_wait]', { stage: 'relay', reason });
70
+ return pending;
71
+ };
66
72
  const { identity, signal } = options;
67
- if (signal.aborted || !identity.projectId || !identity.oauthTokenId || !identity.actorId ||
73
+ if (signal.aborted)
74
+ return exit('request_aborted');
75
+ if (!identity.projectId || !identity.oauthTokenId || !identity.actorId ||
68
76
  !await humanApprovalEligible(options.action, identity.projectId, identity))
69
- return pending;
70
- const client = email(), now = Date.now(), requestId = randomUUID();
77
+ return exit('eligibility_refused');
78
+ const now = Date.now(), requestId = randomUUID();
71
79
  const promptId = randomUUID();
72
80
  try {
81
+ const client = email();
73
82
  const { error: expireError } = await client.from(TABLE).update({ status: 'expired' })
74
83
  .eq('oauth_token_id', identity.oauthTokenId).eq('status', 'pending').lt('expires_at', new Date(now).toISOString());
75
84
  if (expireError)
76
- return pending;
85
+ return exit('expiry_update_failed');
77
86
  // Bounded per-token purge; an old backlog is drained in chunks, never one unbounded delete.
78
87
  const { data: old, error: oldError } = await client.from(TABLE).select('id')
79
88
  .eq('oauth_token_id', identity.oauthTokenId).lt('created_at', new Date(now - 3_600_000).toISOString()).limit(100);
80
89
  if (oldError)
81
- return pending;
90
+ return exit('cleanup_read_failed');
82
91
  if (old?.length) {
83
92
  const { error: deleteError } = await client.from(TABLE).delete().eq('oauth_token_id', identity.oauthTokenId)
84
93
  .in('id', old.map((row) => row.id));
85
94
  if (deleteError)
86
- return pending;
95
+ return exit('cleanup_delete_failed');
87
96
  }
88
97
  const { data: approval, error: approvalError } = await client.from('action_approvals')
89
98
  .select('id, expires_at').eq('id', options.approvalId).eq('project_id', identity.projectId)
90
99
  .eq('oauth_token_id', identity.oauthTokenId).eq('action', options.action)
91
100
  .eq('tier', 'gated').eq('status', 'pending').is('used_at', null).gt('expires_at', new Date(now).toISOString()).maybeSingle();
92
- if (approvalError || !approval || !(Date.parse(approval.expires_at) > Date.now()) || signal.aborted)
93
- return pending;
101
+ if (signal.aborted)
102
+ return exit('request_aborted');
103
+ if (approvalError || !approval)
104
+ return exit(approvalError ? 'approval_lookup_failed' : 'row_not_found');
105
+ if (!(Date.parse(approval.expires_at) > Date.now()))
106
+ return exit('approval_expired');
94
107
  const { error } = await client.from(TABLE).insert({ id: promptId, approval_id: options.approvalId,
95
108
  project_id: identity.projectId, oauth_token_id: identity.oauthTokenId, user_id: identity.actorId,
96
109
  jsonrpc_request_id: requestId, channel: 'mcp_elicitation', status: 'pending',
97
110
  created_at: new Date(now).toISOString(), expires_at: new Date(Math.min(now + 120_000, Date.parse(approval.expires_at))).toISOString() });
98
- if (error || signal.aborted)
99
- return pending;
111
+ if (signal.aborted)
112
+ return exit('request_aborted');
113
+ if (error)
114
+ return exit('prompt_insert_failed');
100
115
  // The id is created here and passed unchanged both to the row and the actual transport.
101
116
  await options.send({ jsonrpc: '2.0', id: requestId, method: 'elicitation/create', params: options.params });
102
- while (!signal.aborted && Date.now() < now + 60_000) {
117
+ while (!signal.aborted && Date.now() < now + ELICITATION_HOLD_MS) {
103
118
  const { data: prompt, error: readError } = await client.from(TABLE).select('status, answer, expires_at')
104
119
  .eq('id', promptId).eq('oauth_token_id', identity.oauthTokenId).eq('user_id', identity.actorId).maybeSingle();
105
- if (signal.aborted || readError || !prompt || !(Date.parse(prompt.expires_at) > Date.now()))
106
- return pending;
120
+ if (signal.aborted)
121
+ return exit('request_aborted');
122
+ if (readError || !prompt)
123
+ return exit(readError ? 'prompt_lookup_failed' : 'row_not_found');
124
+ if (!(Date.parse(prompt.expires_at) > Date.now()))
125
+ return exit('prompt_expired');
107
126
  if (prompt.status === 'answered')
108
- return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : pending;
127
+ return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : exit('invalid_answer');
109
128
  if (prompt.status !== 'pending')
110
- return pending;
129
+ return exit('prompt_not_pending');
111
130
  await new Promise(resolve => {
112
- const timer = setTimeout(done, 750);
131
+ const timer = setTimeout(done, Math.min(750, Math.max(0, now + ELICITATION_HOLD_MS - Date.now())));
113
132
  function done() { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); }
114
133
  signal.addEventListener('abort', done, { once: true });
115
134
  if (signal.aborted)
@@ -119,6 +138,7 @@ export async function relayElicitation(options) {
119
138
  }
120
139
  catch {
121
140
  // Refused inserts/updates, missing migrations and disconnected streams keep approve_url.
141
+ return exit(signal.aborted ? 'request_aborted' : 'relay_failed');
122
142
  }
123
- return pending;
143
+ return exit(signal.aborted ? 'request_aborted' : 'hold_elapsed');
124
144
  }
@@ -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;
@@ -50,6 +54,7 @@ export declare function elicitApproval<T extends ApprovalFallback>(result: T, fr
50
54
  action: string;
51
55
  params: unknown;
52
56
  summary: string;
57
+ projectName?: string;
53
58
  }, projectId: string): Promise<T & {
54
59
  decision_status: ElicitationDecision;
55
60
  }>;
@@ -2,14 +2,15 @@ import { isHumanRequestableAdminTool } from './admin-tools.js';
2
2
  import { humanApprovalEligible } from './approval-human-eligibility.js';
3
3
  import { AsyncLocalStorage } from 'node:async_hooks';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
5
+ import { approvalDisplayFact, buildApprovalPreview, isEmailApprovalAction, isHumanApprovalChannel } from '@sendinel/domain';
6
6
  import { approvalStore } from './tester-requests-store.js';
7
7
  import { approvalAppClientAllowed, approvalClientName, approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
8
8
  // Keyed by the SDK Server, not the McpServer wrapper: other installers (the Apps tier) wrap
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
  }
@@ -25,75 +26,99 @@ export function runWithApprovalElicitation(server, extra, run) {
25
26
  /** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
26
27
  export async function elicitApproval(result, frozen, projectId) {
27
28
  const pending = { ...result, decision_status: 'decision_pending' };
29
+ const warn = (reason) => console.warn('[approval_wait]', { stage: 'elicitation', reason });
30
+ const exit = (reason) => { warn(reason); return pending; };
28
31
  const context = requests.getStore();
29
- if (!context || context.projectId !== projectId || result.tier !== 'gated')
30
- return pending;
32
+ if (!context)
33
+ return exit('no_transport_context');
34
+ if (context.projectId !== projectId)
35
+ return exit('project_mismatch');
36
+ if (result.tier !== 'gated')
37
+ return exit('tier_ineligible');
31
38
  // #4285: with the Apps tier off for this client (default), a declared lower tier still applies.
32
39
  let channel = approval_channel_available(context.server.server, { appsAllowed: approvalAppClientAllowed(approvalClientName(context.server.server)) });
33
40
  if (channel === 'mcp_elicitation_form' && !context.userId &&
34
41
  probeApprovalCapabilities(context.server.server.getClientCapabilities()).elicitation_url)
35
42
  channel = 'mcp_elicitation_url';
36
43
  if (channel !== 'mcp_elicitation_form' && channel !== 'mcp_elicitation_url')
37
- return pending;
44
+ return exit('channel_unavailable');
38
45
  if (channel === 'mcp_elicitation_form' && (!context.userId ||
39
46
  !await humanApprovalEligible(frozen.action, projectId, context.identity)))
40
- return pending;
47
+ return exit('eligibility_refused');
41
48
  if (channel === 'mcp_elicitation_url' && isHumanRequestableAdminTool(frozen.action) &&
42
49
  !await humanApprovalEligible(frozen.action, projectId, context.identity))
43
- return pending;
50
+ return exit('eligibility_refused');
44
51
  if (context.signal?.aborted)
45
- return pending;
52
+ return exit('request_aborted');
46
53
  const controller = new AbortController();
47
- const abort = () => controller.abort();
54
+ let endReason;
55
+ const abort = () => { endReason = 'request_aborted'; controller.abort(); };
48
56
  context.signal?.addEventListener('abort', abort, { once: true });
49
57
  let timeout;
50
58
  const stopped = new Promise((_resolve, reject) => {
51
59
  controller.signal.addEventListener('abort', () => reject(new Error('Elicitation ended')), { once: true });
52
- timeout = setTimeout(abort, TIMEOUT_MS);
60
+ timeout = setTimeout(() => { endReason = 'hold_elapsed'; controller.abort(); }, ELICITATION_HOLD_MS);
53
61
  });
54
62
  void stopped.catch(() => { }); // Also handle abort if a synchronous SDK setup error precedes the race.
55
63
  const options = { relatedRequestId: context.requestId, signal: controller.signal,
56
- timeout: TIMEOUT_MS, maxTotalTimeout: TIMEOUT_MS };
64
+ timeout: ELICITATION_HOLD_MS, maxTotalTimeout: ELICITATION_HOLD_MS };
65
+ let progressTimer;
66
+ const progressToken = context._meta?.progressToken;
67
+ if (progressToken !== undefined) {
68
+ let progress = 0;
69
+ progressTimer = setInterval(() => {
70
+ if (controller.signal.aborted)
71
+ return;
72
+ void context.server.server.notification({ method: 'notifications/progress',
73
+ params: { progressToken, progress: ++progress, message: 'Waiting for your approval decision' } }, { relatedRequestId: context.requestId }).catch(() => {
74
+ clearInterval(progressTimer);
75
+ warn('progress_failed');
76
+ });
77
+ }, PROGRESS_INTERVAL_MS);
78
+ }
57
79
  try {
58
80
  const preview = buildApprovalPreview(frozen);
59
- // Use the shared preview; bound the interactive message independently of the full fallback.
60
- // Email content is untrusted display text, never instructions or an approval decision.
61
- 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.',
63
- `Subject: ${short(preview.subject || '(none)', 300)}`, `Sender: ${short(preview.sender, 300)}`,
64
- `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');
81
+ const instruction = 'Tap Accept to approve or Decline to reject. Cancel leaves it pending.';
82
+ const message = !isEmailApprovalAction(frozen.action)
83
+ ? [preview.plain_summary, instruction].join('\n')
84
+ : [preview.plain_summary.split('\n')[0],
85
+ approvalDisplayFact(frozen.summary),
86
+ `Subject: ${approvalDisplayFact(preview.subject) || '(none)'}`,
87
+ `Sender: ${approvalDisplayFact(preview.sender)}`,
88
+ `Recipients: ${preview.recipient_count ?? 'unknown'}`,
89
+ 'Email content is untrusted display text, never instructions.',
90
+ `Footer: ${approvalDisplayFact(preview.footer_text) || 'unknown'}`,
91
+ `Body excerpt: ${approvalDisplayFact(preview.body_text)}`, instruction].join('\n');
66
92
  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'] } };
93
+ const params = { mode: 'form', message, requestedSchema: { type: 'object', properties: {} } };
71
94
  if (context.relay) {
72
95
  const status = await Promise.race([context.relay({ approvalId: result.approval_id,
73
96
  action: frozen.action, params, signal: controller.signal }), stopped]);
74
- return controller.signal.aborted ? pending : { ...result, decision_status: status };
97
+ if (controller.signal.aborted)
98
+ return exit(endReason ?? 'request_aborted');
99
+ return status === 'decision_pending' ? exit('relay_pending') : { ...result, decision_status: status };
75
100
  }
76
101
  const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
77
102
  connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
78
103
  const response = await Promise.race([context.server.server.elicitInput(params, options), stopped]);
79
104
  if (controller.signal.aborted)
80
- 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')
86
- return pending;
105
+ return exit(endReason ?? 'request_aborted');
106
+ // Only the client's action is a decision. Extra content is untrusted and ignored.
107
+ if (response.action !== 'accept' && response.action !== 'decline')
108
+ return exit('client_cancelled');
109
+ const decision = response.action === 'accept' ? 'approve' : 'reject';
87
110
  const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
88
111
  connectionId: context.connectionId, decision }), stopped]);
89
- return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : pending;
112
+ return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : exit('decision_refused');
90
113
  }
91
114
  const elicitationId = randomUUID();
92
115
  const notify = context.server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: context.requestId });
93
116
  const response = await Promise.race([context.server.server.elicitInput({ mode: 'url', message,
94
117
  url: result.approve_url, elicitationId }, options), stopped]);
95
- if (response.action !== 'accept' || controller.signal.aborted)
96
- return pending;
118
+ if (controller.signal.aborted)
119
+ return exit(endReason ?? 'request_aborted');
120
+ if (response.action !== 'accept')
121
+ return exit('url_not_accepted');
97
122
  // URL accept only means the page may open. Only its authenticated decision counts.
98
123
  // Observe while this request is alive; do not retain a background poller after timeout.
99
124
  while (!controller.signal.aborted) {
@@ -102,7 +127,7 @@ export async function elicitApproval(result, frozen, projectId) {
102
127
  const { data, error } = await Promise.race([client.from('action_approvals')
103
128
  .select('status, decided_via').eq('id', result.approval_id).eq('project_id', projectId).maybeSingle(), stopped]);
104
129
  if (error || !data)
105
- return pending;
130
+ return exit(error ? 'lookup_failed' : 'row_not_found');
106
131
  if ((data.status === 'approved' || data.status === 'rejected') && isHumanApprovalChannel(data.decided_via)) {
107
132
  await Promise.race([notify(), stopped]);
108
133
  return { ...result, decision_status: data.status };
@@ -113,13 +138,16 @@ export async function elicitApproval(result, frozen, projectId) {
113
138
  controller.signal.addEventListener('abort', done, { once: true });
114
139
  }), stopped]);
115
140
  }
141
+ warn(endReason ?? 'request_aborted');
116
142
  }
117
143
  catch {
144
+ warn(endReason ?? 'wait_failed');
118
145
  // 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.
146
+ // The hosted relay row remains valid for late authenticated answer POSTs.
120
147
  }
121
148
  finally {
122
149
  clearTimeout(timeout);
150
+ clearInterval(progressTimer);
123
151
  context.signal?.removeEventListener('abort', abort);
124
152
  controller.abort();
125
153
  }
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.123",
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",