@sendinel/mcp-server 1.0.106 → 1.0.108
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/lib/action-approvals.js +3 -0
- 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.js +4 -2
- package/build/server.js +2 -0
- package/build/tools/campaigns.js +27 -6
- 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>`;
|
|
@@ -4,6 +4,7 @@ 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';
|
|
7
8
|
import { elicitApproval } from './approval-elicitation.js';
|
|
8
9
|
/**
|
|
9
10
|
* Action approval tiers:
|
|
@@ -365,6 +366,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
365
366
|
}, getProjectId()).catch(() => { });
|
|
366
367
|
return elicitApproval({
|
|
367
368
|
requires_approval: true,
|
|
369
|
+
...offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
370
|
+
payload_hash: approvalPayloadHash(action, frozenParams) }),
|
|
368
371
|
approval_id: data.id,
|
|
369
372
|
tier,
|
|
370
373
|
action,
|
|
@@ -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
|
+
}
|
|
@@ -3,15 +3,17 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
|
|
4
4
|
import { db } from '../db.js';
|
|
5
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.
|
|
6
8
|
const sessions = new WeakMap();
|
|
7
9
|
const requests = new AsyncLocalStorage();
|
|
8
10
|
const TIMEOUT_MS = 60_000;
|
|
9
11
|
export function installApprovalElicitation(server, binding) {
|
|
10
|
-
sessions.set(server, { ...binding, connectionId: randomUUID() });
|
|
12
|
+
sessions.set(server.server, { ...binding, connectionId: randomUUID() });
|
|
11
13
|
}
|
|
12
14
|
/** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
|
|
13
15
|
export function runWithApprovalElicitation(server, extra, run) {
|
|
14
|
-
const binding = sessions.get(server);
|
|
16
|
+
const binding = sessions.get(server.server);
|
|
15
17
|
// Nested gateway dispatch retains the originating connection/request. The hosted bridge
|
|
16
18
|
// has no binding and cannot impersonate its outer HTTP client's capabilities.
|
|
17
19
|
if (!binding || requests.getStore())
|
package/build/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
|
|
2
|
+
import { installApprovalApp } from './lib/approval-app.js';
|
|
2
3
|
import { runWithApprovalElicitation } from './lib/approval-elicitation.js';
|
|
3
4
|
import { runWithToolContext } from './db.js';
|
|
4
5
|
import { isApprovalGatedAction } from './lib/action-approvals.js';
|
|
@@ -381,6 +382,7 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
|
|
|
381
382
|
export function registerAllTools(server, options) {
|
|
382
383
|
if (server.server)
|
|
383
384
|
installApprovalCapabilityProbe(server.server);
|
|
385
|
+
server = installApprovalApp(server, options, isApprovalGatedAction);
|
|
384
386
|
const rawScopes = options?.scopes ?? [];
|
|
385
387
|
if (rawScopes.includes('gateway')) {
|
|
386
388
|
const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
|
package/build/tools/campaigns.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
|
|
|
2
2
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
-
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, } from '@sendinel/domain';
|
|
5
|
+
import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
|
|
6
6
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
7
7
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
8
8
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -652,7 +652,7 @@ export function registerCampaignTools(server) {
|
|
|
652
652
|
}, async ({ site_id, status, limit, period }) => {
|
|
653
653
|
let q = db()
|
|
654
654
|
.from('campaigns')
|
|
655
|
-
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config')
|
|
655
|
+
.select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, unsub_footer_template')
|
|
656
656
|
.eq('project_id', getProjectId())
|
|
657
657
|
.order('created_at', { ascending: false })
|
|
658
658
|
.limit(limit ?? 25);
|
|
@@ -2966,6 +2966,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2966
2966
|
site_id: z.string().uuid().describe('Site to send from'),
|
|
2967
2967
|
name: z.string().min(1).max(200).describe('Campaign name'),
|
|
2968
2968
|
type: z.enum(['sequence', 'broadcast', 'triggered']).describe('Campaign type'),
|
|
2969
|
+
send_class: z.enum(['broadcast', 'product']).optional().describe("Consent class: broadcast (marketing, default) or product (account, onboarding, trial and billing mail)."),
|
|
2970
|
+
unsub_footer_template: z.string().nullable().optional().describe('Campaign unsubscribe footer override. Must contain <unsubscribe>link text</unsubscribe>. Null uses the site footer.'),
|
|
2969
2971
|
trigger_event_name: z.string().nullable().optional(),
|
|
2970
2972
|
trigger_tag: z.string().nullable().optional(),
|
|
2971
2973
|
trigger_type: z.enum(['manual', 'date_field', 'contact_event']).nullable().optional(),
|
|
@@ -2984,7 +2986,14 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2984
2986
|
confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
|
|
2985
2987
|
approval_id: z.string().uuid().optional().describe('Approval token from a previous call. Required when activate=true — get this by calling once without it, then have a human approve via the dashboard.'),
|
|
2986
2988
|
},
|
|
2987
|
-
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config }) => {
|
|
2989
|
+
}, async ({ site_id, name, type, steps, segment_id, sends_per_hour, activate, confirmed, approval_id, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, unsub_footer_template }) => {
|
|
2990
|
+
const footerTemplate = unsub_footer_template?.trim() ?? null;
|
|
2991
|
+
if (footerTemplate !== null) {
|
|
2992
|
+
const footerError = validateUnsubFooterTemplate(footerTemplate);
|
|
2993
|
+
if (footerError)
|
|
2994
|
+
return j({ error: footerError, code: 'INVALID_FIELD' });
|
|
2995
|
+
}
|
|
2996
|
+
const sendClass = send_class ?? 'broadcast';
|
|
2988
2997
|
const targeting = {};
|
|
2989
2998
|
const triggerInput = { trigger_event_name, trigger_tag, trigger_type, trigger_config };
|
|
2990
2999
|
const targetingError = await applyCampaignTargetingFields(targeting, triggerInput, getProjectId());
|
|
@@ -2996,7 +3005,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2996
3005
|
const canActivateOnTrigger = campaignType === 'triggered' && (hasEventOrTag || targeting.trigger_type === 'date_field' || targeting.trigger_type === 'contact_event');
|
|
2997
3006
|
// Human-in-the-loop gate for activation
|
|
2998
3007
|
if (activate && confirmed) {
|
|
2999
|
-
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, ...triggerInput };
|
|
3008
|
+
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, ...triggerInput };
|
|
3000
3009
|
const channelContext = currentChannelContext();
|
|
3001
3010
|
const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
|
|
3002
3011
|
if (!decision.allowed)
|
|
@@ -3042,7 +3051,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3042
3051
|
if (!confirmed) {
|
|
3043
3052
|
return j({
|
|
3044
3053
|
confirmation_required: true,
|
|
3045
|
-
campaign: { name, type: campaignType, site: site.name, ...targeting },
|
|
3054
|
+
campaign: { name, type: campaignType, site: site.name, send_class: sendClass, unsub_footer_template: footerTemplate, ...targeting },
|
|
3046
3055
|
steps: steps.map((s, i) => ({
|
|
3047
3056
|
step: i + 1,
|
|
3048
3057
|
subject: s.subject,
|
|
@@ -3066,6 +3075,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3066
3075
|
site_id,
|
|
3067
3076
|
name,
|
|
3068
3077
|
type: campaignType,
|
|
3078
|
+
send_class: sendClass,
|
|
3079
|
+
unsub_footer_template: footerTemplate,
|
|
3069
3080
|
...targeting,
|
|
3070
3081
|
status: 'draft',
|
|
3071
3082
|
segment_id: segment_id ?? null,
|
|
@@ -3150,6 +3161,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3150
3161
|
campaign_id: campaign.id,
|
|
3151
3162
|
name,
|
|
3152
3163
|
type: campaignType,
|
|
3164
|
+
send_class: campaign.send_class,
|
|
3165
|
+
unsub_footer_template: campaign.unsub_footer_template,
|
|
3153
3166
|
...targeting,
|
|
3154
3167
|
fires_on: buildCampaignFiresOn({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
3155
3168
|
trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
|
|
@@ -3415,6 +3428,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3415
3428
|
type: z.enum(['sequence', 'triggered']).optional().describe("Switch a DRAFT campaign between 'sequence' and 'triggered'. Event and tag triggers only start campaigns of type 'triggered'."),
|
|
3416
3429
|
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
|
|
3417
3430
|
send_in_contact_timezone: z.boolean().optional(),
|
|
3431
|
+
unsub_footer_template: z.string().nullable().optional().describe('Campaign unsubscribe footer override. Must contain <unsubscribe>link text</unsubscribe>. Set null to use the site footer.'),
|
|
3418
3432
|
send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
|
|
3419
3433
|
},
|
|
3420
3434
|
}, async ({ approval_id, ...input }) => {
|
|
@@ -3422,9 +3436,14 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3422
3436
|
const result = await resumeCampaignEdit('update_campaign', approval_id, input);
|
|
3423
3437
|
return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
|
|
3424
3438
|
}
|
|
3425
|
-
const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class } = input;
|
|
3439
|
+
const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class, unsub_footer_template } = input;
|
|
3426
3440
|
if (!campaign_id)
|
|
3427
3441
|
return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
|
|
3442
|
+
if (unsub_footer_template !== undefined && unsub_footer_template !== null) {
|
|
3443
|
+
const footerError = validateUnsubFooterTemplate(unsub_footer_template);
|
|
3444
|
+
if (footerError)
|
|
3445
|
+
return j({ error: footerError, code: 'INVALID_FIELD' });
|
|
3446
|
+
}
|
|
3428
3447
|
const projectId = getProjectId();
|
|
3429
3448
|
let campaignType;
|
|
3430
3449
|
const loadExisting = () => db()
|
|
@@ -3491,6 +3510,8 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3491
3510
|
updates.send_in_contact_timezone = send_in_contact_timezone;
|
|
3492
3511
|
if (send_class !== undefined)
|
|
3493
3512
|
updates.send_class = send_class;
|
|
3513
|
+
if (unsub_footer_template !== undefined)
|
|
3514
|
+
updates.unsub_footer_template = unsub_footer_template?.trim() ?? null;
|
|
3494
3515
|
const targetingError = await applyCampaignTargetingFields(updates, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId, existingCampaignForTargeting);
|
|
3495
3516
|
if (targetingError)
|
|
3496
3517
|
return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'INVALID_FIELD' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.108",
|
|
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",
|