@sendinel/mcp-server 1.0.104 → 1.0.106
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.
- package/build/auth.js +1 -0
- package/build/index.js +7 -1
- package/build/lib/action-approvals.d.ts +4 -0
- package/build/lib/action-approvals.js +66 -18
- package/build/lib/approval-capabilities.d.ts +17 -0
- package/build/lib/approval-capabilities.js +40 -0
- package/build/lib/approval-elicitation.d.ts +46 -0
- package/build/lib/approval-elicitation.js +114 -0
- package/build/lib/approval-human-callback.d.ts +19 -0
- package/build/lib/approval-human-callback.js +41 -0
- package/build/lib/approval-snapshot.d.ts +5 -0
- package/build/lib/approval-snapshot.js +106 -0
- package/build/server.js +6 -2
- package/package.json +1 -1
package/build/auth.js
CHANGED
|
@@ -75,6 +75,7 @@ export async function validateKey(key) {
|
|
|
75
75
|
scopes: row.scopes,
|
|
76
76
|
toolGroups: resolveToolGroupsForPlan(plan, row.tool_groups),
|
|
77
77
|
approvalMode: row.approval_mode ?? 'standard',
|
|
78
|
+
actsAsUserId: row.acts_as_user_id ?? null,
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
81
|
export async function authenticateBearer(token, overrideProjectId) {
|
package/build/index.js
CHANGED
|
@@ -7,7 +7,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
7
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
8
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
9
9
|
import express from 'express';
|
|
10
|
-
import { registerAllTools, SENDINEL_SERVER_INSTRUCTIONS } from './server.js';
|
|
10
|
+
import { registerAllTools, SENDINEL_SERVER_INSTRUCTIONS, isAgentConnection, isTesterConnection } from './server.js';
|
|
11
|
+
import { installApprovalElicitation } from './lib/approval-elicitation.js';
|
|
12
|
+
import { createHumanApprovalCallback } from './lib/approval-human-callback.js';
|
|
11
13
|
import { registerAllPrompts } from './prompts.js';
|
|
12
14
|
import { runWithClient } from './db.js';
|
|
13
15
|
import { initProjectClient, resolveProjectClientStrict, TenantResolutionError } from './byod-client.js';
|
|
@@ -38,6 +40,10 @@ async function runStdio() {
|
|
|
38
40
|
}
|
|
39
41
|
registerAllTools(server, { toolGroups, scopes });
|
|
40
42
|
registerAllPrompts(server, { toolGroups, scopes });
|
|
43
|
+
if (!isAgentConnection(info.scopes) && !isTesterConnection(info.scopes)) {
|
|
44
|
+
installApprovalElicitation(server, { projectId: info.projectId, userId: info.actsAsUserId,
|
|
45
|
+
createHumanDecision: createHumanApprovalCallback });
|
|
46
|
+
}
|
|
41
47
|
const t = new StdioServerTransport();
|
|
42
48
|
await server.connect(t);
|
|
43
49
|
process.stderr.write('Sendinel MCP server running (stdio)\n');
|
|
@@ -46,9 +46,13 @@ export interface ApprovalResult {
|
|
|
46
46
|
approve_via: string;
|
|
47
47
|
/** One-tap approval page; works on a phone and needs no project switching (#3661). */
|
|
48
48
|
approve_url: string;
|
|
49
|
+
preview_text: string;
|
|
50
|
+
decision_status: 'decision_pending' | 'approved' | 'rejected';
|
|
49
51
|
message: string;
|
|
50
52
|
}
|
|
51
53
|
export declare function approvalPageUrl(approvalId: string): string;
|
|
54
|
+
/** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
|
|
55
|
+
export declare function approvalPayloadHash(action: string, params: unknown): string;
|
|
52
56
|
/**
|
|
53
57
|
* Request human approval for an action. Creates a pending approval token
|
|
54
58
|
* and returns instructions for the agent to wait.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
2
3
|
import { db, requestContext } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
5
|
+
import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
|
|
4
6
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
7
|
+
import { elicitApproval } from './approval-elicitation.js';
|
|
5
8
|
/**
|
|
6
9
|
* Action approval tiers:
|
|
7
10
|
*
|
|
@@ -303,11 +306,15 @@ function normalizeApprovalParams(value) {
|
|
|
303
306
|
if (value && typeof value === 'object') {
|
|
304
307
|
return Object.fromEntries(Object.entries(value)
|
|
305
308
|
.filter(([, entryValue]) => entryValue !== undefined)
|
|
306
|
-
.sort(([a], [b]) => a
|
|
309
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
307
310
|
.map(([key, entryValue]) => [key, normalizeApprovalParams(entryValue)]));
|
|
308
311
|
}
|
|
309
312
|
return value;
|
|
310
313
|
}
|
|
314
|
+
/** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
|
|
315
|
+
export function approvalPayloadHash(action, params) {
|
|
316
|
+
return createHash('sha256').update(JSON.stringify(normalizeApprovalParams({ action, params }))).digest('hex');
|
|
317
|
+
}
|
|
311
318
|
function approvalParamsEqual(expected, actual) {
|
|
312
319
|
return JSON.stringify(normalizeApprovalParams(expected)) === JSON.stringify(normalizeApprovalParams(actual));
|
|
313
320
|
}
|
|
@@ -318,9 +325,18 @@ function approvalParamsEqual(expected, actual) {
|
|
|
318
325
|
export async function requestApproval(action, params, apiKeyId, channelContext, display) {
|
|
319
326
|
const tier = getApprovalTier(action, params);
|
|
320
327
|
const summary = buildApprovalSummary(action, params, display);
|
|
321
|
-
const expiresAt = new Date(Date.now() +
|
|
328
|
+
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
322
329
|
const { channel, verified } = channelContext ?? currentChannelContext();
|
|
323
330
|
const actor = requestContext();
|
|
331
|
+
// Ignore any model-supplied reserved field; freeze facts read by the server.
|
|
332
|
+
const cleanParams = withoutApprovalSnapshot(params);
|
|
333
|
+
const snapshot = await captureApprovalSnapshot(action, cleanParams, getProjectId());
|
|
334
|
+
const payload = cleanParams.payload;
|
|
335
|
+
if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
|
|
336
|
+
!approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
|
|
337
|
+
throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
|
|
338
|
+
}
|
|
339
|
+
const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
|
|
324
340
|
const { data, error } = await db()
|
|
325
341
|
.from('action_approvals')
|
|
326
342
|
.insert({
|
|
@@ -330,7 +346,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
330
346
|
agent_label: actor?.agentLabel ?? null,
|
|
331
347
|
action,
|
|
332
348
|
tier,
|
|
333
|
-
params,
|
|
349
|
+
params: frozenParams,
|
|
350
|
+
payload_hash: approvalPayloadHash(action, frozenParams),
|
|
334
351
|
summary,
|
|
335
352
|
expires_at: expiresAt,
|
|
336
353
|
channel,
|
|
@@ -346,7 +363,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
346
363
|
resource_id: params.resource_id ?? params.campaign_id ?? params.contact_id ?? null,
|
|
347
364
|
requested_by: apiKeyId ?? null,
|
|
348
365
|
}, getProjectId()).catch(() => { });
|
|
349
|
-
return {
|
|
366
|
+
return elicitApproval({
|
|
350
367
|
requires_approval: true,
|
|
351
368
|
approval_id: data.id,
|
|
352
369
|
tier,
|
|
@@ -355,8 +372,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
355
372
|
expires_at: expiresAt,
|
|
356
373
|
approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
|
|
357
374
|
approve_url: approvalPageUrl(data.id),
|
|
358
|
-
|
|
359
|
-
|
|
375
|
+
preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
|
|
376
|
+
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.`,
|
|
377
|
+
}, { action, params: frozenParams, summary }, getProjectId());
|
|
360
378
|
}
|
|
361
379
|
/**
|
|
362
380
|
* Check if an approval token has been approved.
|
|
@@ -365,7 +383,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
365
383
|
export async function checkApproval(approvalId, options = {}) {
|
|
366
384
|
const { data, error } = await db()
|
|
367
385
|
.from('action_approvals')
|
|
368
|
-
.select('action, status, decided_by, decided_via, expires_at, used_at, params')
|
|
386
|
+
.select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
|
|
369
387
|
.eq('id', approvalId)
|
|
370
388
|
.eq('project_id', getProjectId())
|
|
371
389
|
.maybeSingle();
|
|
@@ -374,17 +392,29 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
374
392
|
if (options.action && data.action !== options.action) {
|
|
375
393
|
return { approved: false, status: 'action_mismatch' };
|
|
376
394
|
}
|
|
377
|
-
if (options.
|
|
395
|
+
if (options.consume && data.payload_hash) {
|
|
396
|
+
if (!options.action || !options.params)
|
|
397
|
+
return { approved: false, status: 'payload_required' };
|
|
398
|
+
if (approvalPayloadHash(options.action, { ...withoutApprovalSnapshot(options.params), ...(data.params?.__approval_snapshot ? { __approval_snapshot: data.params.__approval_snapshot } : {}) }) !== data.payload_hash ||
|
|
399
|
+
approvalPayloadHash(data.action, data.params) !== data.payload_hash) {
|
|
400
|
+
return { approved: false, status: 'payload_changed_after_approval' };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (options.params && !approvalParamsEqual(withoutApprovalSnapshot(options.params), withoutApprovalSnapshot(data.params))) {
|
|
378
404
|
return { approved: false, status: 'params_mismatch' };
|
|
379
405
|
}
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
.eq('id', approvalId);
|
|
406
|
+
// Approved tokens expire too. Invalid timestamps fail closed. Older rows may have a
|
|
407
|
+
// shorter lifetime; no row can authorize an apply more than 24 hours after its request.
|
|
408
|
+
const requested = data.requested_at == null ? null : Date.parse(data.requested_at);
|
|
409
|
+
const expiry = Math.min(Date.parse(data.expires_at), requested == null ? Infinity : requested + 24 * 60 * 60 * 1000);
|
|
410
|
+
if (!Number.isFinite(expiry) || (requested != null && !Number.isFinite(requested)) || expiry <= Date.now()) {
|
|
386
411
|
return { approved: false, status: 'expired' };
|
|
387
412
|
}
|
|
413
|
+
if (data.used_at || data.status === 'used')
|
|
414
|
+
return { approved: false, status: 'consumed' };
|
|
415
|
+
if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)) {
|
|
416
|
+
return { approved: false, status: 'human_decision_required' };
|
|
417
|
+
}
|
|
388
418
|
if (data.status !== 'approved' || !options.consume) {
|
|
389
419
|
if (data.used_at)
|
|
390
420
|
return { approved: false, status: 'consumed' };
|
|
@@ -394,15 +424,33 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
394
424
|
decided_by: data.decided_by,
|
|
395
425
|
};
|
|
396
426
|
}
|
|
397
|
-
|
|
427
|
+
if (data.params?.__approval_snapshot) {
|
|
428
|
+
try {
|
|
429
|
+
const current = await captureApprovalSnapshot(data.action, withoutApprovalSnapshot(data.params), getProjectId());
|
|
430
|
+
if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
|
|
431
|
+
return { approved: false, status: 'target_changed_after_approval' };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return { approved: false, status: 'target_revalidation_failed' };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (expiry <= Date.now())
|
|
439
|
+
return { approved: false, status: 'expired' };
|
|
440
|
+
const claimQuery = db()
|
|
398
441
|
.from('action_approvals')
|
|
399
442
|
.update({ status: 'used', used_at: new Date().toISOString() })
|
|
400
443
|
.eq('id', approvalId)
|
|
401
444
|
.eq('project_id', getProjectId())
|
|
402
445
|
.eq('status', 'approved')
|
|
403
446
|
.is('used_at', null)
|
|
404
|
-
.
|
|
405
|
-
.
|
|
447
|
+
.eq('expires_at', data.expires_at)
|
|
448
|
+
.gt('expires_at', new Date().toISOString())
|
|
449
|
+
.eq('decided_via', data.decided_via)
|
|
450
|
+
.eq('params', JSON.stringify(data.params))
|
|
451
|
+
.gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
|
452
|
+
const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
|
|
453
|
+
const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
|
|
406
454
|
if (claimError || !claimed) {
|
|
407
455
|
return { approved: false, status: 'consumed' };
|
|
408
456
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const MCP_APP_EXTENSION = "io.modelcontextprotocol/ui";
|
|
2
|
+
export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
|
|
3
|
+
export type ApprovalClientCapabilities = Readonly<{
|
|
4
|
+
elicitation_form: boolean;
|
|
5
|
+
elicitation_url: boolean;
|
|
6
|
+
mcp_app: boolean;
|
|
7
|
+
}>;
|
|
8
|
+
export type ApprovalChannelAvailable = 'mcp_app' | 'mcp_elicitation_form' | 'mcp_elicitation_url' | 'plain_text';
|
|
9
|
+
/** Reads only declared initialize capabilities. No user agent or client-name guessing. */
|
|
10
|
+
export declare function probeApprovalCapabilities(declared: unknown): ApprovalClientCapabilities;
|
|
11
|
+
export declare function rememberApprovalCapabilities(session: object, declared: unknown): ApprovalClientCapabilities;
|
|
12
|
+
export declare function approval_channel_available(session: object): ApprovalChannelAvailable;
|
|
13
|
+
/** Each SDK Server is a session boundary, including stateless HTTP request instances. */
|
|
14
|
+
export declare function installApprovalCapabilityProbe(session: {
|
|
15
|
+
oninitialized?: () => void;
|
|
16
|
+
getClientCapabilities(): unknown;
|
|
17
|
+
}): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
|
|
2
|
+
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
|
|
3
|
+
const sessions = new WeakMap();
|
|
4
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
/** Reads only declared initialize capabilities. No user agent or client-name guessing. */
|
|
6
|
+
export function probeApprovalCapabilities(declared) {
|
|
7
|
+
const capabilities = object(declared);
|
|
8
|
+
const elicitation = object(capabilities?.elicitation);
|
|
9
|
+
const extensions = object(capabilities?.extensions);
|
|
10
|
+
const app = object(extensions?.[MCP_APP_EXTENSION]);
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
// The MCP spec defines elicitation:{} as form support for older clients.
|
|
13
|
+
elicitation_form: !!elicitation && (Object.keys(elicitation).length === 0 || !!object(elicitation.form)),
|
|
14
|
+
elicitation_url: !!object(elicitation?.url),
|
|
15
|
+
mcp_app: !!app && Array.isArray(app.mimeTypes) && app.mimeTypes.includes(MCP_APP_MIME_TYPE),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function rememberApprovalCapabilities(session, declared) {
|
|
19
|
+
const capabilities = probeApprovalCapabilities(declared);
|
|
20
|
+
sessions.set(session, capabilities);
|
|
21
|
+
return capabilities;
|
|
22
|
+
}
|
|
23
|
+
export function approval_channel_available(session) {
|
|
24
|
+
const capabilities = sessions.get(session);
|
|
25
|
+
if (capabilities?.mcp_app)
|
|
26
|
+
return 'mcp_app';
|
|
27
|
+
if (capabilities?.elicitation_form)
|
|
28
|
+
return 'mcp_elicitation_form';
|
|
29
|
+
if (capabilities?.elicitation_url)
|
|
30
|
+
return 'mcp_elicitation_url';
|
|
31
|
+
return 'plain_text';
|
|
32
|
+
}
|
|
33
|
+
/** Each SDK Server is a session boundary, including stateless HTTP request instances. */
|
|
34
|
+
export function installApprovalCapabilityProbe(session) {
|
|
35
|
+
const previous = session.oninitialized;
|
|
36
|
+
session.oninitialized = () => {
|
|
37
|
+
rememberApprovalCapabilities(session, session.getClientCapabilities());
|
|
38
|
+
previous?.call(session);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { RequestId } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
type Decision = 'approve' | 'reject';
|
|
4
|
+
type HumanCallback = {
|
|
5
|
+
interactionId: string;
|
|
6
|
+
complete(response: {
|
|
7
|
+
interactionId: string;
|
|
8
|
+
connectionId: string;
|
|
9
|
+
decision: Decision;
|
|
10
|
+
}): Promise<boolean>;
|
|
11
|
+
};
|
|
12
|
+
/** Injected at authenticated transport startup, never taken from tool arguments. */
|
|
13
|
+
type TransportBinding = {
|
|
14
|
+
projectId: string;
|
|
15
|
+
userId?: string | null;
|
|
16
|
+
createHumanDecision(binding: {
|
|
17
|
+
approvalId: string;
|
|
18
|
+
projectId: string;
|
|
19
|
+
connectionId: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
channel: 'mcp_elicitation';
|
|
22
|
+
}): HumanCallback;
|
|
23
|
+
};
|
|
24
|
+
type RequestExtra = {
|
|
25
|
+
requestId?: RequestId;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
};
|
|
28
|
+
export declare function installApprovalElicitation(server: McpServer, binding: TransportBinding): void;
|
|
29
|
+
/** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
|
|
30
|
+
export declare function runWithApprovalElicitation<T>(server: McpServer, extra: RequestExtra | undefined, run: () => T): T;
|
|
31
|
+
type ApprovalFallback = {
|
|
32
|
+
approval_id: string;
|
|
33
|
+
approve_url: string;
|
|
34
|
+
preview_text: string;
|
|
35
|
+
tier: string;
|
|
36
|
+
};
|
|
37
|
+
export type ElicitationDecision = 'decision_pending' | 'approved' | 'rejected';
|
|
38
|
+
/** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
|
|
39
|
+
export declare function elicitApproval<T extends ApprovalFallback>(result: T, frozen: {
|
|
40
|
+
action: string;
|
|
41
|
+
params: unknown;
|
|
42
|
+
summary: string;
|
|
43
|
+
}, projectId: string): Promise<T & {
|
|
44
|
+
decision_status: ElicitationDecision;
|
|
45
|
+
}>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
|
|
4
|
+
import { db } from '../db.js';
|
|
5
|
+
import { approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
|
|
6
|
+
const sessions = new WeakMap();
|
|
7
|
+
const requests = new AsyncLocalStorage();
|
|
8
|
+
const TIMEOUT_MS = 60_000;
|
|
9
|
+
export function installApprovalElicitation(server, binding) {
|
|
10
|
+
sessions.set(server, { ...binding, connectionId: randomUUID() });
|
|
11
|
+
}
|
|
12
|
+
/** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
|
|
13
|
+
export function runWithApprovalElicitation(server, extra, run) {
|
|
14
|
+
const binding = sessions.get(server);
|
|
15
|
+
// Nested gateway dispatch retains the originating connection/request. The hosted bridge
|
|
16
|
+
// has no binding and cannot impersonate its outer HTTP client's capabilities.
|
|
17
|
+
if (!binding || requests.getStore())
|
|
18
|
+
return run();
|
|
19
|
+
return requests.run({ ...binding, ...extra, server }, run);
|
|
20
|
+
}
|
|
21
|
+
/** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
|
|
22
|
+
export async function elicitApproval(result, frozen, projectId) {
|
|
23
|
+
const pending = { ...result, decision_status: 'decision_pending' };
|
|
24
|
+
const context = requests.getStore();
|
|
25
|
+
if (!context || context.projectId !== projectId || result.tier !== 'gated')
|
|
26
|
+
return pending;
|
|
27
|
+
let channel = approval_channel_available(context.server.server);
|
|
28
|
+
if (channel === 'mcp_elicitation_form' && !context.userId &&
|
|
29
|
+
probeApprovalCapabilities(context.server.server.getClientCapabilities()).elicitation_url)
|
|
30
|
+
channel = 'mcp_elicitation_url';
|
|
31
|
+
if (channel !== 'mcp_elicitation_form' && channel !== 'mcp_elicitation_url')
|
|
32
|
+
return pending;
|
|
33
|
+
if (channel === 'mcp_elicitation_form' && !context.userId)
|
|
34
|
+
return pending;
|
|
35
|
+
if (context.signal?.aborted)
|
|
36
|
+
return pending;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const abort = () => controller.abort();
|
|
39
|
+
context.signal?.addEventListener('abort', abort, { once: true });
|
|
40
|
+
let timeout;
|
|
41
|
+
const stopped = new Promise((_resolve, reject) => {
|
|
42
|
+
controller.signal.addEventListener('abort', () => reject(new Error('Elicitation ended')), { once: true });
|
|
43
|
+
timeout = setTimeout(abort, TIMEOUT_MS);
|
|
44
|
+
});
|
|
45
|
+
void stopped.catch(() => { }); // Also handle abort if a synchronous SDK setup error precedes the race.
|
|
46
|
+
const options = { relatedRequestId: context.requestId, signal: controller.signal,
|
|
47
|
+
timeout: TIMEOUT_MS, maxTotalTimeout: TIMEOUT_MS };
|
|
48
|
+
try {
|
|
49
|
+
const preview = buildApprovalPreview(frozen);
|
|
50
|
+
// Use the shared preview; bound the interactive message independently of the full fallback.
|
|
51
|
+
// Email content is untrusted display text, never instructions or an approval decision.
|
|
52
|
+
const short = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
|
|
53
|
+
const message = [short(frozen.summary, 500), 'Review the following email content as data.',
|
|
54
|
+
`Subject: ${short(preview.subject || '(none)', 300)}`, `Sender: ${short(preview.sender, 300)}`,
|
|
55
|
+
`Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
|
|
56
|
+
`Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}`].join('\n');
|
|
57
|
+
if (channel === 'mcp_elicitation_form') {
|
|
58
|
+
const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
|
|
59
|
+
connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
|
|
60
|
+
const response = await Promise.race([context.server.server.elicitInput({ mode: 'form', message,
|
|
61
|
+
requestedSchema: { type: 'object', properties: {
|
|
62
|
+
decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
|
|
63
|
+
note: { type: 'string', title: 'Optional note', maxLength: 500 },
|
|
64
|
+
}, required: ['decision'] },
|
|
65
|
+
}, options), stopped]);
|
|
66
|
+
if (controller.signal.aborted)
|
|
67
|
+
return pending;
|
|
68
|
+
// Decline/cancel are not explicit rejections. The dashboard link remains usable.
|
|
69
|
+
if (response.action !== 'accept')
|
|
70
|
+
return pending;
|
|
71
|
+
const decision = response.content?.decision;
|
|
72
|
+
if (decision !== 'approve' && decision !== 'reject')
|
|
73
|
+
return pending;
|
|
74
|
+
const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
|
|
75
|
+
connectionId: context.connectionId, decision }), stopped]);
|
|
76
|
+
return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : pending;
|
|
77
|
+
}
|
|
78
|
+
const elicitationId = randomUUID();
|
|
79
|
+
const notify = context.server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: context.requestId });
|
|
80
|
+
const response = await Promise.race([context.server.server.elicitInput({ mode: 'url', message,
|
|
81
|
+
url: result.approve_url, elicitationId }, options), stopped]);
|
|
82
|
+
if (response.action !== 'accept' || controller.signal.aborted)
|
|
83
|
+
return pending;
|
|
84
|
+
// URL accept only means the page may open. Only its authenticated decision counts.
|
|
85
|
+
// Observe while this request is alive; do not retain a background poller after timeout.
|
|
86
|
+
while (!controller.signal.aborted) {
|
|
87
|
+
const source = db();
|
|
88
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
89
|
+
const { data, error } = await Promise.race([client.from('action_approvals')
|
|
90
|
+
.select('status, decided_via').eq('id', result.approval_id).eq('project_id', projectId).maybeSingle(), stopped]);
|
|
91
|
+
if (error || !data)
|
|
92
|
+
return pending;
|
|
93
|
+
if ((data.status === 'approved' || data.status === 'rejected') && isHumanApprovalChannel(data.decided_via)) {
|
|
94
|
+
await Promise.race([notify(), stopped]);
|
|
95
|
+
return { ...result, decision_status: data.status };
|
|
96
|
+
}
|
|
97
|
+
await Promise.race([new Promise(resolve => {
|
|
98
|
+
const timer = setTimeout(done, 1000);
|
|
99
|
+
function done() { clearTimeout(timer); controller.signal.removeEventListener('abort', done); resolve(); }
|
|
100
|
+
controller.signal.addEventListener('abort', done, { once: true });
|
|
101
|
+
}), stopped]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Unsupported SDK modes, disconnections, timeouts and refused DB writes fail to the link.
|
|
106
|
+
// No continuation consumes a late elicitation response or grants model decision authority.
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
context.signal?.removeEventListener('abort', abort);
|
|
111
|
+
controller.abort();
|
|
112
|
+
}
|
|
113
|
+
return pending;
|
|
114
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server integration only. The transport creates this closure for one authenticated human
|
|
3
|
+
* interaction and retains it outside the tool registry. Neither its id nor decision is a tool
|
|
4
|
+
* argument. Front ends must call complete only from the matching elicitation/app response.
|
|
5
|
+
*/
|
|
6
|
+
export declare function createHumanApprovalCallback(binding: {
|
|
7
|
+
approvalId: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
connectionId: string;
|
|
10
|
+
userId: string;
|
|
11
|
+
channel: 'mcp_elicitation' | 'mcp_app';
|
|
12
|
+
}): {
|
|
13
|
+
interactionId: `${string}-${string}-${string}-${string}-${string}`;
|
|
14
|
+
complete(response: {
|
|
15
|
+
interactionId: string;
|
|
16
|
+
connectionId: string;
|
|
17
|
+
decision: 'approve' | 'reject';
|
|
18
|
+
}): Promise<boolean>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
/**
|
|
4
|
+
* Server integration only. The transport creates this closure for one authenticated human
|
|
5
|
+
* interaction and retains it outside the tool registry. Neither its id nor decision is a tool
|
|
6
|
+
* argument. Front ends must call complete only from the matching elicitation/app response.
|
|
7
|
+
*/
|
|
8
|
+
export function createHumanApprovalCallback(binding) {
|
|
9
|
+
const interactionId = randomUUID();
|
|
10
|
+
let completed = false;
|
|
11
|
+
return {
|
|
12
|
+
interactionId,
|
|
13
|
+
async complete(response) {
|
|
14
|
+
if (completed || response.interactionId !== interactionId || response.connectionId !== binding.connectionId)
|
|
15
|
+
return false;
|
|
16
|
+
if (response.decision !== 'approve' && response.decision !== 'reject')
|
|
17
|
+
return false;
|
|
18
|
+
completed = true;
|
|
19
|
+
const source = db();
|
|
20
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
21
|
+
const now = new Date().toISOString();
|
|
22
|
+
const { data, error } = await client.from('action_approvals')
|
|
23
|
+
.update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
|
|
24
|
+
decided_by: binding.userId, decided_at: now })
|
|
25
|
+
.eq('id', binding.approvalId).eq('project_id', binding.projectId)
|
|
26
|
+
.eq('status', 'pending').is('used_at', null).gt('expires_at', now)
|
|
27
|
+
.select('id, payload_hash').maybeSingle();
|
|
28
|
+
if (error || !data)
|
|
29
|
+
return false;
|
|
30
|
+
const { error: auditError } = await client.from('audit_log').insert({
|
|
31
|
+
project_id: binding.projectId, actor_type: 'user', actor_id: binding.userId,
|
|
32
|
+
action: 'approval.submitted', resource_type: 'approval', resource_id: binding.approvalId,
|
|
33
|
+
details: { status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
|
|
34
|
+
connection_id: binding.connectionId, interaction_id: interactionId, payload_hash: data.payload_hash },
|
|
35
|
+
});
|
|
36
|
+
if (auditError)
|
|
37
|
+
throw new Error('Approval recorded, but decision audit failed');
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
type Params = Record<string, any>;
|
|
2
|
+
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
3
|
+
export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
|
|
4
|
+
export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
4
|
+
const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
|
|
5
|
+
'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
|
|
6
|
+
'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
|
|
7
|
+
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
8
|
+
export async function captureApprovalSnapshot(action, params, projectId) {
|
|
9
|
+
const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
10
|
+
if (!TARGET_ACTIONS.has(action))
|
|
11
|
+
return snapshot;
|
|
12
|
+
const source = db();
|
|
13
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
14
|
+
const body = params.payload ?? params;
|
|
15
|
+
const ids = [...new Set((Array.isArray(params.campaign_ids) ? params.campaign_ids :
|
|
16
|
+
typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
|
|
17
|
+
if (ids.length) {
|
|
18
|
+
snapshot.campaigns = await readAllRows(client.from('campaigns')
|
|
19
|
+
.select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
|
|
20
|
+
.eq('project_id', projectId).in('id', ids));
|
|
21
|
+
if (snapshot.campaigns.length !== ids.length)
|
|
22
|
+
throw new Error('Approval target not found');
|
|
23
|
+
snapshot.steps = await readAllRows(client.from('campaign_steps')
|
|
24
|
+
.select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
|
|
25
|
+
.in('campaign_id', ids));
|
|
26
|
+
let count = 0;
|
|
27
|
+
for (const id of ids) {
|
|
28
|
+
const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', id);
|
|
29
|
+
if (result.error || result.count == null)
|
|
30
|
+
throw new Error('Approval audience count unavailable');
|
|
31
|
+
count += result.count;
|
|
32
|
+
}
|
|
33
|
+
snapshot.recipient_count = count;
|
|
34
|
+
}
|
|
35
|
+
if (typeof params.issue_id === 'string') {
|
|
36
|
+
const issue = await client.from('newsletter_issues').select('id, newsletter_id, subject, content, preheader, status, scheduled_at')
|
|
37
|
+
.eq('id', params.issue_id).eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
38
|
+
const newsletter = await client.from('newsletters').select('id, name, site_id, status, sender_name, sender_email')
|
|
39
|
+
.eq('id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
40
|
+
if (issue.error || !issue.data || newsletter.error || !newsletter.data)
|
|
41
|
+
throw new Error('Approval newsletter unavailable');
|
|
42
|
+
snapshot.issue = issue.data;
|
|
43
|
+
snapshot.newsletter = newsletter.data;
|
|
44
|
+
snapshot.subscriptions = await readAllRows(client.from('newsletter_subscriptions').select('id, subscriber_id')
|
|
45
|
+
.eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).eq('status', 'active').not('subscriber_id', 'is', null));
|
|
46
|
+
snapshot.subscriptions.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
47
|
+
snapshot.recipient_count = snapshot.subscriptions.length;
|
|
48
|
+
}
|
|
49
|
+
const siteIds = [...new Set([body.site_id, snapshot.newsletter?.site_id, ...snapshot.campaigns.map((c) => c.site_id)]
|
|
50
|
+
.filter((id) => typeof id === 'string' && id.length > 0))].sort();
|
|
51
|
+
for (const id of siteIds) {
|
|
52
|
+
const { data, error } = await client.from('sites')
|
|
53
|
+
.select('id, name, from_name, from_email, sending_domain, physical_address, unsub_link_enabled, unsub_footer_template')
|
|
54
|
+
.eq('id', id).eq('project_id', projectId).maybeSingle();
|
|
55
|
+
if (error || !data)
|
|
56
|
+
throw new Error('Approval sender/footer unavailable');
|
|
57
|
+
snapshot.sites.push(data);
|
|
58
|
+
}
|
|
59
|
+
async function readFrozenContacts(contacts) {
|
|
60
|
+
const found = [];
|
|
61
|
+
for (const chunk of chunkIds([...new Set(contacts.map(String))])) {
|
|
62
|
+
const values = await readAllRows(client.from('subscribers').select('id')
|
|
63
|
+
.eq('project_id', projectId).in('id', chunk));
|
|
64
|
+
found.push(...values.map(value => value.id));
|
|
65
|
+
}
|
|
66
|
+
if (found.length !== new Set(contacts).size)
|
|
67
|
+
throw new Error('Approval recipients changed or unavailable');
|
|
68
|
+
return found.sort();
|
|
69
|
+
}
|
|
70
|
+
if (typeof body.segment_id === 'string') {
|
|
71
|
+
const { data, error } = await client.from('segments').select('id, name, rules, site_id')
|
|
72
|
+
.eq('id', body.segment_id).eq('project_id', projectId).maybeSingle();
|
|
73
|
+
if (error || !data)
|
|
74
|
+
throw new Error('Approval segment unavailable');
|
|
75
|
+
snapshot.segment = data;
|
|
76
|
+
// launch_campaign executes a frozen list, so new segment matches cannot expand its audience.
|
|
77
|
+
// The saved segment definition is still compared; deleted original recipients invalidate it.
|
|
78
|
+
const rules = scopeSegmentRulesToSite(data.rules, data.site_id);
|
|
79
|
+
snapshot.contact_ids = action === 'launch_campaign' && Array.isArray(params.generated?.contact_ids)
|
|
80
|
+
? await readFrozenContacts(params.generated.contact_ids)
|
|
81
|
+
: typeof body.site_id === 'string'
|
|
82
|
+
? (await resolveSendAudience(projectId, body.site_id, rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort()
|
|
83
|
+
: (await resolveSegmentContactIds(projectId, rules)).sort();
|
|
84
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
85
|
+
}
|
|
86
|
+
else if (body.rules && typeof body.site_id === 'string') {
|
|
87
|
+
snapshot.contact_ids = (await resolveSendAudience(projectId, body.site_id, body.rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort();
|
|
88
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const contacts = body.contact_ids ?? params.generated?.contact_ids;
|
|
92
|
+
if (Array.isArray(contacts)) {
|
|
93
|
+
snapshot.contact_ids = await readFrozenContacts(contacts);
|
|
94
|
+
snapshot.recipient_count = snapshot.contact_ids.length;
|
|
95
|
+
}
|
|
96
|
+
else if (typeof body.to === 'string')
|
|
97
|
+
snapshot.recipient_count = 1;
|
|
98
|
+
}
|
|
99
|
+
snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
100
|
+
snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
101
|
+
return snapshot;
|
|
102
|
+
}
|
|
103
|
+
export function withoutApprovalSnapshot(params) {
|
|
104
|
+
const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
|
|
105
|
+
return rest;
|
|
106
|
+
}
|
package/build/server.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
|
|
2
|
+
import { runWithApprovalElicitation } from './lib/approval-elicitation.js';
|
|
1
3
|
import { runWithToolContext } from './db.js';
|
|
2
4
|
import { isApprovalGatedAction } from './lib/action-approvals.js';
|
|
3
5
|
import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
|
|
@@ -353,11 +355,11 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
|
353
355
|
const text = agent ? agentConnectionRefusal(name) : tester ? testerConnectionRefusal(name) : `Forbidden: this action requires "${required}" scope.`;
|
|
354
356
|
return { content: [{ type: 'text', text }] };
|
|
355
357
|
}
|
|
356
|
-
return runWithToolContext(toolGroup, name, () => handler(...args));
|
|
358
|
+
return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)));
|
|
357
359
|
};
|
|
358
360
|
return target.registerTool(name, config, guardedHandler);
|
|
359
361
|
}
|
|
360
|
-
return target.registerTool(name, config, (...args) => runWithToolContext(toolGroup, name, () => handler(...args)));
|
|
362
|
+
return target.registerTool(name, config, (...args) => runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args))));
|
|
361
363
|
};
|
|
362
364
|
}
|
|
363
365
|
return Reflect.get(target, prop, receiver);
|
|
@@ -377,6 +379,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
|
|
|
377
379
|
'On the hosted connection, any tool call (and invoke_tool) also accepts an optional project_id to act on one project for that call only; it is checked each time, and approval-gated tools require it when the connection was switched to another project. In gateway mode, find tools with search_tools (plain words work, for example "send email" or "last campaign"), check arguments with describe_tool, then call invoke_tool.',
|
|
378
380
|
].join(' ');
|
|
379
381
|
export function registerAllTools(server, options) {
|
|
382
|
+
if (server.server)
|
|
383
|
+
installApprovalCapabilityProbe(server.server);
|
|
380
384
|
const rawScopes = options?.scopes ?? [];
|
|
381
385
|
if (rawScopes.includes('gateway')) {
|
|
382
386
|
const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.106",
|
|
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",
|