@sendinel/mcp-server 1.0.105 → 1.0.107
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/approval-app-html.d.ts +2 -0
- package/build/approval-app-html.js +124 -0
- package/build/auth.js +1 -0
- package/build/index.js +7 -1
- package/build/lib/action-approvals.d.ts +1 -0
- package/build/lib/action-approvals.js +6 -2
- package/build/lib/approval-app-conventions.d.ts +25 -0
- package/build/lib/approval-app-conventions.js +24 -0
- package/build/lib/approval-app.d.ts +43 -0
- package/build/lib/approval-app.js +161 -0
- package/build/lib/approval-elicitation.d.ts +46 -0
- package/build/lib/approval-elicitation.js +116 -0
- package/build/server.js +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Single-file, network-free Apps UI; email images load only inside scriptless srcdoc. */
|
|
2
|
+
export const APPROVAL_APP_HTML = String.raw `<!doctype html>
|
|
3
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
4
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-__NONCE__'; style-src 'unsafe-inline'; img-src https: data:; frame-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Review email approval</title>
|
|
7
|
+
<style>body{font:15px system-ui,sans-serif;margin:16px;color:#17212b;background:white}pre{white-space:pre-wrap;overflow-wrap:anywhere}section{margin:20px 0}dt{font-weight:650}dd{margin:0 0 10px;white-space:pre-wrap;overflow-wrap:anywhere}.devices{display:flex;gap:16px;overflow:auto}iframe{height:480px;border:1px solid #ccd3db;background:white;flex:none}button{padding:12px 24px;margin:8px 8px 8px 0;border:1px solid #667;border-radius:6px;background:white;color:#17212b}#approve{background:#0c857a;color:white}button:disabled{opacity:.5}#status{white-space:pre-wrap}</style>
|
|
8
|
+
</head><body><h1>Review email</h1><p id="status" role="status">Connecting to approval host…</p>
|
|
9
|
+
<main id="preview"></main><button id="approve" disabled>Approve</button><button id="reject" disabled>Reject</button>
|
|
10
|
+
<script id="approval-data" type="application/json" nonce="__NONCE__">__APP_DATA__</script>
|
|
11
|
+
<script nonce="__NONCE__">
|
|
12
|
+
(() => {
|
|
13
|
+
'use strict';
|
|
14
|
+
let data = JSON.parse(document.getElementById('approval-data').textContent);
|
|
15
|
+
let ready = false, started = false, serial = 0;
|
|
16
|
+
const pending = new Map();
|
|
17
|
+
const status = document.getElementById('status');
|
|
18
|
+
const buttons = [document.getElementById('approve'), document.getElementById('reject')];
|
|
19
|
+
const notify = (method, params) => parent.postMessage({jsonrpc:'2.0',method,params}, '*');
|
|
20
|
+
function request(method, params) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const id = ++serial;
|
|
23
|
+
const timeout = setTimeout(() => { pending.delete(id); reject(new Error('Host did not respond; open the dashboard approval link.')); }, 15000);
|
|
24
|
+
pending.set(id, {resolve, reject, timeout});
|
|
25
|
+
parent.postMessage({jsonrpc:'2.0',id,method,params}, '*');
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function text(tag, value, target) {
|
|
29
|
+
const node = document.createElement(tag); node.textContent = String(value ?? 'unknown'); target.append(node); return node;
|
|
30
|
+
}
|
|
31
|
+
function safeEmail(html) {
|
|
32
|
+
// Parse inertly, then allow only email layout/formatting. No active elements,
|
|
33
|
+
// forms, navigation, event handlers, SVG/MathML, external CSS or arbitrary URLs.
|
|
34
|
+
const template = document.createElement('template'); template.innerHTML = html;
|
|
35
|
+
const tags = new Set('HTML HEAD BODY TITLE STYLE A B BLOCKQUOTE BR CENTER DIV EM FONT H1 H2 H3 H4 H5 H6 HR IMG LI OL P SMALL SPAN STRONG SUB SUP TABLE TBODY TD TFOOT TH THEAD TR U UL'.split(' '));
|
|
36
|
+
const attrs = new Set('style class id title alt width height align valign bgcolor border cellpadding cellspacing colspan rowspan role color face size'.split(' '));
|
|
37
|
+
for (const el of [...template.content.querySelectorAll('*')]) {
|
|
38
|
+
if (!tags.has(el.tagName) || el.namespaceURI !== 'http://www.w3.org/1999/xhtml') { el.remove(); continue; }
|
|
39
|
+
for (const attr of [...el.attributes]) {
|
|
40
|
+
if (el.tagName === 'IMG' && attr.name === 'src' && (/^https:\/\//i.test(attr.value) || /^data:image\/(png|gif|jpeg|webp);base64,/i.test(attr.value))) continue;
|
|
41
|
+
if (!attrs.has(attr.name)) el.removeAttribute(attr.name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// CSP precedes every email element. The empty sandbox independently forbids
|
|
45
|
+
// scripts, same-origin access, top navigation, forms, popups and downloads.
|
|
46
|
+
const csp = document.createElement('meta'); csp.httpEquiv = 'Content-Security-Policy';
|
|
47
|
+
csp.content = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src https: data:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
48
|
+
return '<!doctype html><html><head>' + csp.outerHTML + '</head><body>' + template.innerHTML + '</body></html>';
|
|
49
|
+
}
|
|
50
|
+
function render() {
|
|
51
|
+
const root = document.getElementById('preview'); root.replaceChildren();
|
|
52
|
+
if (!data.preview) return;
|
|
53
|
+
const p = data.preview;
|
|
54
|
+
text('pre', p.plain_summary, root);
|
|
55
|
+
const dl = document.createElement('dl'); root.append(dl);
|
|
56
|
+
for (const [label,value] of [['From',p.sender],['Subject',p.subject],['Preheader',p.preheader || '(none)'],['Recipients',p.recipient_count == null ? 'unknown' : p.recipient_count],['Footer',p.footer_text || 'unknown']]) {
|
|
57
|
+
text('dt',label,dl); text('dd',value,dl);
|
|
58
|
+
}
|
|
59
|
+
for (const change of data.diff || []) {
|
|
60
|
+
const section = document.createElement('section'); root.append(section);
|
|
61
|
+
text('h2',change.field + ' — before → after',section);
|
|
62
|
+
text('pre',change.before + '\n→\n' + change.after,section);
|
|
63
|
+
}
|
|
64
|
+
for (const group of p.groups) {
|
|
65
|
+
text('h2',group.title,root); if (group.trigger) text('p',group.trigger,root);
|
|
66
|
+
for (const email of group.emails) {
|
|
67
|
+
const section = document.createElement('section'); root.append(section);
|
|
68
|
+
text('h3',email.label + ': ' + email.subject,section);
|
|
69
|
+
text('p','From: ' + email.from + '\nPreheader: ' + (email.preheader || '(none)'),section);
|
|
70
|
+
if (email.delay) text('p',email.delay,section);
|
|
71
|
+
const devices = document.createElement('div'); devices.className = 'devices'; section.append(devices);
|
|
72
|
+
for (const width of [600,375]) {
|
|
73
|
+
const device = document.createElement('div'); devices.append(device); text('p',width + 'px',device);
|
|
74
|
+
const frame = document.createElement('iframe'); frame.setAttribute('sandbox',''); frame.setAttribute('referrerpolicy','no-referrer');
|
|
75
|
+
frame.title = email.label + ' ' + width + 'px'; frame.width = String(width); frame.srcdoc = safeEmail(email.html || '<pre>' + email.text.replace(/&/g,'&').replace(/</g,'<') + '</pre>'); device.append(frame);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
text('p','Template content shown; recipient merge fields resolve when sending. Expires: ' + data.expires_at,root);
|
|
80
|
+
text('p','Payload SHA-256: ' + data.payload_hash,root);
|
|
81
|
+
buttons.forEach(b => b.disabled = !ready || !data.app_session || started);
|
|
82
|
+
}
|
|
83
|
+
async function loadApproval(uri) {
|
|
84
|
+
const result = await request('resources/read',{uri});
|
|
85
|
+
const resource = new DOMParser().parseFromString(result.contents[0].text,'text/html');
|
|
86
|
+
const loaded = JSON.parse(resource.getElementById('approval-data').textContent);
|
|
87
|
+
// Host-only resource metadata, omitted from model-readable resource text.
|
|
88
|
+
loaded.app_session = result.contents[0]._meta?.[data.sessionMetaKey];
|
|
89
|
+
data = loaded; render(); status.textContent = data.app_session ? 'Review the email, then choose Approve or Reject.' : 'Use the dashboard approval link.';
|
|
90
|
+
}
|
|
91
|
+
window.addEventListener('message', async event => {
|
|
92
|
+
// Email frames are untrusted: even synthetic JSON-RPC from a child is ignored.
|
|
93
|
+
if (event.source !== parent || !event.data || event.data.jsonrpc !== '2.0') return;
|
|
94
|
+
const message = event.data, entry = pending.get(message.id);
|
|
95
|
+
if (entry) {
|
|
96
|
+
clearTimeout(entry.timeout); pending.delete(message.id);
|
|
97
|
+
if (message.error) entry.reject(new Error('Host refused the request.')); else entry.resolve(message.result);
|
|
98
|
+
} else if (message.method === 'ui/notifications/tool-result' && !data.preview) {
|
|
99
|
+
const uri = message.params?._meta?.ui?.resourceUri;
|
|
100
|
+
if (typeof uri !== 'string' || !/^ui:\/\/sendinel\/approval\/[a-zA-Z0-9-]+$/.test(uri)) return;
|
|
101
|
+
try {
|
|
102
|
+
await loadApproval(uri);
|
|
103
|
+
} catch (error) { status.textContent = error.message; }
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
buttons.forEach(button => button.addEventListener('click', async event => {
|
|
107
|
+
if (!event.isTrusted || !ready || !data.app_session || started) return;
|
|
108
|
+
started = true; buttons.forEach(b => b.disabled = true); status.textContent = 'Recording decision…';
|
|
109
|
+
try {
|
|
110
|
+
const result = await request('tools/call',{name:data.tool,arguments:{approval_id:data.approval_id,decision:button.id === 'approve' ? 'approve':'reject',app_session:data.app_session}});
|
|
111
|
+
data.app_session = undefined;
|
|
112
|
+
status.textContent = result.isError ? 'Decision refused. Open the dashboard approval link.' : 'Decision recorded. The next action call with approval_id can apply an approved payload.';
|
|
113
|
+
} catch (error) { data.app_session = undefined; status.textContent = error.message; }
|
|
114
|
+
}));
|
|
115
|
+
render();
|
|
116
|
+
request('ui/initialize',{appInfo:{name:'Sendinel approval',version:'1.0.0'},appCapabilities:{},protocolVersion:data.protocolVersion})
|
|
117
|
+
.then(async result => {
|
|
118
|
+
if (result.protocolVersion !== data.protocolVersion) throw new Error('Unsupported Apps protocol; open the dashboard approval link.');
|
|
119
|
+
ready = true; notify('ui/notifications/initialized',{}); render();
|
|
120
|
+
if (data.approval_id && !data.app_session) await loadApproval('ui://sendinel/approval/' + encodeURIComponent(data.approval_id));
|
|
121
|
+
status.textContent = data.preview ? (data.app_session ? 'Review the email, then choose Approve or Reject.' : 'Use the dashboard approval link.') : 'Waiting for approval preview…';
|
|
122
|
+
}).catch(error => { status.textContent = error.message; });
|
|
123
|
+
})();
|
|
124
|
+
</script></body></html>`;
|
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');
|
|
@@ -47,6 +47,7 @@ export interface ApprovalResult {
|
|
|
47
47
|
/** One-tap approval page; works on a phone and needs no project switching (#3661). */
|
|
48
48
|
approve_url: string;
|
|
49
49
|
preview_text: string;
|
|
50
|
+
decision_status: 'decision_pending' | 'approved' | 'rejected';
|
|
50
51
|
message: string;
|
|
51
52
|
}
|
|
52
53
|
export declare function approvalPageUrl(approvalId: string): string;
|
|
@@ -4,6 +4,8 @@ import { db, requestContext } from '../db.js';
|
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
|
|
6
6
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
7
|
+
import { offerApprovalApp } from './approval-app.js';
|
|
8
|
+
import { elicitApproval } from './approval-elicitation.js';
|
|
7
9
|
/**
|
|
8
10
|
* Action approval tiers:
|
|
9
11
|
*
|
|
@@ -362,8 +364,10 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
362
364
|
resource_id: params.resource_id ?? params.campaign_id ?? params.contact_id ?? null,
|
|
363
365
|
requested_by: apiKeyId ?? null,
|
|
364
366
|
}, getProjectId()).catch(() => { });
|
|
365
|
-
return {
|
|
367
|
+
return elicitApproval({
|
|
366
368
|
requires_approval: true,
|
|
369
|
+
...offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
370
|
+
payload_hash: approvalPayloadHash(action, frozenParams) }),
|
|
367
371
|
approval_id: data.id,
|
|
368
372
|
tier,
|
|
369
373
|
action,
|
|
@@ -373,7 +377,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
373
377
|
approve_url: approvalPageUrl(data.id),
|
|
374
378
|
preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
|
|
375
379
|
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.`,
|
|
376
|
-
};
|
|
380
|
+
}, { action, params: frozenParams, summary }, getProjectId());
|
|
377
381
|
}
|
|
378
382
|
/**
|
|
379
383
|
* Check if an approval token has been approved.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare const APPROVAL_APP: {
|
|
2
|
+
readonly mimeType: "text/html;profile=mcp-app";
|
|
3
|
+
readonly uri: 'ui://sendinel/approval';
|
|
4
|
+
readonly template: 'ui://sendinel/approval/{approval_id}';
|
|
5
|
+
readonly protocolVersion: '2026-01-26';
|
|
6
|
+
readonly tool: 'approval_app_decide';
|
|
7
|
+
readonly resultUriKey: 'resourceUri';
|
|
8
|
+
readonly csp: {
|
|
9
|
+
readonly connectDomains: readonly [];
|
|
10
|
+
readonly resourceDomains: readonly ['https:'];
|
|
11
|
+
readonly frameDomains: readonly ["'self'"];
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export declare const approvalAppResourceMeta: {
|
|
15
|
+
ui: {
|
|
16
|
+
csp: {
|
|
17
|
+
readonly connectDomains: readonly [];
|
|
18
|
+
readonly resourceDomains: readonly ['https:'];
|
|
19
|
+
readonly frameDomains: readonly ["'self'"];
|
|
20
|
+
};
|
|
21
|
+
permissions: {};
|
|
22
|
+
prefersBorder: boolean;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export declare const APPROVAL_APP_SESSION_META = "sendinel/app_session";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MCP_APP_MIME_TYPE } from './approval-capabilities.js';
|
|
2
|
+
// Confirmed against ext-apps specification/2026-01-26/apps.mdx and src/spec.types.ts.
|
|
3
|
+
// SDK 1.32 carries _meta through, but visibility filtering is the Apps HOST's duty.
|
|
4
|
+
export const APPROVAL_APP = {
|
|
5
|
+
mimeType: MCP_APP_MIME_TYPE,
|
|
6
|
+
uri: 'ui://sendinel/approval',
|
|
7
|
+
template: 'ui://sendinel/approval/{approval_id}',
|
|
8
|
+
protocolVersion: '2026-01-26',
|
|
9
|
+
tool: 'approval_app_decide',
|
|
10
|
+
// UNCONFIRMED: dynamic resourceUri in a TOOL RESULT is requested by #4280;
|
|
11
|
+
// the stable spec defines it on tool definitions. The static definition URI
|
|
12
|
+
// also works: its app reads the dynamic resource using the tool-result metadata.
|
|
13
|
+
resultUriKey: 'resourceUri',
|
|
14
|
+
// UNCONFIRMED: host acceptance of scheme sources / srcdoc in these domain lists.
|
|
15
|
+
// Needed for real-email HTTPS images in nested srcdoc, without external scripts,
|
|
16
|
+
// CSS or outer-app fetch. Hosts may further restrict; live runbook must check.
|
|
17
|
+
csp: { connectDomains: [], resourceDomains: ['https:'], frameDomains: ["'self'"] },
|
|
18
|
+
};
|
|
19
|
+
export const approvalAppResourceMeta = { ui: { csp: APPROVAL_APP.csp, permissions: {}, prefersBorder: true } };
|
|
20
|
+
// Custom, host/app-only metadata. Hosts must keep this OUT of model context,
|
|
21
|
+
// including when the model reads a resource. UNCONFIRMED per client until live
|
|
22
|
+
// runbook verifies resource metadata isolation (tool metadata isolation alone
|
|
23
|
+
// is insufficient). Never embed this value in resource text.
|
|
24
|
+
export const APPROVAL_APP_SESSION_META = 'sendinel/app_session';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export type AppApproval = {
|
|
3
|
+
id: string;
|
|
4
|
+
action: string;
|
|
5
|
+
params: Record<string, any>;
|
|
6
|
+
summary?: string;
|
|
7
|
+
tier: string;
|
|
8
|
+
expires_at: string;
|
|
9
|
+
payload_hash: string;
|
|
10
|
+
};
|
|
11
|
+
/** Only frozen facts enter this view. No live reads or model-supplied display overrides. */
|
|
12
|
+
export declare function approvalAppView(approval: AppApproval): {
|
|
13
|
+
approval_id: string;
|
|
14
|
+
preview: import("@sendinel/domain").ApprovalPreviewModel;
|
|
15
|
+
diff: {
|
|
16
|
+
field: string;
|
|
17
|
+
before: string;
|
|
18
|
+
after: string;
|
|
19
|
+
}[];
|
|
20
|
+
payload_hash: string;
|
|
21
|
+
expires_at: string;
|
|
22
|
+
};
|
|
23
|
+
/** A TypeScript string bundles the single HTML file into build/ without a copy step. */
|
|
24
|
+
export declare function renderApprovalApp(approval?: AppApproval): string;
|
|
25
|
+
export declare function registerApprovalAppResources(server: McpServer, read: (id: string) => Promise<{
|
|
26
|
+
html: string;
|
|
27
|
+
appSession: string;
|
|
28
|
+
}>): void;
|
|
29
|
+
/** Called only after the core has persisted its frozen payload. Tokens never enter tool results. */
|
|
30
|
+
export declare function offerApprovalApp(approval: AppApproval): {
|
|
31
|
+
_meta?: {
|
|
32
|
+
ui: {
|
|
33
|
+
resourceUri: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
/** Transport boundary, outside the model catalog and gateway catalog. Apps hosts MUST
|
|
38
|
+
* honor visibility. Even a raw model tools/call cannot decide without the resource-only token.
|
|
39
|
+
* The SDK Server instance is the connection boundary; no cross-request/global token cache.
|
|
40
|
+
*/
|
|
41
|
+
export declare function installApprovalApp(server: McpServer, options?: {
|
|
42
|
+
scopes?: string[];
|
|
43
|
+
}, approvalTool?: (name: string) => boolean): McpServer;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { buildApprovalPreview } from '@sendinel/domain';
|
|
3
|
+
import { APPROVAL_APP, APPROVAL_APP_SESSION_META } from './approval-app-conventions.js';
|
|
4
|
+
import { APPROVAL_APP_HTML } from '../approval-app-html.js';
|
|
5
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { approvalAppResourceMeta } from './approval-app-conventions.js';
|
|
7
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { requestContext } from '../db.js';
|
|
10
|
+
import { getProjectId } from '../project.js';
|
|
11
|
+
import { approval_channel_available } from './approval-capabilities.js';
|
|
12
|
+
import { createHumanApprovalCallback } from './approval-human-callback.js';
|
|
13
|
+
/** Only frozen facts enter this view. No live reads or model-supplied display overrides. */
|
|
14
|
+
export function approvalAppView(approval) {
|
|
15
|
+
const preview = buildApprovalPreview(approval);
|
|
16
|
+
const p = approval.params;
|
|
17
|
+
const snapshot = p.__approval_snapshot ?? {};
|
|
18
|
+
const before = p.previous_campaign ?? p.previous_site ?? snapshot.campaigns?.[0] ?? snapshot.sites?.[0] ?? {};
|
|
19
|
+
const after = p.updates ?? p.payload ?? p;
|
|
20
|
+
const fields = ['trigger_event_name', 'trigger_tag', 'trigger_type', 'trigger_config', 'send_class',
|
|
21
|
+
'physical_address', 'unsub_link_enabled', 'unsub_footer_template'];
|
|
22
|
+
const diff = fields.filter(key => Object.hasOwn(after, key) && JSON.stringify(before[key]) !== JSON.stringify(after[key]))
|
|
23
|
+
.map(key => ({ field: key, before: before[key] === undefined ? 'unknown' : JSON.stringify(before[key]),
|
|
24
|
+
after: JSON.stringify(after[key]) }));
|
|
25
|
+
return { approval_id: approval.id, preview, diff, payload_hash: approval.payload_hash, expires_at: approval.expires_at };
|
|
26
|
+
}
|
|
27
|
+
/** A TypeScript string bundles the single HTML file into build/ without a copy step. */
|
|
28
|
+
export function renderApprovalApp(approval) {
|
|
29
|
+
const nonce = randomBytes(24).toString('base64');
|
|
30
|
+
const data = { ...(approval ? approvalAppView(approval) : {}), sessionMetaKey: APPROVAL_APP_SESSION_META,
|
|
31
|
+
tool: APPROVAL_APP.tool, protocolVersion: APPROVAL_APP.protocolVersion };
|
|
32
|
+
// Never interpolate untrusted text as markup, even into an application/json script.
|
|
33
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
34
|
+
return APPROVAL_APP_HTML.replaceAll('__NONCE__', nonce).replace('__APP_DATA__', () => json);
|
|
35
|
+
}
|
|
36
|
+
export function registerApprovalAppResources(server, read) {
|
|
37
|
+
const config = { mimeType: APPROVAL_APP.mimeType, _meta: approvalAppResourceMeta };
|
|
38
|
+
const result = (uri, text, appSession) => ({ contents: [{ uri: uri.href,
|
|
39
|
+
mimeType: APPROVAL_APP.mimeType, text, _meta: { ...approvalAppResourceMeta,
|
|
40
|
+
...(appSession ? { [APPROVAL_APP_SESSION_META]: appSession } : {}) } }] });
|
|
41
|
+
server.registerResource('approval-app', APPROVAL_APP.uri, config, async (uri) => result(uri, renderApprovalApp()));
|
|
42
|
+
server.registerResource('approval-preview', new ResourceTemplate(APPROVAL_APP.template, { list: undefined }), config, async (uri, variables) => { const view = await read(String(variables.approval_id)); return result(uri, view.html, view.appSession); });
|
|
43
|
+
}
|
|
44
|
+
const runtimes = new WeakMap();
|
|
45
|
+
const calls = new AsyncLocalStorage();
|
|
46
|
+
const resourceUri = (id) => `${APPROVAL_APP.uri}/${encodeURIComponent(id)}`;
|
|
47
|
+
function identity() {
|
|
48
|
+
const context = requestContext();
|
|
49
|
+
// Do not attribute an API key owner, agent or tester's click to a human.
|
|
50
|
+
return context?.channelVerified === true && context.actorId && context.authKind !== 'scoped_jwt'
|
|
51
|
+
? { userId: context.actorId, projectId: getProjectId() } : null;
|
|
52
|
+
}
|
|
53
|
+
function matches(offer) {
|
|
54
|
+
const actor = identity();
|
|
55
|
+
return !!actor && actor.projectId === offer.projectId && actor.userId === offer.userId;
|
|
56
|
+
}
|
|
57
|
+
function live(approval) {
|
|
58
|
+
return Number.isFinite(Date.parse(approval.expires_at)) && Date.parse(approval.expires_at) > Date.now();
|
|
59
|
+
}
|
|
60
|
+
function prune(runtime) {
|
|
61
|
+
for (const [id, offer] of runtime.offers)
|
|
62
|
+
if (!live(offer.approval))
|
|
63
|
+
runtime.offers.delete(id);
|
|
64
|
+
for (const [token, session] of runtime.sessions)
|
|
65
|
+
if (!live(session.offer.approval))
|
|
66
|
+
runtime.sessions.delete(token);
|
|
67
|
+
}
|
|
68
|
+
/** Called only after the core has persisted its frozen payload. Tokens never enter tool results. */
|
|
69
|
+
export function offerApprovalApp(approval) {
|
|
70
|
+
const runtime = calls.getStore(), actor = identity();
|
|
71
|
+
if (!runtime || !runtime.canDecide || !actor || approval.tier === 'protected' ||
|
|
72
|
+
approval_channel_available(runtime.server.server) !== 'mcp_app')
|
|
73
|
+
return {};
|
|
74
|
+
prune(runtime);
|
|
75
|
+
if (runtime.offers.size >= 128 || !live(approval))
|
|
76
|
+
return {};
|
|
77
|
+
runtime.offers.set(approval.id, { approval: structuredClone(approval), ...actor });
|
|
78
|
+
return { _meta: { ui: { [APPROVAL_APP.resultUriKey]: resourceUri(approval.id) } } };
|
|
79
|
+
}
|
|
80
|
+
/** Transport boundary, outside the model catalog and gateway catalog. Apps hosts MUST
|
|
81
|
+
* honor visibility. Even a raw model tools/call cannot decide without the resource-only token.
|
|
82
|
+
* The SDK Server instance is the connection boundary; no cross-request/global token cache.
|
|
83
|
+
*/
|
|
84
|
+
export function installApprovalApp(server, options = {}, approvalTool = () => false) {
|
|
85
|
+
// Catalog collectors have no transport/resource API; never expose this tool through them.
|
|
86
|
+
if (!server.server || typeof server.registerResource !== 'function')
|
|
87
|
+
return server;
|
|
88
|
+
let runtime = runtimes.get(server.server);
|
|
89
|
+
if (!runtime) {
|
|
90
|
+
runtime = { server, connectionId: randomBytes(32).toString('hex'), offers: new Map(), sessions: new Map(),
|
|
91
|
+
canDecide: !options.scopes?.some(scope => scope === 'tester' || scope === 'agent') };
|
|
92
|
+
runtimes.set(server.server, runtime);
|
|
93
|
+
const bound = runtime;
|
|
94
|
+
registerApprovalAppResources(server, async (id) => {
|
|
95
|
+
prune(bound);
|
|
96
|
+
const offer = bound.offers.get(id);
|
|
97
|
+
if (!offer || !matches(offer) || approval_channel_available(server.server) !== 'mcp_app')
|
|
98
|
+
throw new Error('Approval app unavailable on this connection; use approve_url.');
|
|
99
|
+
// One outstanding render session per approval. Re-render invalidates the old view.
|
|
100
|
+
for (const [token, session] of bound.sessions)
|
|
101
|
+
if (session.offer === offer)
|
|
102
|
+
bound.sessions.delete(token);
|
|
103
|
+
const callback = createHumanApprovalCallback({ approvalId: id, projectId: offer.projectId,
|
|
104
|
+
userId: offer.userId, connectionId: bound.connectionId, channel: 'mcp_app' });
|
|
105
|
+
const token = randomBytes(32).toString('base64url');
|
|
106
|
+
bound.sessions.set(token, { offer, callback });
|
|
107
|
+
// Resource text can be shown to the model; the bearer belongs exclusively
|
|
108
|
+
// in host/app metadata, never HTML, a tool result, a URL or an audit entry.
|
|
109
|
+
return { html: renderApprovalApp(offer.approval), appSession: token };
|
|
110
|
+
});
|
|
111
|
+
const registerDecide = () => server.registerTool(APPROVAL_APP.tool, {
|
|
112
|
+
description: 'Record the human decision from this connection’s rendered approval app.',
|
|
113
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
114
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
115
|
+
inputSchema: z.object({ approval_id: z.string(), decision: z.enum(['approve', 'reject']), app_session: z.string().min(1) }).strict(),
|
|
116
|
+
}, async ({ approval_id, decision, app_session }) => {
|
|
117
|
+
prune(bound);
|
|
118
|
+
const session = bound.sessions.get(app_session);
|
|
119
|
+
const refused = () => ({ isError: true, content: [{ type: 'text', text: 'Decision refused; use the dashboard approval link.' }] });
|
|
120
|
+
if (!bound.canDecide || !session || session.offer.approval.id !== approval_id || !matches(session.offer) ||
|
|
121
|
+
!live(session.offer.approval) || approval_channel_available(server.server) !== 'mcp_app')
|
|
122
|
+
return refused();
|
|
123
|
+
// Claim before awaiting the callback, so concurrent clicks cannot reuse a session.
|
|
124
|
+
bound.sessions.delete(app_session);
|
|
125
|
+
bound.offers.delete(approval_id);
|
|
126
|
+
const accepted = await session.callback.complete({ interactionId: session.callback.interactionId,
|
|
127
|
+
connectionId: bound.connectionId, decision });
|
|
128
|
+
return accepted ? { content: [{ type: 'text', text: 'Decision recorded.' }] } : refused();
|
|
129
|
+
});
|
|
130
|
+
const previousInitialized = server.server.oninitialized;
|
|
131
|
+
server.server.oninitialized = () => {
|
|
132
|
+
previousInitialized?.();
|
|
133
|
+
if (bound.canDecide && approval_channel_available(server.server) === 'mcp_app')
|
|
134
|
+
registerDecide();
|
|
135
|
+
};
|
|
136
|
+
const previousClose = server.server.onclose;
|
|
137
|
+
server.server.onclose = () => { bound.offers.clear(); bound.sessions.clear(); previousClose?.(); };
|
|
138
|
+
}
|
|
139
|
+
const bound = runtime;
|
|
140
|
+
return new Proxy(server, { get(target, prop, receiver) {
|
|
141
|
+
if (prop !== 'registerTool')
|
|
142
|
+
return Reflect.get(target, prop, receiver);
|
|
143
|
+
return (name, config, handler) => target.registerTool(name, approvalTool(name) || name === 'invoke_tool' ? { ...config, _meta: { ...config._meta, ui: { resourceUri: APPROVAL_APP.uri } } } : config, async (...args) => calls.run(calls.getStore() ?? bound, async () => {
|
|
144
|
+
const result = await handler(...args);
|
|
145
|
+
// Existing tools serialize ApprovalResult into content; promote ONLY its UI
|
|
146
|
+
// reference to MCP result metadata. The resource token is never in that object.
|
|
147
|
+
for (const content of result?.content ?? []) {
|
|
148
|
+
if (content.type !== 'text')
|
|
149
|
+
continue;
|
|
150
|
+
try {
|
|
151
|
+
const value = JSON.parse(content.text);
|
|
152
|
+
if (value.requires_approval === true && value._meta?.ui?.resourceUri) {
|
|
153
|
+
return { ...result, _meta: { ...result._meta, ui: value._meta.ui } };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch { /* Non-JSON text fallback. */ }
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}));
|
|
160
|
+
} });
|
|
161
|
+
}
|
|
@@ -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,116 @@
|
|
|
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
|
+
// Keyed by the SDK Server, not the McpServer wrapper: other installers (the Apps tier) wrap
|
|
7
|
+
// McpServer in a Proxy, which changes the wrapper's identity but never the connection's Server.
|
|
8
|
+
const sessions = new WeakMap();
|
|
9
|
+
const requests = new AsyncLocalStorage();
|
|
10
|
+
const TIMEOUT_MS = 60_000;
|
|
11
|
+
export function installApprovalElicitation(server, binding) {
|
|
12
|
+
sessions.set(server.server, { ...binding, connectionId: randomUUID() });
|
|
13
|
+
}
|
|
14
|
+
/** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
|
|
15
|
+
export function runWithApprovalElicitation(server, extra, run) {
|
|
16
|
+
const binding = sessions.get(server.server);
|
|
17
|
+
// Nested gateway dispatch retains the originating connection/request. The hosted bridge
|
|
18
|
+
// has no binding and cannot impersonate its outer HTTP client's capabilities.
|
|
19
|
+
if (!binding || requests.getStore())
|
|
20
|
+
return run();
|
|
21
|
+
return requests.run({ ...binding, ...extra, server }, run);
|
|
22
|
+
}
|
|
23
|
+
/** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
|
|
24
|
+
export async function elicitApproval(result, frozen, projectId) {
|
|
25
|
+
const pending = { ...result, decision_status: 'decision_pending' };
|
|
26
|
+
const context = requests.getStore();
|
|
27
|
+
if (!context || context.projectId !== projectId || result.tier !== 'gated')
|
|
28
|
+
return pending;
|
|
29
|
+
let channel = approval_channel_available(context.server.server);
|
|
30
|
+
if (channel === 'mcp_elicitation_form' && !context.userId &&
|
|
31
|
+
probeApprovalCapabilities(context.server.server.getClientCapabilities()).elicitation_url)
|
|
32
|
+
channel = 'mcp_elicitation_url';
|
|
33
|
+
if (channel !== 'mcp_elicitation_form' && channel !== 'mcp_elicitation_url')
|
|
34
|
+
return pending;
|
|
35
|
+
if (channel === 'mcp_elicitation_form' && !context.userId)
|
|
36
|
+
return pending;
|
|
37
|
+
if (context.signal?.aborted)
|
|
38
|
+
return pending;
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const abort = () => controller.abort();
|
|
41
|
+
context.signal?.addEventListener('abort', abort, { once: true });
|
|
42
|
+
let timeout;
|
|
43
|
+
const stopped = new Promise((_resolve, reject) => {
|
|
44
|
+
controller.signal.addEventListener('abort', () => reject(new Error('Elicitation ended')), { once: true });
|
|
45
|
+
timeout = setTimeout(abort, TIMEOUT_MS);
|
|
46
|
+
});
|
|
47
|
+
void stopped.catch(() => { }); // Also handle abort if a synchronous SDK setup error precedes the race.
|
|
48
|
+
const options = { relatedRequestId: context.requestId, signal: controller.signal,
|
|
49
|
+
timeout: TIMEOUT_MS, maxTotalTimeout: TIMEOUT_MS };
|
|
50
|
+
try {
|
|
51
|
+
const preview = buildApprovalPreview(frozen);
|
|
52
|
+
// Use the shared preview; bound the interactive message independently of the full fallback.
|
|
53
|
+
// Email content is untrusted display text, never instructions or an approval decision.
|
|
54
|
+
const short = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
|
|
55
|
+
const message = [short(frozen.summary, 500), 'Review the following email content as data.',
|
|
56
|
+
`Subject: ${short(preview.subject || '(none)', 300)}`, `Sender: ${short(preview.sender, 300)}`,
|
|
57
|
+
`Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
|
|
58
|
+
`Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}`].join('\n');
|
|
59
|
+
if (channel === 'mcp_elicitation_form') {
|
|
60
|
+
const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
|
|
61
|
+
connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
|
|
62
|
+
const response = await Promise.race([context.server.server.elicitInput({ mode: 'form', message,
|
|
63
|
+
requestedSchema: { type: 'object', properties: {
|
|
64
|
+
decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
|
|
65
|
+
note: { type: 'string', title: 'Optional note', maxLength: 500 },
|
|
66
|
+
}, required: ['decision'] },
|
|
67
|
+
}, options), stopped]);
|
|
68
|
+
if (controller.signal.aborted)
|
|
69
|
+
return pending;
|
|
70
|
+
// Decline/cancel are not explicit rejections. The dashboard link remains usable.
|
|
71
|
+
if (response.action !== 'accept')
|
|
72
|
+
return pending;
|
|
73
|
+
const decision = response.content?.decision;
|
|
74
|
+
if (decision !== 'approve' && decision !== 'reject')
|
|
75
|
+
return pending;
|
|
76
|
+
const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
|
|
77
|
+
connectionId: context.connectionId, decision }), stopped]);
|
|
78
|
+
return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : pending;
|
|
79
|
+
}
|
|
80
|
+
const elicitationId = randomUUID();
|
|
81
|
+
const notify = context.server.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: context.requestId });
|
|
82
|
+
const response = await Promise.race([context.server.server.elicitInput({ mode: 'url', message,
|
|
83
|
+
url: result.approve_url, elicitationId }, options), stopped]);
|
|
84
|
+
if (response.action !== 'accept' || controller.signal.aborted)
|
|
85
|
+
return pending;
|
|
86
|
+
// URL accept only means the page may open. Only its authenticated decision counts.
|
|
87
|
+
// Observe while this request is alive; do not retain a background poller after timeout.
|
|
88
|
+
while (!controller.signal.aborted) {
|
|
89
|
+
const source = db();
|
|
90
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
91
|
+
const { data, error } = await Promise.race([client.from('action_approvals')
|
|
92
|
+
.select('status, decided_via').eq('id', result.approval_id).eq('project_id', projectId).maybeSingle(), stopped]);
|
|
93
|
+
if (error || !data)
|
|
94
|
+
return pending;
|
|
95
|
+
if ((data.status === 'approved' || data.status === 'rejected') && isHumanApprovalChannel(data.decided_via)) {
|
|
96
|
+
await Promise.race([notify(), stopped]);
|
|
97
|
+
return { ...result, decision_status: data.status };
|
|
98
|
+
}
|
|
99
|
+
await Promise.race([new Promise(resolve => {
|
|
100
|
+
const timer = setTimeout(done, 1000);
|
|
101
|
+
function done() { clearTimeout(timer); controller.signal.removeEventListener('abort', done); resolve(); }
|
|
102
|
+
controller.signal.addEventListener('abort', done, { once: true });
|
|
103
|
+
}), stopped]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Unsupported SDK modes, disconnections, timeouts and refused DB writes fail to the link.
|
|
108
|
+
// No continuation consumes a late elicitation response or grants model decision authority.
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
context.signal?.removeEventListener('abort', abort);
|
|
113
|
+
controller.abort();
|
|
114
|
+
}
|
|
115
|
+
return pending;
|
|
116
|
+
}
|
package/build/server.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
|
|
2
|
+
import { installApprovalApp } from './lib/approval-app.js';
|
|
3
|
+
import { runWithApprovalElicitation } from './lib/approval-elicitation.js';
|
|
2
4
|
import { runWithToolContext } from './db.js';
|
|
3
5
|
import { isApprovalGatedAction } from './lib/action-approvals.js';
|
|
4
6
|
import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
|
|
@@ -354,11 +356,11 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
|
354
356
|
const text = agent ? agentConnectionRefusal(name) : tester ? testerConnectionRefusal(name) : `Forbidden: this action requires "${required}" scope.`;
|
|
355
357
|
return { content: [{ type: 'text', text }] };
|
|
356
358
|
}
|
|
357
|
-
return runWithToolContext(toolGroup, name, () => handler(...args));
|
|
359
|
+
return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)));
|
|
358
360
|
};
|
|
359
361
|
return target.registerTool(name, config, guardedHandler);
|
|
360
362
|
}
|
|
361
|
-
return target.registerTool(name, config, (...args) => runWithToolContext(toolGroup, name, () => handler(...args)));
|
|
363
|
+
return target.registerTool(name, config, (...args) => runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args))));
|
|
362
364
|
};
|
|
363
365
|
}
|
|
364
366
|
return Reflect.get(target, prop, receiver);
|
|
@@ -380,6 +382,7 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
|
|
|
380
382
|
export function registerAllTools(server, options) {
|
|
381
383
|
if (server.server)
|
|
382
384
|
installApprovalCapabilityProbe(server.server);
|
|
385
|
+
server = installApprovalApp(server, options, isApprovalGatedAction);
|
|
383
386
|
const rawScopes = options?.scopes ?? [];
|
|
384
387
|
if (rawScopes.includes('gateway')) {
|
|
385
388
|
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.107",
|
|
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",
|