@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
|
|
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
|
|
72
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
95
|
-
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');
|
|
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 (
|
|
101
|
-
return
|
|
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
|
|
108
|
-
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');
|
|
109
126
|
if (prompt.status === 'answered')
|
|
110
|
-
return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' :
|
|
127
|
+
return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : exit('invalid_answer');
|
|
111
128
|
if (prompt.status !== 'pending')
|
|
112
|
-
return
|
|
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
|
|
143
|
+
return exit(signal.aborted ? 'request_aborted' : 'hold_elapsed');
|
|
126
144
|
}
|
|
@@ -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
|
|
31
|
-
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');
|
|
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
|
|
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
|
|
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
|
|
50
|
+
return exit('eligibility_refused');
|
|
45
51
|
if (context.signal?.aborted)
|
|
46
|
-
return
|
|
52
|
+
return exit('request_aborted');
|
|
47
53
|
const controller = new AbortController();
|
|
48
|
-
|
|
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(
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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' } :
|
|
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 (
|
|
110
|
-
return
|
|
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
|
|
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.
|
|
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",
|