@sendinel/mcp-server 1.0.108 → 1.0.110

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.
@@ -366,8 +366,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
366
366
  }, getProjectId()).catch(() => { });
367
367
  return elicitApproval({
368
368
  requires_approval: true,
369
- ...offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
370
- payload_hash: approvalPayloadHash(action, frozenParams) }),
369
+ ...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
370
+ payload_hash: approvalPayloadHash(action, frozenParams) })),
371
371
  approval_id: data.id,
372
372
  tier,
373
373
  action,
@@ -0,0 +1,62 @@
1
+ import { type ApprovalChannelAvailable } from './approval-capabilities.js';
2
+ /**
3
+ * #4285: connection-scoped persistence for the MCP Apps approval tier on stateless transports.
4
+ * The hosted route builds a fresh SDK server per POST, so neither declared capabilities nor
5
+ * app sessions may live in process memory. Both live in service-role-only `email` tables.
6
+ * This module never writes approval decisions: only approval-app.ts may reach the human callback.
7
+ */
8
+ export declare const CLIENT_CAPABILITIES_TABLE = "mcp_client_capabilities";
9
+ export declare const APP_SESSIONS_TABLE = "approval_app_sessions";
10
+ /** A declaration not refreshed by a new initialize within this window is ignored and purged. */
11
+ export declare const CLIENT_CAPABILITIES_TTL_MS: number;
12
+ export type ConnectionCredential = {
13
+ column: 'oauth_token_id' | 'api_key_id';
14
+ id: string;
15
+ };
16
+ /** OAuth connections are keyed by token id; API keys (including service keys) by key id. */
17
+ export declare function connectionCredential(context: {
18
+ authKind?: string | null;
19
+ oauthTokenId?: string | null;
20
+ keyId?: string | null;
21
+ } | undefined): ConnectionCredential | null;
22
+ /**
23
+ * Called by the hosted route for the POST that carries `initialize`. Overwrites the previous
24
+ * declaration for the credential. Returns false on any failure; callers then keep the fallback.
25
+ */
26
+ export declare function recordClientCapabilities(credential: ConnectionCredential, initializeParams: unknown): Promise<boolean>;
27
+ /**
28
+ * Channel for this session, with the #4285 rollout gate applied. The in-process declaration
29
+ * wins when it selects a tier. Otherwise (the hosted bridge pair declares nothing) the stored
30
+ * initialize declaration for this request's credential is read once per server instance.
31
+ * Only the Apps tier is honored from storage: elicitation needs the live connection that
32
+ * sent initialize, which a stateless route does not retain. No row means the fallback tier.
33
+ */
34
+ export declare function resolveApprovalChannel(session: object): Promise<ApprovalChannelAvailable>;
35
+ export type AppIdentity = {
36
+ userId: string;
37
+ projectId: string;
38
+ oauthTokenId: string;
39
+ };
40
+ export type OwnedApproval = {
41
+ id: string;
42
+ action: string;
43
+ params: Record<string, any>;
44
+ summary?: string;
45
+ tier: string;
46
+ expires_at: string;
47
+ payload_hash: string;
48
+ };
49
+ /** The approval row is the offer: same project and same OAuth token, still pending, gated, frozen. */
50
+ export declare function loadOwnedPendingApproval(approvalId: unknown, identity: AppIdentity): Promise<OwnedApproval | null>;
51
+ export declare function hashAppSession(token: string): string;
52
+ /**
53
+ * Mints the one outstanding 256-bit app_session for an owned approval. A re-render deletes the
54
+ * previous unclaimed token first; a partial unique index rejects a concurrent second insert.
55
+ * Only the SHA-256 hash is stored. The plaintext is returned once, for resource `_meta` only.
56
+ */
57
+ export declare function mintApprovalAppSession(approval: OwnedApproval, identity: AppIdentity): Promise<string | null>;
58
+ /**
59
+ * Single guarded UPDATE: succeeds once, only for the unclaimed, unexpired token bound to this
60
+ * approval, project, OAuth token, user and frozen payload hash. Wrong bindings do not burn it.
61
+ */
62
+ export declare function claimApprovalAppSession(token: unknown, approval: OwnedApproval, identity: AppIdentity): Promise<boolean>;
@@ -0,0 +1,174 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { db, requestContext } from '../db.js';
3
+ import { approvalAppClientAllowed, approvalAppEnabled, approvalClientName, approval_channel_available, declaredAppMimeTypes, probeApprovalCapabilities, } from './approval-capabilities.js';
4
+ /**
5
+ * #4285: connection-scoped persistence for the MCP Apps approval tier on stateless transports.
6
+ * The hosted route builds a fresh SDK server per POST, so neither declared capabilities nor
7
+ * app sessions may live in process memory. Both live in service-role-only `email` tables.
8
+ * This module never writes approval decisions: only approval-app.ts may reach the human callback.
9
+ */
10
+ export const CLIENT_CAPABILITIES_TABLE = 'mcp_client_capabilities';
11
+ export const APP_SESSIONS_TABLE = 'approval_app_sessions';
12
+ /** A declaration not refreshed by a new initialize within this window is ignored and purged. */
13
+ export const CLIENT_CAPABILITIES_TTL_MS = 7 * 24 * 60 * 60 * 1000;
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ const APP_SESSION = /^[A-Za-z0-9_-]{43}$/;
16
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
17
+ // Over-long or non-string values are unknown (NULL), never truncated into a different name.
18
+ const bounded = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max ? value : null;
19
+ const nowIso = () => new Date(Date.now()).toISOString();
20
+ function email() {
21
+ const source = db();
22
+ return typeof source.schema === 'function' ? source.schema('email') : source;
23
+ }
24
+ /** OAuth connections are keyed by token id; API keys (including service keys) by key id. */
25
+ export function connectionCredential(context) {
26
+ if (context?.authKind === 'oauth') {
27
+ return context.oauthTokenId && UUID.test(context.oauthTokenId) ? { column: 'oauth_token_id', id: context.oauthTokenId } : null;
28
+ }
29
+ return context?.keyId && UUID.test(context.keyId) ? { column: 'api_key_id', id: context.keyId } : null;
30
+ }
31
+ /**
32
+ * Called by the hosted route for the POST that carries `initialize`. Overwrites the previous
33
+ * declaration for the credential. Returns false on any failure; callers then keep the fallback.
34
+ */
35
+ export async function recordClientCapabilities(credential, initializeParams) {
36
+ const params = object(initializeParams);
37
+ const info = object(params?.clientInfo);
38
+ const capabilities = probeApprovalCapabilities(params?.capabilities);
39
+ const now = Date.now();
40
+ const client = email();
41
+ try {
42
+ // Lazy purge (#4285 item 6): expired declarations never select a tier and are removed here.
43
+ await client.from(CLIENT_CAPABILITIES_TABLE).delete().lt('expires_at', new Date(now).toISOString());
44
+ const { error } = await client.from(CLIENT_CAPABILITIES_TABLE).upsert({
45
+ [credential.column]: credential.id,
46
+ elicitation_form: capabilities.elicitation_form,
47
+ elicitation_url: capabilities.elicitation_url,
48
+ mcp_app: capabilities.mcp_app,
49
+ app_mime_types: declaredAppMimeTypes(params?.capabilities),
50
+ client_name: bounded(info?.name, 200),
51
+ client_version: bounded(info?.version, 100),
52
+ updated_at: new Date(now).toISOString(),
53
+ expires_at: new Date(now + CLIENT_CAPABILITIES_TTL_MS).toISOString(),
54
+ }, { onConflict: credential.column });
55
+ return !error;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ const storedCache = new WeakMap();
62
+ async function loadStoredCapabilities(credential) {
63
+ try {
64
+ const { data, error } = await email().from(CLIENT_CAPABILITIES_TABLE).select('mcp_app, client_name')
65
+ .eq(credential.column, credential.id).gt('expires_at', nowIso()).maybeSingle();
66
+ return error || !data ? null : { mcp_app: data.mcp_app === true, client_name: typeof data.client_name === 'string' ? data.client_name : null };
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /**
73
+ * Channel for this session, with the #4285 rollout gate applied. The in-process declaration
74
+ * wins when it selects a tier. Otherwise (the hosted bridge pair declares nothing) the stored
75
+ * initialize declaration for this request's credential is read once per server instance.
76
+ * Only the Apps tier is honored from storage: elicitation needs the live connection that
77
+ * sent initialize, which a stateless route does not retain. No row means the fallback tier.
78
+ */
79
+ export async function resolveApprovalChannel(session) {
80
+ const local = approval_channel_available(session, { appsAllowed: approvalAppClientAllowed(approvalClientName(session)) });
81
+ if (local !== 'plain_text' || !approvalAppEnabled())
82
+ return local;
83
+ const credential = connectionCredential(requestContext());
84
+ if (!credential)
85
+ return local;
86
+ let perSession = storedCache.get(session);
87
+ if (!perSession)
88
+ storedCache.set(session, perSession = new Map());
89
+ const key = `${credential.column}:${credential.id}`;
90
+ let pending = perSession.get(key);
91
+ if (!pending)
92
+ perSession.set(key, pending = loadStoredCapabilities(credential));
93
+ const stored = await pending;
94
+ return stored?.mcp_app && approvalAppClientAllowed(stored.client_name) ? 'mcp_app' : local;
95
+ }
96
+ /** The approval row is the offer: same project and same OAuth token, still pending, gated, frozen. */
97
+ export async function loadOwnedPendingApproval(approvalId, identity) {
98
+ if (typeof approvalId !== 'string' || !UUID.test(approvalId))
99
+ return null;
100
+ const now = nowIso();
101
+ try {
102
+ const { data, error } = await email().from('action_approvals')
103
+ .select('id, project_id, oauth_token_id, action, params, summary, tier, status, used_at, expires_at, payload_hash')
104
+ .eq('id', approvalId).eq('project_id', identity.projectId).eq('oauth_token_id', identity.oauthTokenId)
105
+ .eq('status', 'pending').is('used_at', null).gt('expires_at', now).maybeSingle();
106
+ if (error || !data || data.id !== approvalId || data.project_id !== identity.projectId ||
107
+ data.oauth_token_id !== identity.oauthTokenId || data.status !== 'pending' || data.used_at)
108
+ return null;
109
+ if (data.tier === 'protected' || typeof data.payload_hash !== 'string' || !data.payload_hash)
110
+ return null;
111
+ if (!(Date.parse(data.expires_at) > Date.now()) || !object(data.params))
112
+ return null;
113
+ return { id: data.id, action: data.action, params: data.params, summary: data.summary ?? undefined,
114
+ tier: data.tier, expires_at: data.expires_at, payload_hash: data.payload_hash };
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ export function hashAppSession(token) {
121
+ return createHash('sha256').update(token).digest('hex');
122
+ }
123
+ /**
124
+ * Mints the one outstanding 256-bit app_session for an owned approval. A re-render deletes the
125
+ * previous unclaimed token first; a partial unique index rejects a concurrent second insert.
126
+ * Only the SHA-256 hash is stored. The plaintext is returned once, for resource `_meta` only.
127
+ */
128
+ export async function mintApprovalAppSession(approval, identity) {
129
+ const client = email();
130
+ try {
131
+ // Lazy purge (#4285 item 6). A failed purge does not block the render.
132
+ await client.from(APP_SESSIONS_TABLE).delete().lt('expires_at', nowIso());
133
+ const { error: rotateError } = await client.from(APP_SESSIONS_TABLE).delete()
134
+ .eq('approval_id', approval.id).is('claimed_at', null);
135
+ if (rotateError)
136
+ return null;
137
+ const token = randomBytes(32).toString('base64url');
138
+ const { error } = await client.from(APP_SESSIONS_TABLE).insert({
139
+ approval_id: approval.id,
140
+ token_hash: hashAppSession(token),
141
+ oauth_token_id: identity.oauthTokenId,
142
+ user_id: identity.userId,
143
+ project_id: identity.projectId,
144
+ payload_hash: approval.payload_hash,
145
+ // Never outlives the approval it decides.
146
+ expires_at: approval.expires_at,
147
+ });
148
+ return error ? null : token;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Single guarded UPDATE: succeeds once, only for the unclaimed, unexpired token bound to this
156
+ * approval, project, OAuth token, user and frozen payload hash. Wrong bindings do not burn it.
157
+ */
158
+ export async function claimApprovalAppSession(token, approval, identity) {
159
+ if (typeof token !== 'string' || !APP_SESSION.test(token))
160
+ return false;
161
+ const now = nowIso();
162
+ try {
163
+ const { data, error } = await email().from(APP_SESSIONS_TABLE)
164
+ .update({ claimed_at: now })
165
+ .eq('token_hash', hashAppSession(token)).eq('approval_id', approval.id).eq('project_id', identity.projectId)
166
+ .eq('oauth_token_id', identity.oauthTokenId).eq('user_id', identity.userId).eq('payload_hash', approval.payload_hash)
167
+ .is('claimed_at', null).gt('expires_at', now)
168
+ .select('id').maybeSingle();
169
+ return !error && !!data;
170
+ }
171
+ catch {
172
+ return false;
173
+ }
174
+ }
@@ -25,19 +25,26 @@ export declare function renderApprovalApp(approval?: AppApproval): string;
25
25
  export declare function registerApprovalAppResources(server: McpServer, read: (id: string) => Promise<{
26
26
  html: string;
27
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): {
28
+ }>, guard?: () => void): void;
29
+ /**
30
+ * Called only after the core has persisted its frozen payload. Tokens never enter tool results.
31
+ * The persisted approval row (project, OAuth token, pending, expiry, payload_hash) is the offer:
32
+ * there is no in-memory offer state, so the resource read may land on another server instance.
33
+ */
34
+ export declare function offerApprovalApp(approval: AppApproval): Promise<{
31
35
  _meta?: {
32
36
  ui: {
33
37
  resourceUri: string;
34
38
  };
35
39
  };
36
- };
40
+ }>;
37
41
  /** Transport boundary, outside the model catalog and gateway catalog. Apps hosts MUST
38
42
  * 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.
43
+ * #4285: sessions live hashed in email.approval_app_sessions, bound to approval, project, OAuth
44
+ * token, user and payload hash, so no cross-request in-memory state exists. `hostedDecision`
45
+ * is set only by the hosted route's dedicated decision bridge (never by catalogs/gateways).
40
46
  */
41
47
  export declare function installApprovalApp(server: McpServer, options?: {
42
48
  scopes?: string[];
49
+ hostedDecision?: boolean;
43
50
  }, approvalTool?: (name: string) => boolean): McpServer;
@@ -8,8 +8,9 @@ import { AsyncLocalStorage } from 'node:async_hooks';
8
8
  import { z } from 'zod';
9
9
  import { requestContext } from '../db.js';
10
10
  import { getProjectId } from '../project.js';
11
- import { approval_channel_available } from './approval-capabilities.js';
11
+ import { approvalAppClientAllowed, approvalAppEnabled, approvalClientName, approval_channel_available } from './approval-capabilities.js';
12
12
  import { createHumanApprovalCallback } from './approval-human-callback.js';
13
+ import { claimApprovalAppSession, loadOwnedPendingApproval, mintApprovalAppSession, resolveApprovalChannel } from './approval-app-store.js';
13
14
  /** Only frozen facts enter this view. No live reads or model-supplied display overrides. */
14
15
  export function approvalAppView(approval) {
15
16
  const preview = buildApprovalPreview(approval);
@@ -33,53 +34,48 @@ export function renderApprovalApp(approval) {
33
34
  const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
34
35
  return APPROVAL_APP_HTML.replaceAll('__NONCE__', nonce).replace('__APP_DATA__', () => json);
35
36
  }
36
- export function registerApprovalAppResources(server, read) {
37
+ export function registerApprovalAppResources(server, read, guard = () => { }) {
37
38
  const config = { mimeType: APPROVAL_APP.mimeType, _meta: approvalAppResourceMeta };
38
39
  const result = (uri, text, appSession) => ({ contents: [{ uri: uri.href,
39
40
  mimeType: APPROVAL_APP.mimeType, text, _meta: { ...approvalAppResourceMeta,
40
41
  ...(appSession ? { [APPROVAL_APP_SESSION_META]: appSession } : {}) } }] });
41
- server.registerResource('approval-app', APPROVAL_APP.uri, config, async (uri) => result(uri, renderApprovalApp()));
42
+ server.registerResource('approval-app', APPROVAL_APP.uri, config, async (uri) => { guard(); return result(uri, renderApprovalApp()); });
42
43
  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
  }
44
45
  const runtimes = new WeakMap();
45
46
  const calls = new AsyncLocalStorage();
46
47
  const resourceUri = (id) => `${APPROVAL_APP.uri}/${encodeURIComponent(id)}`;
48
+ const unavailable = () => new Error('Approval app unavailable on this connection; use approve_url.');
49
+ /**
50
+ * #4285: a clicking human on an authenticated OAuth connection. Sessions are bound to the OAuth
51
+ * token id, so an API key owner, agent, tester or scoped JWT is never attributed a click.
52
+ */
47
53
  function identity() {
48
54
  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;
55
+ return context?.channelVerified === true && context.actorId && context.authKind === 'oauth' && context.oauthTokenId
56
+ ? { userId: context.actorId, projectId: getProjectId(), oauthTokenId: context.oauthTokenId } : null;
56
57
  }
57
58
  function live(approval) {
58
59
  return Number.isFinite(Date.parse(approval.expires_at)) && Date.parse(approval.expires_at) > Date.now();
59
60
  }
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) {
61
+ /**
62
+ * Called only after the core has persisted its frozen payload. Tokens never enter tool results.
63
+ * The persisted approval row (project, OAuth token, pending, expiry, payload_hash) is the offer:
64
+ * there is no in-memory offer state, so the resource read may land on another server instance.
65
+ */
66
+ export async function offerApprovalApp(approval) {
70
67
  const runtime = calls.getStore(), actor = identity();
71
- if (!runtime || !runtime.canDecide || !actor || approval.tier === 'protected' ||
72
- approval_channel_available(runtime.server.server) !== 'mcp_app')
68
+ if (!runtime || !runtime.canDecide || !actor || !approvalAppEnabled() || approval.tier === 'protected' || !live(approval))
73
69
  return {};
74
- prune(runtime);
75
- if (runtime.offers.size >= 128 || !live(approval))
70
+ if (await resolveApprovalChannel(runtime.server.server) !== 'mcp_app')
76
71
  return {};
77
- runtime.offers.set(approval.id, { approval: structuredClone(approval), ...actor });
78
72
  return { _meta: { ui: { [APPROVAL_APP.resultUriKey]: resourceUri(approval.id) } } };
79
73
  }
80
74
  /** Transport boundary, outside the model catalog and gateway catalog. Apps hosts MUST
81
75
  * 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.
76
+ * #4285: sessions live hashed in email.approval_app_sessions, bound to approval, project, OAuth
77
+ * token, user and payload hash, so no cross-request in-memory state exists. `hostedDecision`
78
+ * is set only by the hosted route's dedicated decision bridge (never by catalogs/gateways).
83
79
  */
84
80
  export function installApprovalApp(server, options = {}, approvalTool = () => false) {
85
81
  // Catalog collectors have no transport/resource API; never expose this tool through them.
@@ -87,60 +83,68 @@ export function installApprovalApp(server, options = {}, approvalTool = () => fa
87
83
  return server;
88
84
  let runtime = runtimes.get(server.server);
89
85
  if (!runtime) {
90
- runtime = { server, connectionId: randomBytes(32).toString('hex'), offers: new Map(), sessions: new Map(),
86
+ runtime = { server, decideRegistered: false,
91
87
  canDecide: !options.scopes?.some(scope => scope === 'tester' || scope === 'agent') };
92
88
  runtimes.set(server.server, runtime);
93
89
  const bound = runtime;
94
90
  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.');
91
+ const actor = identity();
92
+ if (!bound.canDecide || !actor || !approvalAppEnabled() || await resolveApprovalChannel(server.server) !== 'mcp_app')
93
+ throw unavailable();
94
+ const approval = await loadOwnedPendingApproval(id, actor);
95
+ if (!approval)
96
+ throw unavailable();
99
97
  // 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 });
98
+ const token = await mintApprovalAppSession(approval, actor);
99
+ if (!token)
100
+ throw unavailable();
107
101
  // Resource text can be shown to the model; the bearer belongs exclusively
108
102
  // 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
- });
103
+ return { html: renderApprovalApp(approval), appSession: token };
104
+ }, () => { if (!approvalAppEnabled())
105
+ throw unavailable(); });
106
+ const registerDecide = () => {
107
+ if (bound.decideRegistered)
108
+ return;
109
+ bound.decideRegistered = true;
110
+ server.registerTool(APPROVAL_APP.tool, {
111
+ description: 'Record the human decision from this connection’s rendered approval app.',
112
+ _meta: { ui: { visibility: ['app'] } },
113
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
114
+ inputSchema: z.object({ approval_id: z.string(), decision: z.enum(['approve', 'reject']), app_session: z.string().min(1) }).strict(),
115
+ }, async ({ approval_id, decision, app_session }) => {
116
+ const refused = () => ({ isError: true, content: [{ type: 'text', text: 'Decision refused; use the dashboard approval link.' }] });
117
+ const actor = identity();
118
+ if (!bound.canDecide || !approvalAppEnabled() || !actor)
119
+ return refused();
120
+ if (await resolveApprovalChannel(server.server) !== 'mcp_app')
121
+ return refused();
122
+ const approval = await loadOwnedPendingApproval(approval_id, actor);
123
+ // Single guarded claim before the callback, so concurrent clicks cannot reuse a session.
124
+ if (!approval || !await claimApprovalAppSession(app_session, approval, actor))
125
+ return refused();
126
+ const connectionId = `oauth:${actor.oauthTokenId}`;
127
+ const callback = createHumanApprovalCallback({ approvalId: approval.id, projectId: actor.projectId,
128
+ userId: actor.userId, connectionId, channel: 'mcp_app' });
129
+ const accepted = await callback.complete({ interactionId: callback.interactionId, connectionId, decision });
130
+ return accepted ? { content: [{ type: 'text', text: 'Decision recorded.' }] } : refused();
131
+ });
132
+ };
133
+ if (options.hostedDecision && bound.canDecide && approvalAppEnabled())
134
+ registerDecide();
130
135
  const previousInitialized = server.server.oninitialized;
131
136
  server.server.oninitialized = () => {
132
137
  previousInitialized?.();
133
- if (bound.canDecide && approval_channel_available(server.server) === 'mcp_app')
138
+ if (bound.canDecide && approvalAppEnabled() && approval_channel_available(server.server, { appsAllowed: approvalAppClientAllowed(approvalClientName(server.server)) }) === 'mcp_app')
134
139
  registerDecide();
135
140
  };
136
- const previousClose = server.server.onclose;
137
- server.server.onclose = () => { bound.offers.clear(); bound.sessions.clear(); previousClose?.(); };
138
141
  }
139
142
  const bound = runtime;
140
143
  return new Proxy(server, { get(target, prop, receiver) {
141
144
  if (prop !== 'registerTool')
142
145
  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 () => {
146
+ return (name, config, handler) => target.registerTool(name, approvalAppEnabled() && (approvalTool(name) || name === 'invoke_tool')
147
+ ? { ...config, _meta: { ...config._meta, ui: { resourceUri: APPROVAL_APP.uri } } } : config, async (...args) => calls.run(calls.getStore() ?? bound, async () => {
144
148
  const result = await handler(...args);
145
149
  // Existing tools serialize ApprovalResult into content; promote ONLY its UI
146
150
  // reference to MCP result metadata. The resource token is never in that object.
@@ -8,10 +8,30 @@ export type ApprovalClientCapabilities = Readonly<{
8
8
  export type ApprovalChannelAvailable = 'mcp_app' | 'mcp_elicitation_form' | 'mcp_elicitation_url' | 'plain_text';
9
9
  /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
10
10
  export declare function probeApprovalCapabilities(declared: unknown): ApprovalClientCapabilities;
11
- export declare function rememberApprovalCapabilities(session: object, declared: unknown): ApprovalClientCapabilities;
12
- export declare function approval_channel_available(session: object): ApprovalChannelAvailable;
11
+ /** Declared Apps mime types, bounded for storage. Never used to decide Apps support on its own. */
12
+ export declare function declaredAppMimeTypes(declared: unknown): string[];
13
+ /**
14
+ * #4285 rollout gate. The Apps tier is OFF unless MCP_APPROVAL_APP_ENABLED is exactly "true"
15
+ * or "1". When on, only clients whose initialize clientInfo.name appears in the comma-separated
16
+ * MCP_APPROVAL_APP_CLIENTS allowlist get it. An empty allowlist allows no client. The name is
17
+ * client-declared: this is a rollout control for hosts verified to keep resource metadata and
18
+ * app-only tools out of model context, not authentication.
19
+ */
20
+ export declare function approvalAppEnabled(env?: NodeJS.ProcessEnv): boolean;
21
+ export declare function approvalAppClientAllowed(clientName: unknown, env?: NodeJS.ProcessEnv): boolean;
22
+ export declare function rememberApprovalCapabilities(session: object, declared: unknown, clientName?: unknown): ApprovalClientCapabilities;
23
+ /** Name from this in-process session's initialize clientInfo; undefined when none was declared. */
24
+ export declare function approvalClientName(session: object): string | undefined;
25
+ /**
26
+ * Declared-capability probe for this in-process session. `appsAllowed: false` removes the Apps
27
+ * tier from the choice (flag off / client not allowlisted), so a lower tier can still be used.
28
+ */
29
+ export declare function approval_channel_available(session: object, options?: {
30
+ appsAllowed?: boolean;
31
+ }): ApprovalChannelAvailable;
13
32
  /** Each SDK Server is a session boundary, including stateless HTTP request instances. */
14
33
  export declare function installApprovalCapabilityProbe(session: {
15
34
  oninitialized?: () => void;
16
35
  getClientCapabilities(): unknown;
36
+ getClientVersion?(): unknown;
17
37
  }): void;
@@ -1,6 +1,7 @@
1
1
  export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
2
2
  export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
3
3
  const sessions = new WeakMap();
4
+ const clientNames = new WeakMap();
4
5
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
5
6
  /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
6
7
  export function probeApprovalCapabilities(declared) {
@@ -15,14 +16,50 @@ export function probeApprovalCapabilities(declared) {
15
16
  mcp_app: !!app && Array.isArray(app.mimeTypes) && app.mimeTypes.includes(MCP_APP_MIME_TYPE),
16
17
  });
17
18
  }
18
- export function rememberApprovalCapabilities(session, declared) {
19
+ /** Declared Apps mime types, bounded for storage. Never used to decide Apps support on its own. */
20
+ export function declaredAppMimeTypes(declared) {
21
+ const app = object(object(object(declared)?.extensions)?.[MCP_APP_EXTENSION]);
22
+ if (!Array.isArray(app?.mimeTypes))
23
+ return [];
24
+ return app.mimeTypes.filter((value) => typeof value === 'string' && value.length <= 200).slice(0, 16);
25
+ }
26
+ /**
27
+ * #4285 rollout gate. The Apps tier is OFF unless MCP_APPROVAL_APP_ENABLED is exactly "true"
28
+ * or "1". When on, only clients whose initialize clientInfo.name appears in the comma-separated
29
+ * MCP_APPROVAL_APP_CLIENTS allowlist get it. An empty allowlist allows no client. The name is
30
+ * client-declared: this is a rollout control for hosts verified to keep resource metadata and
31
+ * app-only tools out of model context, not authentication.
32
+ */
33
+ export function approvalAppEnabled(env = process.env) {
34
+ const value = env.MCP_APPROVAL_APP_ENABLED?.trim();
35
+ return value === 'true' || value === '1';
36
+ }
37
+ export function approvalAppClientAllowed(clientName, env = process.env) {
38
+ if (!approvalAppEnabled(env) || typeof clientName !== 'string' || !clientName.trim())
39
+ return false;
40
+ const allowed = (env.MCP_APPROVAL_APP_CLIENTS ?? '').split(',').map(name => name.trim()).filter(Boolean);
41
+ return allowed.includes(clientName.trim());
42
+ }
43
+ export function rememberApprovalCapabilities(session, declared, clientName) {
19
44
  const capabilities = probeApprovalCapabilities(declared);
20
45
  sessions.set(session, capabilities);
46
+ if (typeof clientName === 'string')
47
+ clientNames.set(session, clientName);
48
+ else
49
+ clientNames.delete(session);
21
50
  return capabilities;
22
51
  }
23
- export function approval_channel_available(session) {
52
+ /** Name from this in-process session's initialize clientInfo; undefined when none was declared. */
53
+ export function approvalClientName(session) {
54
+ return clientNames.get(session);
55
+ }
56
+ /**
57
+ * Declared-capability probe for this in-process session. `appsAllowed: false` removes the Apps
58
+ * tier from the choice (flag off / client not allowlisted), so a lower tier can still be used.
59
+ */
60
+ export function approval_channel_available(session, options = {}) {
24
61
  const capabilities = sessions.get(session);
25
- if (capabilities?.mcp_app)
62
+ if (capabilities?.mcp_app && options.appsAllowed !== false)
26
63
  return 'mcp_app';
27
64
  if (capabilities?.elicitation_form)
28
65
  return 'mcp_elicitation_form';
@@ -34,7 +71,8 @@ export function approval_channel_available(session) {
34
71
  export function installApprovalCapabilityProbe(session) {
35
72
  const previous = session.oninitialized;
36
73
  session.oninitialized = () => {
37
- rememberApprovalCapabilities(session, session.getClientCapabilities());
74
+ const info = object(session.getClientVersion?.());
75
+ rememberApprovalCapabilities(session, session.getClientCapabilities(), info?.name);
38
76
  previous?.call(session);
39
77
  };
40
78
  }
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
4
4
  import { db } from '../db.js';
5
- import { approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
5
+ import { approvalAppClientAllowed, approvalClientName, approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
6
6
  // Keyed by the SDK Server, not the McpServer wrapper: other installers (the Apps tier) wrap
7
7
  // McpServer in a Proxy, which changes the wrapper's identity but never the connection's Server.
8
8
  const sessions = new WeakMap();
@@ -26,7 +26,8 @@ export async function elicitApproval(result, frozen, projectId) {
26
26
  const context = requests.getStore();
27
27
  if (!context || context.projectId !== projectId || result.tier !== 'gated')
28
28
  return pending;
29
- let channel = approval_channel_available(context.server.server);
29
+ // #4285: with the Apps tier off for this client (default), a declared lower tier still applies.
30
+ let channel = approval_channel_available(context.server.server, { appsAllowed: approvalAppClientAllowed(approvalClientName(context.server.server)) });
30
31
  if (channel === 'mcp_elicitation_form' && !context.userId &&
31
32
  probeApprovalCapabilities(context.server.server.getClientCapabilities()).elicitation_url)
32
33
  channel = 'mcp_elicitation_url';
package/build/server.js CHANGED
@@ -382,7 +382,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
382
382
  export function registerAllTools(server, options) {
383
383
  if (server.server)
384
384
  installApprovalCapabilityProbe(server.server);
385
- server = installApprovalApp(server, options, isApprovalGatedAction);
385
+ // Registry callers pass scopes only: the hosted decision bridge is never enabled from a catalog.
386
+ server = installApprovalApp(server, { scopes: options?.scopes }, isApprovalGatedAction);
386
387
  const rawScopes = options?.scopes ?? [];
387
388
  if (rawScopes.includes('gateway')) {
388
389
  const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
@@ -1,4 +1,6 @@
1
1
  import { z } from 'zod';
2
+ import { validateSiteUnsubscribeSettings } from '@sendinel/domain';
3
+ import { logAudit } from '../audit.js';
2
4
  import { db } from '../db.js';
3
5
  import { getProjectId } from '../project.js';
4
6
  import { checkPlanLimit } from '../plan-limits.js';
@@ -112,14 +114,14 @@ export function registerSiteTools(server) {
112
114
  idempotentHint: true,
113
115
  openWorldHint: false,
114
116
  },
115
- description: 'Read the brand voice configuration for a sender/site.',
117
+ description: 'Read the brand voice configuration and current site-wide footer, unsubscribe link settings, unsubscribe text, and physical mailing address for a sender/site.',
116
118
  inputSchema: {
117
119
  site_id: z.string().uuid(),
118
120
  },
119
121
  }, async ({ site_id }) => {
120
122
  const { data, error } = await db()
121
123
  .from('sites')
122
- .select('id, name, brand_voice')
124
+ .select('id, name, brand_voice, physical_address, unsub_link_enabled, unsub_one_click_enabled, unsub_custom_url, unsub_footer_template, unsub_custom_message, unsub_brand_logo_url, unsub_brand_color')
123
125
  .eq('project_id', getProjectId())
124
126
  .eq('id', site_id)
125
127
  .maybeSingle();
@@ -184,15 +186,23 @@ export function registerSiteTools(server) {
184
186
  idempotentHint: true,
185
187
  openWorldHint: false,
186
188
  },
187
- description: 'Update a site name, from address, or other settings',
189
+ description: 'Update site-wide email footer, unsubscribe text, unsubscribe link, one-click unsubscribe, unsubscribe page branding, sender name/from address, active status, or physical mailing address. unsub_footer_template must be non-empty and include <unsubscribe>link text</unsubscribe>. Omitted settings and physical_address stay unchanged. Returns stored settings and previous_unsub_footer_template when the footer changes, so callers can undo.',
188
190
  inputSchema: {
189
191
  site_id: z.string().uuid(),
190
192
  name: z.string().optional(),
191
193
  from_email: z.string().email().optional(),
192
194
  from_name: z.string().optional(),
193
195
  active: z.boolean().optional(),
196
+ physical_address: z.string().trim().min(1).optional().describe('Physical mailing address; unchanged unless explicitly supplied.'),
197
+ unsub_link_enabled: z.boolean().optional().describe('Enable the unsubscribe link in the email footer.'),
198
+ unsub_one_click_enabled: z.boolean().optional().describe('Enable one-click unsubscribe headers.'),
199
+ unsub_custom_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe URL; null or blank clears it.'),
200
+ unsub_footer_template: z.string().nullable().optional().describe('Non-empty site-wide footer including <unsubscribe>link text</unsubscribe>.'),
201
+ unsub_custom_message: z.string().nullable().optional().describe('Unsubscribe page message, up to 280 characters; null or blank clears it.'),
202
+ unsub_brand_logo_url: z.string().nullable().optional().describe('HTTP(S) unsubscribe page logo URL; null or blank clears it.'),
203
+ unsub_brand_color: z.string().nullable().optional().describe('Six-digit hex unsubscribe page color; null or blank clears it.'),
194
204
  },
195
- }, async ({ site_id, name, from_email, from_name, active }) => {
205
+ }, async ({ site_id, name, from_email, from_name, active, physical_address, ...unsubscribeInput }) => {
196
206
  const updates = {};
197
207
  if (name)
198
208
  updates.name = name;
@@ -202,18 +212,54 @@ export function registerSiteTools(server) {
202
212
  updates.from_name = from_name;
203
213
  if (active !== undefined)
204
214
  updates.active = active;
215
+ if (physical_address !== undefined)
216
+ updates.physical_address = physical_address.trim().slice(0, 500);
217
+ const unsubscribe = validateSiteUnsubscribeSettings(unsubscribeInput);
218
+ if (!unsubscribe.ok)
219
+ return txt(`Error: ${unsubscribe.error}`);
220
+ Object.assign(updates, unsubscribe.value);
205
221
  if (!Object.keys(updates).length)
206
222
  return txt('No fields to update.');
223
+ // Read only when a footer is supplied, and guard the write so the undo value is exact.
224
+ const sb = db();
225
+ const projectId = getProjectId();
226
+ let previousTemplate;
227
+ if (updates.unsub_footer_template !== undefined) {
228
+ const { data: current, error } = await sb
229
+ .from('sites')
230
+ .select('unsub_footer_template')
231
+ .eq('id', site_id)
232
+ .eq('project_id', projectId)
233
+ .maybeSingle();
234
+ if (error)
235
+ return txt(`Error: ${error.message}`);
236
+ if (!current)
237
+ return j({ error: 'Site not found', code: 'NOT_FOUND' });
238
+ previousTemplate = current.unsub_footer_template;
239
+ }
207
240
  updates.updated_at = new Date().toISOString();
208
- const { data, error } = await db()
241
+ let query = sb
209
242
  .from('sites')
210
243
  .update(updates)
211
244
  .eq('id', site_id)
212
- .eq('project_id', getProjectId())
213
- .select()
214
- .single();
245
+ .eq('project_id', projectId);
246
+ if (previousTemplate === null)
247
+ query = query.is('unsub_footer_template', null);
248
+ else if (previousTemplate !== undefined)
249
+ query = query.eq('unsub_footer_template', previousTemplate);
250
+ const { data, error } = await query.select().maybeSingle();
215
251
  if (error)
216
252
  return txt(`Error: ${error.message}`);
217
- return j(data);
253
+ if (!data)
254
+ return j({ error: 'Site not found or footer changed during update. Read the site and retry.', code: 'SITE_CHANGED_OR_NOT_FOUND' });
255
+ await logAudit({
256
+ action: 'site.updated', resourceType: 'site', resourceId: site_id,
257
+ details: { fields: Object.keys(updates).filter((field) => field !== 'updated_at') },
258
+ });
259
+ return j({
260
+ ...data,
261
+ ...(previousTemplate !== undefined && previousTemplate !== data.unsub_footer_template
262
+ ? { previous_unsub_footer_template: previousTemplate } : {}),
263
+ });
218
264
  });
219
265
  }
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
12
12
  * Only tools that exist in the registry belong here.
13
13
  */
14
14
  export const TOOL_ALIASES = {
15
+ update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
15
16
  set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'trial ending'],
16
17
  send_broadcast: [
17
18
  'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.108",
3
+ "version": "1.0.110",
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",