@sendinel/mcp-server 1.0.122 → 1.0.124

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,7 +298,20 @@ 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
- return elicitApproval({
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. */ }
312
+ const approveUrl = approvalPageUrl(data.id);
313
+ const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary;
314
+ const result = await elicitApproval({
302
315
  requires_approval: true,
303
316
  ...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
304
317
  payload_hash: approvalPayloadHash(action, frozenParams) })),
@@ -308,10 +321,16 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
308
321
  summary,
309
322
  expires_at: expiresAt,
310
323
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
311
- approve_url: approvalPageUrl(data.id),
312
- preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
313
- 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());
324
+ approve_url: approveUrl,
325
+ preview_text: previewText,
326
+ message: `Tap to approve: ${approveUrl}\n${previewText}\nIt stays open for 24 hours. In a terminal with prompts you can also answer the on-screen Accept/Decline.`,
327
+ }, { action, params: frozenParams, summary, projectName }, getProjectId());
328
+ if (result.decision_status === 'approved') {
329
+ return { ...result, message: 'Approved. Call this tool again with the same arguments plus approval_id to run it.' };
330
+ }
331
+ if (result.decision_status === 'rejected')
332
+ return { ...result, message: 'Rejected.' };
333
+ return result;
315
334
  }
316
335
  const registeredApproval = new AsyncLocalStorage();
317
336
  /** Read by the hosted bridge only; no scope or token is changed. */
@@ -334,7 +353,7 @@ export async function checkApproval(approvalId, options = {}) {
334
353
  }
335
354
  const { data, error } = await approvalStore()
336
355
  .from('action_approvals')
337
- .select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
356
+ .select('action, status, decided_by, decided_via, oauth_token_id, expires_at, used_at, params, payload_hash, requested_at')
338
357
  .eq('id', approvalId)
339
358
  .eq('project_id', getProjectId())
340
359
  .maybeSingle();
@@ -1,5 +1,6 @@
1
1
  import type { ElicitRequest, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
3
+ export declare const ELICITATION_ANSWER_WINDOW_MS = 600000;
3
4
  export declare function isElicitationResponse(body: unknown): boolean;
4
5
  /** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
5
6
  * No decision can enter this path via a tools/call, model text, or nested tool arguments.
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { approvalStore } from './tester-requests-store.js';
4
4
  import { humanApprovalEligible } from './approval-human-eligibility.js';
5
5
  const TABLE = 'mcp_elicitation_prompts';
6
+ export const ELICITATION_ANSWER_WINDOW_MS = 600_000;
6
7
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
7
8
  function email() {
8
9
  const source = approvalStore();
@@ -65,51 +66,68 @@ export async function recordElicitationResponse(body, identity) {
65
66
  */
66
67
  export async function relayElicitation(options) {
67
68
  const pending = 'decision_pending';
69
+ const exit = (reason) => {
70
+ console.warn('[approval_wait]', { stage: 'relay', reason });
71
+ return pending;
72
+ };
68
73
  const { identity, signal } = options;
69
- if (signal.aborted || !identity.projectId || !identity.oauthTokenId || !identity.actorId ||
74
+ if (signal.aborted)
75
+ return exit('request_aborted');
76
+ if (!identity.projectId || !identity.oauthTokenId || !identity.actorId ||
70
77
  !await humanApprovalEligible(options.action, identity.projectId, identity))
71
- return pending;
72
- const client = email(), now = Date.now(), requestId = randomUUID();
78
+ return exit('eligibility_refused');
79
+ const now = Date.now(), requestId = randomUUID();
73
80
  const promptId = randomUUID();
74
81
  try {
82
+ const client = email();
75
83
  const { error: expireError } = await client.from(TABLE).update({ status: 'expired' })
76
84
  .eq('oauth_token_id', identity.oauthTokenId).eq('status', 'pending').lt('expires_at', new Date(now).toISOString());
77
85
  if (expireError)
78
- return pending;
86
+ return exit('expiry_update_failed');
79
87
  // Bounded per-token purge; an old backlog is drained in chunks, never one unbounded delete.
80
88
  const { data: old, error: oldError } = await client.from(TABLE).select('id')
81
89
  .eq('oauth_token_id', identity.oauthTokenId).lt('created_at', new Date(now - 3_600_000).toISOString()).limit(100);
82
90
  if (oldError)
83
- return pending;
91
+ return exit('cleanup_read_failed');
84
92
  if (old?.length) {
85
93
  const { error: deleteError } = await client.from(TABLE).delete().eq('oauth_token_id', identity.oauthTokenId)
86
94
  .in('id', old.map((row) => row.id));
87
95
  if (deleteError)
88
- return pending;
96
+ return exit('cleanup_delete_failed');
89
97
  }
90
98
  const { data: approval, error: approvalError } = await client.from('action_approvals')
91
99
  .select('id, expires_at').eq('id', options.approvalId).eq('project_id', identity.projectId)
92
100
  .eq('oauth_token_id', identity.oauthTokenId).eq('action', options.action)
93
101
  .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;
102
+ if (signal.aborted)
103
+ return exit('request_aborted');
104
+ if (approvalError || !approval)
105
+ return exit(approvalError ? 'approval_lookup_failed' : 'row_not_found');
106
+ if (!(Date.parse(approval.expires_at) > Date.now()))
107
+ return exit('approval_expired');
96
108
  const { error } = await client.from(TABLE).insert({ id: promptId, approval_id: options.approvalId,
97
109
  project_id: identity.projectId, oauth_token_id: identity.oauthTokenId, user_id: identity.actorId,
98
110
  jsonrpc_request_id: requestId, channel: 'mcp_elicitation', status: 'pending',
99
- 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
+ created_at: new Date(now).toISOString(), expires_at: new Date(Math.min(now + ELICITATION_ANSWER_WINDOW_MS, Date.parse(approval.expires_at))).toISOString() });
112
+ if (signal.aborted)
113
+ return exit('request_aborted');
114
+ if (error)
115
+ return exit('prompt_insert_failed');
102
116
  // The id is created here and passed unchanged both to the row and the actual transport.
103
117
  await options.send({ jsonrpc: '2.0', id: requestId, method: 'elicitation/create', params: options.params });
104
118
  while (!signal.aborted && Date.now() < now + ELICITATION_HOLD_MS) {
105
119
  const { data: prompt, error: readError } = await client.from(TABLE).select('status, answer, expires_at')
106
120
  .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;
121
+ if (signal.aborted)
122
+ return exit('request_aborted');
123
+ if (readError || !prompt)
124
+ return exit(readError ? 'prompt_lookup_failed' : 'row_not_found');
125
+ if (!(Date.parse(prompt.expires_at) > Date.now()))
126
+ return exit('prompt_expired');
109
127
  if (prompt.status === 'answered')
110
- return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : pending;
128
+ return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : exit('invalid_answer');
111
129
  if (prompt.status !== 'pending')
112
- return pending;
130
+ return exit('prompt_not_pending');
113
131
  await new Promise(resolve => {
114
132
  const timer = setTimeout(done, Math.min(750, Math.max(0, now + ELICITATION_HOLD_MS - Date.now())));
115
133
  function done() { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); }
@@ -121,6 +139,7 @@ export async function relayElicitation(options) {
121
139
  }
122
140
  catch {
123
141
  // Refused inserts/updates, missing migrations and disconnected streams keep approve_url.
142
+ return exit(signal.aborted ? 'request_aborted' : 'relay_failed');
124
143
  }
125
- return pending;
144
+ return exit(signal.aborted ? 'request_aborted' : 'hold_elapsed');
126
145
  }
@@ -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.124",
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",