@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.
@@ -0,0 +1,2 @@
1
+ /** Single-file, network-free Apps UI; email images load only inside scriptless srcdoc. */
2
+ export declare const APPROVAL_APP_HTML: string;
@@ -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,'&amp;').replace(/</g,'&lt;') + '</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');
@@ -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.106",
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",