@sendinel/mcp-server 1.0.122 → 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();
@@ -65,51 +65,68 @@ export async function recordElicitationResponse(body, identity) {
65
65
  */
66
66
  export async function relayElicitation(options) {
67
67
  const pending = 'decision_pending';
68
+ const exit = (reason) => {
69
+ console.warn('[approval_wait]', { stage: 'relay', reason });
70
+ return pending;
71
+ };
68
72
  const { identity, signal } = options;
69
- 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 ||
70
76
  !await humanApprovalEligible(options.action, identity.projectId, identity))
71
- return pending;
72
- const client = email(), now = Date.now(), requestId = randomUUID();
77
+ return exit('eligibility_refused');
78
+ const now = Date.now(), requestId = randomUUID();
73
79
  const promptId = randomUUID();
74
80
  try {
81
+ const client = email();
75
82
  const { error: expireError } = await client.from(TABLE).update({ status: 'expired' })
76
83
  .eq('oauth_token_id', identity.oauthTokenId).eq('status', 'pending').lt('expires_at', new Date(now).toISOString());
77
84
  if (expireError)
78
- return pending;
85
+ return exit('expiry_update_failed');
79
86
  // Bounded per-token purge; an old backlog is drained in chunks, never one unbounded delete.
80
87
  const { data: old, error: oldError } = await client.from(TABLE).select('id')
81
88
  .eq('oauth_token_id', identity.oauthTokenId).lt('created_at', new Date(now - 3_600_000).toISOString()).limit(100);
82
89
  if (oldError)
83
- return pending;
90
+ return exit('cleanup_read_failed');
84
91
  if (old?.length) {
85
92
  const { error: deleteError } = await client.from(TABLE).delete().eq('oauth_token_id', identity.oauthTokenId)
86
93
  .in('id', old.map((row) => row.id));
87
94
  if (deleteError)
88
- return pending;
95
+ return exit('cleanup_delete_failed');
89
96
  }
90
97
  const { data: approval, error: approvalError } = await client.from('action_approvals')
91
98
  .select('id, expires_at').eq('id', options.approvalId).eq('project_id', identity.projectId)
92
99
  .eq('oauth_token_id', identity.oauthTokenId).eq('action', options.action)
93
100
  .eq('tier', 'gated').eq('status', 'pending').is('used_at', null).gt('expires_at', new Date(now).toISOString()).maybeSingle();
94
- if (approvalError || !approval || !(Date.parse(approval.expires_at) > Date.now()) || signal.aborted)
95
- 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');
96
107
  const { error } = await client.from(TABLE).insert({ id: promptId, approval_id: options.approvalId,
97
108
  project_id: identity.projectId, oauth_token_id: identity.oauthTokenId, user_id: identity.actorId,
98
109
  jsonrpc_request_id: requestId, channel: 'mcp_elicitation', status: 'pending',
99
110
  created_at: new Date(now).toISOString(), expires_at: new Date(Math.min(now + 120_000, Date.parse(approval.expires_at))).toISOString() });
100
- if (error || signal.aborted)
101
- return pending;
111
+ if (signal.aborted)
112
+ return exit('request_aborted');
113
+ if (error)
114
+ return exit('prompt_insert_failed');
102
115
  // The id is created here and passed unchanged both to the row and the actual transport.
103
116
  await options.send({ jsonrpc: '2.0', id: requestId, method: 'elicitation/create', params: options.params });
104
117
  while (!signal.aborted && Date.now() < now + ELICITATION_HOLD_MS) {
105
118
  const { data: prompt, error: readError } = await client.from(TABLE).select('status, answer, expires_at')
106
119
  .eq('id', promptId).eq('oauth_token_id', identity.oauthTokenId).eq('user_id', identity.actorId).maybeSingle();
107
- if (signal.aborted || readError || !prompt || !(Date.parse(prompt.expires_at) > Date.now()))
108
- 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');
109
126
  if (prompt.status === 'answered')
110
- return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : pending;
127
+ return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : exit('invalid_answer');
111
128
  if (prompt.status !== 'pending')
112
- return pending;
129
+ return exit('prompt_not_pending');
113
130
  await new Promise(resolve => {
114
131
  const timer = setTimeout(done, Math.min(750, Math.max(0, now + ELICITATION_HOLD_MS - Date.now())));
115
132
  function done() { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); }
@@ -121,6 +138,7 @@ export async function relayElicitation(options) {
121
138
  }
122
139
  catch {
123
140
  // Refused inserts/updates, missing migrations and disconnected streams keep approve_url.
141
+ return exit(signal.aborted ? 'request_aborted' : 'relay_failed');
124
142
  }
125
- return pending;
143
+ return exit(signal.aborted ? 'request_aborted' : 'hold_elapsed');
126
144
  }
@@ -54,6 +54,7 @@ export declare function elicitApproval<T extends ApprovalFallback>(result: T, fr
54
54
  action: string;
55
55
  params: unknown;
56
56
  summary: string;
57
+ projectName?: string;
57
58
  }, projectId: string): Promise<T & {
58
59
  decision_status: ElicitationDecision;
59
60
  }>;
@@ -2,7 +2,7 @@ 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
@@ -26,31 +26,38 @@ export function runWithApprovalElicitation(server, extra, run) {
26
26
  /** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
27
27
  export async function elicitApproval(result, frozen, projectId) {
28
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; };
29
31
  const context = requests.getStore();
30
- if (!context || context.projectId !== projectId || result.tier !== 'gated')
31
- 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');
32
38
  // #4285: with the Apps tier off for this client (default), a declared lower tier still applies.
33
39
  let channel = approval_channel_available(context.server.server, { appsAllowed: approvalAppClientAllowed(approvalClientName(context.server.server)) });
34
40
  if (channel === 'mcp_elicitation_form' && !context.userId &&
35
41
  probeApprovalCapabilities(context.server.server.getClientCapabilities()).elicitation_url)
36
42
  channel = 'mcp_elicitation_url';
37
43
  if (channel !== 'mcp_elicitation_form' && channel !== 'mcp_elicitation_url')
38
- return pending;
44
+ return exit('channel_unavailable');
39
45
  if (channel === 'mcp_elicitation_form' && (!context.userId ||
40
46
  !await humanApprovalEligible(frozen.action, projectId, context.identity)))
41
- return pending;
47
+ return exit('eligibility_refused');
42
48
  if (channel === 'mcp_elicitation_url' && isHumanRequestableAdminTool(frozen.action) &&
43
49
  !await humanApprovalEligible(frozen.action, projectId, context.identity))
44
- return pending;
50
+ return exit('eligibility_refused');
45
51
  if (context.signal?.aborted)
46
- return pending;
52
+ return exit('request_aborted');
47
53
  const controller = new AbortController();
48
- const abort = () => controller.abort();
54
+ let endReason;
55
+ const abort = () => { endReason = 'request_aborted'; controller.abort(); };
49
56
  context.signal?.addEventListener('abort', abort, { once: true });
50
57
  let timeout;
51
58
  const stopped = new Promise((_resolve, reject) => {
52
59
  controller.signal.addEventListener('abort', () => reject(new Error('Elicitation ended')), { once: true });
53
- timeout = setTimeout(abort, ELICITATION_HOLD_MS);
60
+ timeout = setTimeout(() => { endReason = 'hold_elapsed'; controller.abort(); }, ELICITATION_HOLD_MS);
54
61
  });
55
62
  void stopped.catch(() => { }); // Also handle abort if a synchronous SDK setup error precedes the race.
56
63
  const options = { relatedRequestId: context.requestId, signal: controller.signal,
@@ -63,51 +70,55 @@ export async function elicitApproval(result, frozen, projectId) {
63
70
  if (controller.signal.aborted)
64
71
  return;
65
72
  void context.server.server.notification({ method: 'notifications/progress',
66
- params: { progressToken, progress: ++progress, message: 'Waiting for your approval decision' } }, { relatedRequestId: context.requestId }).catch(abort);
73
+ params: { progressToken, progress: ++progress, message: 'Waiting for your approval decision' } }, { relatedRequestId: context.requestId }).catch(() => {
74
+ clearInterval(progressTimer);
75
+ warn('progress_failed');
76
+ });
67
77
  }, PROGRESS_INTERVAL_MS);
68
78
  }
69
79
  try {
70
80
  const preview = buildApprovalPreview(frozen);
71
- // Use the shared preview; bound the interactive message independently of the full fallback.
72
- // Email content is untrusted display text, never instructions or an approval decision.
73
- const short = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
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.',
81
- `Subject: ${short(preview.subject || '(none)', 300)}`, `Sender: ${short(preview.sender, 300)}`,
82
- `Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
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');
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');
85
92
  if (channel === 'mcp_elicitation_form') {
86
93
  const params = { mode: 'form', message, requestedSchema: { type: 'object', properties: {} } };
87
94
  if (context.relay) {
88
95
  const status = await Promise.race([context.relay({ approvalId: result.approval_id,
89
96
  action: frozen.action, params, signal: controller.signal }), stopped]);
90
- 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 };
91
100
  }
92
101
  const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
93
102
  connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
94
103
  const response = await Promise.race([context.server.server.elicitInput(params, options), stopped]);
95
104
  if (controller.signal.aborted)
96
- return pending;
105
+ return exit(endReason ?? 'request_aborted');
97
106
  // Only the client's action is a decision. Extra content is untrusted and ignored.
98
107
  if (response.action !== 'accept' && response.action !== 'decline')
99
- return pending;
108
+ return exit('client_cancelled');
100
109
  const decision = response.action === 'accept' ? 'approve' : 'reject';
101
110
  const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
102
111
  connectionId: context.connectionId, decision }), stopped]);
103
- return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : pending;
112
+ return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : exit('decision_refused');
104
113
  }
105
114
  const elicitationId = randomUUID();
106
115
  const notify = context.server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: context.requestId });
107
116
  const response = await Promise.race([context.server.server.elicitInput({ mode: 'url', message,
108
117
  url: result.approve_url, elicitationId }, options), stopped]);
109
- if (response.action !== 'accept' || controller.signal.aborted)
110
- return pending;
118
+ if (controller.signal.aborted)
119
+ return exit(endReason ?? 'request_aborted');
120
+ if (response.action !== 'accept')
121
+ return exit('url_not_accepted');
111
122
  // URL accept only means the page may open. Only its authenticated decision counts.
112
123
  // Observe while this request is alive; do not retain a background poller after timeout.
113
124
  while (!controller.signal.aborted) {
@@ -116,7 +127,7 @@ export async function elicitApproval(result, frozen, projectId) {
116
127
  const { data, error } = await Promise.race([client.from('action_approvals')
117
128
  .select('status, decided_via').eq('id', result.approval_id).eq('project_id', projectId).maybeSingle(), stopped]);
118
129
  if (error || !data)
119
- return pending;
130
+ return exit(error ? 'lookup_failed' : 'row_not_found');
120
131
  if ((data.status === 'approved' || data.status === 'rejected') && isHumanApprovalChannel(data.decided_via)) {
121
132
  await Promise.race([notify(), stopped]);
122
133
  return { ...result, decision_status: data.status };
@@ -127,8 +138,10 @@ export async function elicitApproval(result, frozen, projectId) {
127
138
  controller.signal.addEventListener('abort', done, { once: true });
128
139
  }), stopped]);
129
140
  }
141
+ warn(endReason ?? 'request_aborted');
130
142
  }
131
143
  catch {
144
+ warn(endReason ?? 'wait_failed');
132
145
  // Unsupported SDK modes, disconnections, timeouts and refused DB writes fail to the link.
133
146
  // The hosted relay row remains valid for late authenticated answer POSTs.
134
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.122",
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",