@sendinel/mcp-server 1.0.121 → 1.0.122
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.
|
@@ -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;
|
|
@@ -99,7 +101,7 @@ export async function relayElicitation(options) {
|
|
|
99
101
|
return pending;
|
|
100
102
|
// The id is created here and passed unchanged both to the row and the actual transport.
|
|
101
103
|
await options.send({ jsonrpc: '2.0', id: requestId, method: 'elicitation/create', params: options.params });
|
|
102
|
-
while (!signal.aborted && Date.now() < now +
|
|
104
|
+
while (!signal.aborted && Date.now() < now + ELICITATION_HOLD_MS) {
|
|
103
105
|
const { data: prompt, error: readError } = await client.from(TABLE).select('status, answer, expires_at')
|
|
104
106
|
.eq('id', promptId).eq('oauth_token_id', identity.oauthTokenId).eq('user_id', identity.actorId).maybeSingle();
|
|
105
107
|
if (signal.aborted || readError || !prompt || !(Date.parse(prompt.expires_at) > Date.now()))
|
|
@@ -109,7 +111,7 @@ export async function relayElicitation(options) {
|
|
|
109
111
|
if (prompt.status !== 'pending')
|
|
110
112
|
return pending;
|
|
111
113
|
await new Promise(resolve => {
|
|
112
|
-
const timer = setTimeout(done, 750);
|
|
114
|
+
const timer = setTimeout(done, Math.min(750, Math.max(0, now + ELICITATION_HOLD_MS - Date.now())));
|
|
113
115
|
function done() { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); }
|
|
114
116
|
signal.addEventListener('abort', done, { once: true });
|
|
115
117
|
if (signal.aborted)
|
|
@@ -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;
|
|
@@ -9,7 +9,8 @@ import { approvalAppClientAllowed, approvalClientName, approval_channel_availabl
|
|
|
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
|
}
|
|
@@ -49,25 +50,40 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
49
50
|
let timeout;
|
|
50
51
|
const stopped = new Promise((_resolve, reject) => {
|
|
51
52
|
controller.signal.addEventListener('abort', () => reject(new Error('Elicitation ended')), { once: true });
|
|
52
|
-
timeout = setTimeout(abort,
|
|
53
|
+
timeout = setTimeout(abort, ELICITATION_HOLD_MS);
|
|
53
54
|
});
|
|
54
55
|
void stopped.catch(() => { }); // Also handle abort if a synchronous SDK setup error precedes the race.
|
|
55
56
|
const options = { relatedRequestId: context.requestId, signal: controller.signal,
|
|
56
|
-
timeout:
|
|
57
|
+
timeout: ELICITATION_HOLD_MS, maxTotalTimeout: ELICITATION_HOLD_MS };
|
|
58
|
+
let progressTimer;
|
|
59
|
+
const progressToken = context._meta?.progressToken;
|
|
60
|
+
if (progressToken !== undefined) {
|
|
61
|
+
let progress = 0;
|
|
62
|
+
progressTimer = setInterval(() => {
|
|
63
|
+
if (controller.signal.aborted)
|
|
64
|
+
return;
|
|
65
|
+
void context.server.server.notification({ method: 'notifications/progress',
|
|
66
|
+
params: { progressToken, progress: ++progress, message: 'Waiting for your approval decision' } }, { relatedRequestId: context.requestId }).catch(abort);
|
|
67
|
+
}, PROGRESS_INTERVAL_MS);
|
|
68
|
+
}
|
|
57
69
|
try {
|
|
58
70
|
const preview = buildApprovalPreview(frozen);
|
|
59
71
|
// Use the shared preview; bound the interactive message independently of the full fallback.
|
|
60
72
|
// Email content is untrusted display text, never instructions or an approval decision.
|
|
61
73
|
const short = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
|
|
62
|
-
const
|
|
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.',
|
|
63
81
|
`Subject: ${short(preview.subject || '(none)', 300)}`, `Sender: ${short(preview.sender, 300)}`,
|
|
64
82
|
`Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
|
|
65
|
-
`Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}
|
|
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');
|
|
66
85
|
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'] } };
|
|
86
|
+
const params = { mode: 'form', message, requestedSchema: { type: 'object', properties: {} } };
|
|
71
87
|
if (context.relay) {
|
|
72
88
|
const status = await Promise.race([context.relay({ approvalId: result.approval_id,
|
|
73
89
|
action: frozen.action, params, signal: controller.signal }), stopped]);
|
|
@@ -78,12 +94,10 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
78
94
|
const response = await Promise.race([context.server.server.elicitInput(params, options), stopped]);
|
|
79
95
|
if (controller.signal.aborted)
|
|
80
96
|
return pending;
|
|
81
|
-
//
|
|
82
|
-
if (response.action !== 'accept')
|
|
83
|
-
return pending;
|
|
84
|
-
const decision = response.content?.decision;
|
|
85
|
-
if (decision !== 'approve' && decision !== 'reject')
|
|
97
|
+
// Only the client's action is a decision. Extra content is untrusted and ignored.
|
|
98
|
+
if (response.action !== 'accept' && response.action !== 'decline')
|
|
86
99
|
return pending;
|
|
100
|
+
const decision = response.action === 'accept' ? 'approve' : 'reject';
|
|
87
101
|
const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
|
|
88
102
|
connectionId: context.connectionId, decision }), stopped]);
|
|
89
103
|
return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : pending;
|
|
@@ -116,10 +130,11 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
116
130
|
}
|
|
117
131
|
catch {
|
|
118
132
|
// Unsupported SDK modes, disconnections, timeouts and refused DB writes fail to the link.
|
|
119
|
-
//
|
|
133
|
+
// The hosted relay row remains valid for late authenticated answer POSTs.
|
|
120
134
|
}
|
|
121
135
|
finally {
|
|
122
136
|
clearTimeout(timeout);
|
|
137
|
+
clearInterval(progressTimer);
|
|
123
138
|
context.signal?.removeEventListener('abort', abort);
|
|
124
139
|
controller.abort();
|
|
125
140
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.122",
|
|
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",
|