@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)
|
|
24
|
-
if (result?.action !== 'accept'
|
|
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:
|
|
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
|
|
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
|
|
70
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
93
|
-
return
|
|
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 (
|
|
99
|
-
return
|
|
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 +
|
|
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
|
|
106
|
-
return
|
|
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' :
|
|
127
|
+
return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : exit('invalid_answer');
|
|
109
128
|
if (prompt.status !== 'pending')
|
|
110
|
-
return
|
|
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
|
|
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
|
|
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
|
|
30
|
-
return
|
|
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
|
|
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
|
|
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
|
|
50
|
+
return exit('eligibility_refused');
|
|
44
51
|
if (context.signal?.aborted)
|
|
45
|
-
return
|
|
52
|
+
return exit('request_aborted');
|
|
46
53
|
const controller = new AbortController();
|
|
47
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
//
|
|
82
|
-
if (response.action !== 'accept')
|
|
83
|
-
return
|
|
84
|
-
const decision = response.
|
|
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' } :
|
|
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 (
|
|
96
|
-
return
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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",
|