@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
|
-
|
|
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:
|
|
312
|
-
preview_text:
|
|
313
|
-
message: `
|
|
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
|
|
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
|
|
72
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
95
|
-
return
|
|
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 +
|
|
100
|
-
if (
|
|
101
|
-
return
|
|
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
|
|
108
|
-
return
|
|
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' :
|
|
128
|
+
return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : exit('invalid_answer');
|
|
111
129
|
if (prompt.status !== 'pending')
|
|
112
|
-
return
|
|
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
|
|
144
|
+
return exit(signal.aborted ? 'request_aborted' : 'hold_elapsed');
|
|
126
145
|
}
|
|
@@ -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.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",
|