@sendinel/mcp-server 1.0.109 → 1.0.111
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/db.d.ts +6 -1
- package/build/db.js +12 -3
- package/build/lib/action-approvals.js +18 -8
- package/build/lib/approval-app-store.d.ts +62 -0
- package/build/lib/approval-app-store.js +174 -0
- package/build/lib/approval-app.d.ts +12 -5
- package/build/lib/approval-app.js +67 -63
- package/build/lib/approval-capabilities.d.ts +22 -2
- package/build/lib/approval-capabilities.js +42 -4
- package/build/lib/approval-elicitation.js +5 -4
- package/build/lib/approval-human-callback.js +2 -2
- package/build/lib/approval-snapshot.js +5 -2
- package/build/lib/campaign-trigger-approval.js +2 -1
- package/build/lib/how-to-recipes.js +1 -1
- package/build/lib/tester-policy.d.ts +6 -0
- package/build/lib/tester-policy.js +18 -0
- package/build/lib/tester-request-audit.d.ts +2 -0
- package/build/lib/tester-request-audit.js +15 -0
- package/build/lib/tester-request-snapshot.d.ts +4 -0
- package/build/lib/tester-request-snapshot.js +46 -0
- package/build/lib/tester-requests-store.d.ts +2 -0
- package/build/lib/tester-requests-store.js +8 -0
- package/build/lib/tester-requests.d.ts +5 -0
- package/build/lib/tester-requests.js +71 -0
- package/build/server.d.ts +3 -3
- package/build/server.js +19 -10
- package/package.json +1 -1
package/build/db.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export declare function setProjectClient(client: any): void;
|
|
|
32
32
|
* @param fn - Async function to run within this client's context.
|
|
33
33
|
*/
|
|
34
34
|
export declare function runWithClient<T>(client: any, fn: () => Promise<T>, request?: {
|
|
35
|
+
scopes?: readonly string[];
|
|
35
36
|
projectId?: string;
|
|
36
37
|
approvalMode?: string;
|
|
37
38
|
channel?: string;
|
|
@@ -48,12 +49,16 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
|
|
|
48
49
|
kid?: string | null;
|
|
49
50
|
v1Caller?: V1Caller;
|
|
50
51
|
}): Promise<T>;
|
|
51
|
-
export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T): Promise<T>;
|
|
52
|
+
export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T, scopes?: readonly string[] | null): Promise<T>;
|
|
53
|
+
/** Keep the consumed-approval marker inside this async call; model input cannot set it. */
|
|
54
|
+
export declare function runWithApprovedTesterRequest<T>(action: string, fn: () => Promise<T> | T): Promise<T>;
|
|
52
55
|
/**
|
|
53
56
|
* Request-scoped identity set via runWithClient() (HTTP transport).
|
|
54
57
|
* Undefined under stdio, where the process env is the single-project source of truth.
|
|
55
58
|
*/
|
|
56
59
|
export declare function requestContext(): {
|
|
60
|
+
scopes?: readonly string[];
|
|
61
|
+
approvedTesterAction?: string;
|
|
57
62
|
projectId?: string;
|
|
58
63
|
approvalMode?: string;
|
|
59
64
|
channel?: string;
|
package/build/db.js
CHANGED
|
@@ -48,11 +48,18 @@ export async function runWithClient(
|
|
|
48
48
|
client, fn, request) {
|
|
49
49
|
return _requestStore.run({ client, ...request }, fn);
|
|
50
50
|
}
|
|
51
|
-
export async function runWithToolContext(toolGroup, toolName, fn) {
|
|
51
|
+
export async function runWithToolContext(toolGroup, toolName, fn, scopes) {
|
|
52
52
|
const current = _requestStore.getStore();
|
|
53
53
|
if (!current)
|
|
54
|
-
return await _requestStore.run({ client: _processClient, toolGroup, toolName }, fn);
|
|
55
|
-
return await _requestStore.run({ ...current, toolGroup, toolName }, fn);
|
|
54
|
+
return await _requestStore.run({ client: _processClient, toolGroup, toolName, scopes: scopes ?? undefined }, fn);
|
|
55
|
+
return await _requestStore.run({ ...current, toolGroup, toolName, scopes: scopes ?? current.scopes }, fn);
|
|
56
|
+
}
|
|
57
|
+
/** Keep the consumed-approval marker inside this async call; model input cannot set it. */
|
|
58
|
+
export async function runWithApprovedTesterRequest(action, fn) {
|
|
59
|
+
const current = _requestStore.getStore();
|
|
60
|
+
if (!current)
|
|
61
|
+
throw new Error('Tester application requires request context');
|
|
62
|
+
return _requestStore.run({ ...current, approvedTesterAction: action }, fn);
|
|
56
63
|
}
|
|
57
64
|
/**
|
|
58
65
|
* Request-scoped identity set via runWithClient() (HTTP transport).
|
|
@@ -62,6 +69,8 @@ export function requestContext() {
|
|
|
62
69
|
const ctx = _requestStore.getStore();
|
|
63
70
|
return ctx
|
|
64
71
|
? {
|
|
72
|
+
scopes: ctx.scopes,
|
|
73
|
+
approvedTesterAction: ctx.approvedTesterAction,
|
|
65
74
|
projectId: ctx.projectId,
|
|
66
75
|
approvalMode: ctx.approvalMode,
|
|
67
76
|
channel: ctx.channel,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { isTesterRequest, isApplyingTesterRequest } from './tester-policy.js';
|
|
2
|
+
import { logTesterRequestAudit } from './tester-request-audit.js';
|
|
3
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
1
4
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
3
|
-
import {
|
|
5
|
+
import { approvalSetupSummary, buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
|
|
6
|
+
import { requestContext } from '../db.js';
|
|
4
7
|
import { getProjectId } from '../project.js';
|
|
5
8
|
import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
|
|
6
9
|
import { fireWebhookEvent } from './webhook-events.js';
|
|
@@ -97,6 +100,8 @@ export function isApprovalGatedAction(action) {
|
|
|
97
100
|
return GATED_ACTIONS.has(action) || PROTECTED_ACTIONS.has(action);
|
|
98
101
|
}
|
|
99
102
|
export function getApprovalTier(action, params) {
|
|
103
|
+
if (isTesterRequest(action))
|
|
104
|
+
return isApplyingTesterRequest(action) ? 'autonomous' : 'gated';
|
|
100
105
|
// CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
|
|
101
106
|
// These are irreversible (GDPR delete, merge, etc.) — no opt-out.
|
|
102
107
|
if (PROTECTED_ACTIONS.has(action)) {
|
|
@@ -235,6 +240,9 @@ function launchSummary(p, d) {
|
|
|
235
240
|
}
|
|
236
241
|
/** Build a human-readable summary of the action for the approval screen */
|
|
237
242
|
export function buildApprovalSummary(action, params, display = {}) {
|
|
243
|
+
const setup = approvalSetupSummary(action, params);
|
|
244
|
+
if (setup)
|
|
245
|
+
return setup;
|
|
238
246
|
if ((action === 'update_campaign' || action === 'set_campaign_trigger') && params.previous_campaign && params.updates)
|
|
239
247
|
return triggerChangeSummary(String(params.previous_campaign.name ?? params.campaign_id), params.previous_campaign, params.updates);
|
|
240
248
|
const summaries = {
|
|
@@ -325,7 +333,6 @@ function approvalParamsEqual(expected, actual) {
|
|
|
325
333
|
*/
|
|
326
334
|
export async function requestApproval(action, params, apiKeyId, channelContext, display) {
|
|
327
335
|
const tier = getApprovalTier(action, params);
|
|
328
|
-
const summary = buildApprovalSummary(action, params, display);
|
|
329
336
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
330
337
|
const { channel, verified } = channelContext ?? currentChannelContext();
|
|
331
338
|
const actor = requestContext();
|
|
@@ -338,7 +345,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
338
345
|
throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
|
|
339
346
|
}
|
|
340
347
|
const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
|
|
341
|
-
const
|
|
348
|
+
const summary = buildApprovalSummary(action, frozenParams, display);
|
|
349
|
+
const { data, error } = await approvalStore()
|
|
342
350
|
.from('action_approvals')
|
|
343
351
|
.insert({
|
|
344
352
|
project_id: getProjectId(),
|
|
@@ -358,6 +366,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
358
366
|
.single();
|
|
359
367
|
if (error)
|
|
360
368
|
throw new Error(`Failed to create approval request: ${error.message}`);
|
|
369
|
+
if (isTesterRequest(action))
|
|
370
|
+
await logTesterRequestAudit('tester_request', action, data.id, null);
|
|
361
371
|
fireWebhookEvent('approval.requested', {
|
|
362
372
|
approval_id: data.id,
|
|
363
373
|
kind: action,
|
|
@@ -366,8 +376,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
366
376
|
}, getProjectId()).catch(() => { });
|
|
367
377
|
return elicitApproval({
|
|
368
378
|
requires_approval: true,
|
|
369
|
-
...offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
370
|
-
payload_hash: approvalPayloadHash(action, frozenParams) }),
|
|
379
|
+
...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
380
|
+
payload_hash: approvalPayloadHash(action, frozenParams) })),
|
|
371
381
|
approval_id: data.id,
|
|
372
382
|
tier,
|
|
373
383
|
action,
|
|
@@ -384,7 +394,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
384
394
|
* Returns the approval if approved, null if still pending, throws if rejected/expired.
|
|
385
395
|
*/
|
|
386
396
|
export async function checkApproval(approvalId, options = {}) {
|
|
387
|
-
const { data, error } = await
|
|
397
|
+
const { data, error } = await approvalStore()
|
|
388
398
|
.from('action_approvals')
|
|
389
399
|
.select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
|
|
390
400
|
.eq('id', approvalId)
|
|
@@ -440,7 +450,7 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
440
450
|
}
|
|
441
451
|
if (expiry <= Date.now())
|
|
442
452
|
return { approved: false, status: 'expired' };
|
|
443
|
-
const claimQuery =
|
|
453
|
+
const claimQuery = approvalStore()
|
|
444
454
|
.from('action_approvals')
|
|
445
455
|
.update({ status: 'used', used_at: new Date().toISOString() })
|
|
446
456
|
.eq('id', approvalId)
|
|
@@ -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
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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'
|
|
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
|
-
|
|
12
|
-
export declare function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
const info = object(session.getClientVersion?.());
|
|
75
|
+
rememberApprovalCapabilities(session, session.getClientCapabilities(), info?.name);
|
|
38
76
|
previous?.call(session);
|
|
39
77
|
};
|
|
40
78
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
|
|
4
|
-
import {
|
|
5
|
-
import { approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
|
|
4
|
+
import { approvalStore } from './tester-requests-store.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
|
-
|
|
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';
|
|
@@ -86,7 +87,7 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
86
87
|
// URL accept only means the page may open. Only its authenticated decision counts.
|
|
87
88
|
// Observe while this request is alive; do not retain a background poller after timeout.
|
|
88
89
|
while (!controller.signal.aborted) {
|
|
89
|
-
const source =
|
|
90
|
+
const source = approvalStore();
|
|
90
91
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
91
92
|
const { data, error } = await Promise.race([client.from('action_approvals')
|
|
92
93
|
.select('status, decided_via').eq('id', result.approval_id).eq('project_id', projectId).maybeSingle(), stopped]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
3
3
|
/**
|
|
4
4
|
* Server integration only. The transport creates this closure for one authenticated human
|
|
5
5
|
* interaction and retains it outside the tool registry. Neither its id nor decision is a tool
|
|
@@ -16,7 +16,7 @@ export function createHumanApprovalCallback(binding) {
|
|
|
16
16
|
if (response.decision !== 'approve' && response.decision !== 'reject')
|
|
17
17
|
return false;
|
|
18
18
|
completed = true;
|
|
19
|
-
const source =
|
|
19
|
+
const source = approvalStore();
|
|
20
20
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
21
21
|
const now = new Date().toISOString();
|
|
22
22
|
const { data, error } = await client.from('action_approvals')
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isTesterRequest } from './tester-policy.js';
|
|
2
|
+
import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
|
|
1
3
|
import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
|
|
2
4
|
import { db } from '../db.js';
|
|
3
5
|
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
@@ -6,9 +8,10 @@ const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launc
|
|
|
6
8
|
'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
|
|
7
9
|
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
8
10
|
export async function captureApprovalSnapshot(action, params, projectId) {
|
|
11
|
+
const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
|
|
9
12
|
const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
10
13
|
if (!TARGET_ACTIONS.has(action))
|
|
11
|
-
return snapshot;
|
|
14
|
+
return setup ?? snapshot;
|
|
12
15
|
const source = db();
|
|
13
16
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
14
17
|
const body = params.payload ?? params;
|
|
@@ -98,7 +101,7 @@ export async function captureApprovalSnapshot(action, params, projectId) {
|
|
|
98
101
|
}
|
|
99
102
|
snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
100
103
|
snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
101
|
-
return snapshot;
|
|
104
|
+
return setup ? { ...snapshot, ...setup, recipient_count: snapshot.recipient_count, audience_snapshot: snapshot } : snapshot;
|
|
102
105
|
}
|
|
103
106
|
export function withoutApprovalSnapshot(params) {
|
|
104
107
|
const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isApplyingTesterRequest } from './tester-policy.js';
|
|
1
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
3
|
import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
|
|
3
4
|
import { db } from '../db.js';
|
|
@@ -22,7 +23,7 @@ export async function finishCampaignEdit(action, input, existing, updates, reque
|
|
|
22
23
|
if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
|
|
23
24
|
return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
|
|
24
25
|
}
|
|
25
|
-
if (!triggerChangeNeedsApproval(existing, updates))
|
|
26
|
+
if (isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
|
|
26
27
|
return writeEdit(input.campaign_id, existing, updates);
|
|
27
28
|
const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
|
|
28
29
|
const context = currentChannelContext();
|
|
@@ -23,7 +23,7 @@ export const HOW_TO_RECIPES = [
|
|
|
23
23
|
id: 'send-one-off-email', title: 'Send a one-off email to my list',
|
|
24
24
|
when_to_use: 'Send an announcement or email blast to an audience with human approval.',
|
|
25
25
|
synonyms: TOOL_ALIASES.send_broadcast,
|
|
26
|
-
requires: { scopes: ['write'], tester_ok:
|
|
26
|
+
requires: { scopes: ['write'], tester_ok: false },
|
|
27
27
|
steps: [
|
|
28
28
|
{ n: 1, tool: 'send_broadcast', args_example: { audience: 'subscribers to the intended sender', subject: 'A quick update', html: '<p>Here is the update you requested.</p>' }, approval, note: 'Specify site_id if there is more than one sender. Show the subject, recipient_count, sender, finished email and approve_url; wait for a person to approve.' },
|
|
29
29
|
{ n: 2, tool: 'send_broadcast', args_example: { approval_id: from(1, 'approval_id') }, approval, note: 'Only after human approval. Pass ONLY approval_id; changed content needs a new request.' },
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Campaign setup only. Never infer this permission from a prefix or model arguments. */
|
|
2
|
+
export declare const TESTER_REQUEST_TOOLS: ReadonlySet<string>;
|
|
3
|
+
export declare const TESTER_SITE_FIELDS: ReadonlySet<string>;
|
|
4
|
+
export declare function isTesterRequest(action: string): boolean;
|
|
5
|
+
/** Server-only context set after the shared core has consumed a human-approved payload. */
|
|
6
|
+
export declare function isApplyingTesterRequest(action: string): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { requestContext } from '../db.js';
|
|
2
|
+
/** Campaign setup only. Never infer this permission from a prefix or model arguments. */
|
|
3
|
+
export const TESTER_REQUEST_TOOLS = new Set([
|
|
4
|
+
'update_site', 'update_campaign', 'set_campaign_trigger', 'create_campaign',
|
|
5
|
+
'create_campaign_with_content', 'add_campaign_step', 'update_campaign_step',
|
|
6
|
+
]);
|
|
7
|
+
export const TESTER_SITE_FIELDS = new Set([
|
|
8
|
+
'unsub_link_enabled', 'unsub_one_click_enabled', 'unsub_custom_url',
|
|
9
|
+
'unsub_footer_template', 'unsub_custom_message', 'unsub_brand_logo_url', 'unsub_brand_color',
|
|
10
|
+
]);
|
|
11
|
+
export function isTesterRequest(action) {
|
|
12
|
+
const ctx = requestContext();
|
|
13
|
+
return !!ctx?.scopes?.includes('tester') && !ctx.scopes.includes('agent') && TESTER_REQUEST_TOOLS.has(action);
|
|
14
|
+
}
|
|
15
|
+
/** Server-only context set after the shared core has consumed a human-approved payload. */
|
|
16
|
+
export function isApplyingTesterRequest(action) {
|
|
17
|
+
return isTesterRequest(action) && requestContext()?.approvedTesterAction === action;
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { requestContext } from '../db.js';
|
|
2
|
+
import { getProjectId } from '../project.js';
|
|
3
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
4
|
+
/** Central audit infrastructure, including when the edited site belongs to a BYOD project. */
|
|
5
|
+
export async function logTesterRequestAudit(event, action, approvalId, approver) {
|
|
6
|
+
const ctx = requestContext();
|
|
7
|
+
const { error } = await approvalStore().from('audit_log').insert({
|
|
8
|
+
project_id: getProjectId(), actor_type: ctx?.actorType ?? 'mcp', actor_id: ctx?.actorId ?? null,
|
|
9
|
+
action: `mcp_connection.${event}`, resource_type: 'mcp_connection', resource_id: ctx?.oauthTokenId,
|
|
10
|
+
channel: ctx?.channel ?? 'mcp',
|
|
11
|
+
details: { connection_id: ctx?.oauthTokenId, approval_id: approvalId, tool: action, approver },
|
|
12
|
+
});
|
|
13
|
+
if (error)
|
|
14
|
+
throw new Error(`Failed to audit tester ${event}: ${error.message}`);
|
|
15
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readAllRows } from '@sendinel/domain';
|
|
2
|
+
import { db, requestContext } from '../db.js';
|
|
3
|
+
/** Setup targets and before values, frozen by the same hash and revalidation as sending approvals. */
|
|
4
|
+
export async function captureTesterRequestSnapshot(action, params, projectId) {
|
|
5
|
+
const source = db();
|
|
6
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
7
|
+
const snapshot = { version: 1, tester_connection_id: requestContext()?.oauthTokenId,
|
|
8
|
+
campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
9
|
+
let campaignId = params.campaign_id;
|
|
10
|
+
if (action === 'update_campaign_step') {
|
|
11
|
+
const { data, error } = await client.from('campaign_steps').select('*').eq('id', params.step_id).maybeSingle();
|
|
12
|
+
if (error || !data)
|
|
13
|
+
throw new Error('Approval step unavailable');
|
|
14
|
+
campaignId = data.campaign_id;
|
|
15
|
+
}
|
|
16
|
+
if (campaignId) {
|
|
17
|
+
const { data, error } = await client.from('campaigns')
|
|
18
|
+
.select('id, name, description, site_id, segment_id, type, status, topic, send_at, sends_per_hour, send_in_contact_timezone, send_class, requires_approval, unsub_footer_template, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at')
|
|
19
|
+
.eq('id', campaignId).eq('project_id', projectId).maybeSingle();
|
|
20
|
+
if (error || !data)
|
|
21
|
+
throw new Error('Approval campaign unavailable');
|
|
22
|
+
snapshot.campaigns = [data];
|
|
23
|
+
snapshot.steps = await readAllRows(client.from('campaign_steps').select('*').eq('campaign_id', campaignId));
|
|
24
|
+
snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
25
|
+
if (params.step_id && !snapshot.steps.some((step) => step.id === params.step_id))
|
|
26
|
+
throw new Error('Approval step unavailable');
|
|
27
|
+
}
|
|
28
|
+
const siteIds = [...new Set([params.site_id, ...snapshot.campaigns.map((campaign) => campaign.site_id)]
|
|
29
|
+
.filter((id) => typeof id === 'string'))].sort();
|
|
30
|
+
for (const siteId of siteIds) {
|
|
31
|
+
const { data, error } = await client.from('sites')
|
|
32
|
+
.select('id, name, from_name, from_email, sending_domain, 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, updated_at')
|
|
33
|
+
.eq('id', siteId).eq('project_id', projectId).maybeSingle();
|
|
34
|
+
if (error || !data)
|
|
35
|
+
throw new Error('Approval site unavailable');
|
|
36
|
+
snapshot.sites.push(data);
|
|
37
|
+
}
|
|
38
|
+
if (params.segment_id) {
|
|
39
|
+
const { data, error } = await client.from('segments').select('id, name, rules, site_id')
|
|
40
|
+
.eq('id', params.segment_id).eq('project_id', projectId).maybeSingle();
|
|
41
|
+
if (error || !data)
|
|
42
|
+
throw new Error('Approval segment unavailable');
|
|
43
|
+
snapshot.segment = data;
|
|
44
|
+
}
|
|
45
|
+
return snapshot;
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { adminDb, db, requestContext } from '../db.js';
|
|
2
|
+
/** Approval infrastructure stays central, including when update_site uses a BYOD client. */
|
|
3
|
+
export function approvalStore() {
|
|
4
|
+
if (!requestContext()?.scopes?.includes('tester'))
|
|
5
|
+
return db();
|
|
6
|
+
const source = adminDb();
|
|
7
|
+
return typeof source.schema === 'function' ? source.schema('email') : source;
|
|
8
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Only installed for an authenticated tester connection, never selected by tool arguments. */
|
|
2
|
+
export declare function testerRequestTool(action: string, config: any, handler: (...args: any[]) => any): {
|
|
3
|
+
config: any;
|
|
4
|
+
handler: (input: Record<string, unknown>, extra?: unknown) => Promise<any>;
|
|
5
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { requestContext, runWithApprovedTesterRequest } from '../db.js';
|
|
4
|
+
import { getProjectId } from '../project.js';
|
|
5
|
+
import { withApproval } from './action-approvals.js';
|
|
6
|
+
import { withoutApprovalSnapshot } from './approval-snapshot.js';
|
|
7
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
8
|
+
import { logTesterRequestAudit } from './tester-request-audit.js';
|
|
9
|
+
import { TESTER_SITE_FIELDS } from './tester-policy.js';
|
|
10
|
+
const text = (message) => ({ content: [{ type: 'text', text: message }] });
|
|
11
|
+
/** Only installed for an authenticated tester connection, never selected by tool arguments. */
|
|
12
|
+
export function testerRequestTool(action, config, handler) {
|
|
13
|
+
const original = z.object(config.inputSchema);
|
|
14
|
+
// Wrapping before optional prevents Zod defaults from populating an approval-id-only retry.
|
|
15
|
+
const inputSchema = Object.fromEntries(Object.entries(config.inputSchema).map(([key, schema]) => [key, z.preprocess(value => value, schema).optional()]));
|
|
16
|
+
inputSchema.approval_id = z.string().uuid().optional();
|
|
17
|
+
return {
|
|
18
|
+
config: { ...config, inputSchema },
|
|
19
|
+
handler: async (input, extra) => {
|
|
20
|
+
if (!requestContext()?.oauthTokenId)
|
|
21
|
+
return text('Tester setup requires an authenticated connection id.');
|
|
22
|
+
const { approval_id, ...supplied } = input;
|
|
23
|
+
let params;
|
|
24
|
+
if (approval_id) {
|
|
25
|
+
const { data, error } = await approvalStore().from('action_approvals').select('*')
|
|
26
|
+
.eq('id', approval_id).eq('project_id', getProjectId()).maybeSingle();
|
|
27
|
+
if (error || !data || data.action !== action || data.oauth_token_id !== requestContext()?.oauthTokenId) {
|
|
28
|
+
return text('Approval not found for this action and tester connection.');
|
|
29
|
+
}
|
|
30
|
+
params = withoutApprovalSnapshot(data.params);
|
|
31
|
+
for (const [key, value] of Object.entries(supplied)) {
|
|
32
|
+
if (value !== undefined && !isDeepStrictEqual(value, params[key]))
|
|
33
|
+
return text('params_mismatch: request differs from the frozen payload.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const parsed = original.safeParse(supplied);
|
|
38
|
+
if (!parsed.success)
|
|
39
|
+
return text(`Invalid request: ${parsed.error.message}`);
|
|
40
|
+
params = parsed.data;
|
|
41
|
+
delete params.approval_id;
|
|
42
|
+
// This human approval replaces the compound tool's model confirmation preview.
|
|
43
|
+
if (action === 'create_campaign_with_content')
|
|
44
|
+
params.confirmed = true;
|
|
45
|
+
}
|
|
46
|
+
if (action === 'update_site' && Object.keys(params).some(key => key !== 'site_id' && params[key] !== undefined && !TESTER_SITE_FIELDS.has(key))) {
|
|
47
|
+
return text('Tester connections may request only site footer and unsubscribe settings.');
|
|
48
|
+
}
|
|
49
|
+
return withApproval(action, async (frozen) => {
|
|
50
|
+
const result = await runWithApprovedTesterRequest(action, () => handler(frozen, extra));
|
|
51
|
+
// All seven setup tools report a successful apply as a resource id or an explicit ok.
|
|
52
|
+
let value;
|
|
53
|
+
try {
|
|
54
|
+
value = JSON.parse(result.content.filter((block) => block.type === 'text')
|
|
55
|
+
.map((block) => block.text).join('\n'));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
if (!result.isError && !value.error && (value.id || value.campaign_id || value.ok === true)) {
|
|
61
|
+
const { data, error } = await approvalStore().from('action_approvals').select('decided_by')
|
|
62
|
+
.eq('id', approval_id).eq('project_id', getProjectId()).maybeSingle();
|
|
63
|
+
if (error || !data?.decided_by)
|
|
64
|
+
throw new Error('Applied tester request has no recorded approver');
|
|
65
|
+
await logTesterRequestAudit('tester_applied', action, String(approval_id), data.decided_by);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
})({ ...params, approval_id: approval_id }, extra);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/build/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
|
|
2
3
|
export interface ToolRegistrationOptions {
|
|
3
4
|
scopes?: string[];
|
|
4
5
|
toolGroups?: string[];
|
|
@@ -19,7 +20,7 @@ export declare function isAgentConnection(scopes: readonly string[] | null | und
|
|
|
19
20
|
export declare function agentConnectionAllowsTool(name: string): boolean;
|
|
20
21
|
export declare function agentConnectionRefusal(name: string): string;
|
|
21
22
|
export declare function isTesterConnection(scopes: readonly string[] | null | undefined): boolean;
|
|
22
|
-
/**
|
|
23
|
+
/** Tester reads, explicit setup requests and the existing campaign activation paths. */
|
|
23
24
|
export declare function testerConnectionAllowsTool(name: string): boolean;
|
|
24
25
|
/**
|
|
25
26
|
* Tools whose own input already has a top-level `project_id` that means something other than "the project
|
|
@@ -31,7 +32,7 @@ export declare const PROJECT_ID_NATIVE_TOOLS: ReadonlySet<string>;
|
|
|
31
32
|
* Tools that need a stated project when the connection is switched away from its home project (#4033):
|
|
32
33
|
* anything that goes through a person's approval, plus the two always-gated actions.
|
|
33
34
|
*/
|
|
34
|
-
export declare function isProjectPinnedWriteTool(name: string): boolean;
|
|
35
|
+
export declare function isProjectPinnedWriteTool(name: string, scopes?: readonly string[]): boolean;
|
|
35
36
|
export declare function testerConnectionRefusal(name: string): string;
|
|
36
37
|
export declare const GATEWAY_TOOL_NAMES: string[];
|
|
37
38
|
/**
|
|
@@ -46,4 +47,3 @@ export declare function getRegisteredToolGroups(options?: ToolRegistrationOption
|
|
|
46
47
|
*/
|
|
47
48
|
export declare const SENDINEL_SERVER_INSTRUCTIONS: string;
|
|
48
49
|
export declare function registerAllTools(server: McpServer, options?: ToolRegistrationOptions): void;
|
|
49
|
-
export {};
|
package/build/server.js
CHANGED
|
@@ -2,6 +2,9 @@ import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
|
|
|
2
2
|
import { installApprovalApp } from './lib/approval-app.js';
|
|
3
3
|
import { runWithApprovalElicitation } from './lib/approval-elicitation.js';
|
|
4
4
|
import { runWithToolContext } from './db.js';
|
|
5
|
+
import { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
|
|
6
|
+
import { testerRequestTool } from './lib/tester-requests.js';
|
|
7
|
+
export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
|
|
5
8
|
import { isApprovalGatedAction } from './lib/action-approvals.js';
|
|
6
9
|
import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
|
|
7
10
|
import { registerAnalyticsTools } from './tools/analytics.js';
|
|
@@ -203,14 +206,14 @@ export function agentConnectionRefusal(name) {
|
|
|
203
206
|
export function isTesterConnection(scopes) {
|
|
204
207
|
return !!scopes?.includes(TESTER_CONNECTION_SCOPE);
|
|
205
208
|
}
|
|
206
|
-
/**
|
|
209
|
+
/** Tester reads, explicit setup requests and the existing campaign activation paths. */
|
|
207
210
|
export function testerConnectionAllowsTool(name) {
|
|
208
|
-
|
|
209
|
-
|
|
211
|
+
// Retain the existing campaign activation path. A globally gated tool is not by itself
|
|
212
|
+
// permission for a tester: sends, deletes and access/infrastructure writes stay refused.
|
|
210
213
|
return getRequiredScope(name) === 'read'
|
|
211
|
-
|| name
|
|
214
|
+
|| TESTER_REQUEST_TOOLS.has(name)
|
|
212
215
|
|| name === 'activate_campaigns'
|
|
213
|
-
||
|
|
216
|
+
|| name === 'update_campaign_status';
|
|
214
217
|
}
|
|
215
218
|
/**
|
|
216
219
|
* Tools whose own input already has a top-level `project_id` that means something other than "the project
|
|
@@ -222,11 +225,11 @@ export const PROJECT_ID_NATIVE_TOOLS = new Set(['set_active_project', 'get_outbo
|
|
|
222
225
|
* Tools that need a stated project when the connection is switched away from its home project (#4033):
|
|
223
226
|
* anything that goes through a person's approval, plus the two always-gated actions.
|
|
224
227
|
*/
|
|
225
|
-
export function isProjectPinnedWriteTool(name) {
|
|
226
|
-
return name === 'send_broadcast' || name === 'activate_campaigns' || isApprovalGatedAction(name);
|
|
228
|
+
export function isProjectPinnedWriteTool(name, scopes) {
|
|
229
|
+
return (isTesterConnection(scopes) && TESTER_REQUEST_TOOLS.has(name)) || name === 'send_broadcast' || name === 'activate_campaigns' || isApprovalGatedAction(name);
|
|
227
230
|
}
|
|
228
231
|
export function testerConnectionRefusal(name) {
|
|
229
|
-
return `"${name}" is not available to a tester connection.
|
|
232
|
+
return `"${name}" is not available to a tester connection. Testers can read and request human approval for campaign setup: ${[...TESTER_REQUEST_TOOLS].join(', ')}. update_site is limited to footer/unsubscribe fields. Every setup call returns approval_id, approve_url and preview_text; after a person approves, retry with only approval_id. Deletes, sends, access/role/key/team tools, provider credentials, imports and create_agent_mailbox are refused. Ask the workspace owner to promote the connection in Dashboard > Agent Connect for direct writes.`;
|
|
230
233
|
}
|
|
231
234
|
// Names of the meta-tools gateway mode registers in place of the real catalog
|
|
232
235
|
// (see tools/gateway.ts). Grouped as 'gateway' rather than any real tool
|
|
@@ -350,13 +353,18 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
|
350
353
|
// Don't register tools the key can't access — they won't appear in tool list
|
|
351
354
|
return;
|
|
352
355
|
}
|
|
356
|
+
if (tester && !agent && TESTER_REQUEST_TOOLS.has(name)) {
|
|
357
|
+
const wrapped = testerRequestTool(name, config, handler);
|
|
358
|
+
config = wrapped.config;
|
|
359
|
+
handler = wrapped.handler;
|
|
360
|
+
}
|
|
353
361
|
// Wrap handler with runtime scope check (defense in depth)
|
|
354
362
|
const guardedHandler = (...args) => {
|
|
355
363
|
if (!allowed()) {
|
|
356
364
|
const text = agent ? agentConnectionRefusal(name) : tester ? testerConnectionRefusal(name) : `Forbidden: this action requires "${required}" scope.`;
|
|
357
365
|
return { content: [{ type: 'text', text }] };
|
|
358
366
|
}
|
|
359
|
-
return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)));
|
|
367
|
+
return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)), scopes);
|
|
360
368
|
};
|
|
361
369
|
return target.registerTool(name, config, guardedHandler);
|
|
362
370
|
}
|
|
@@ -382,7 +390,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
|
|
|
382
390
|
export function registerAllTools(server, options) {
|
|
383
391
|
if (server.server)
|
|
384
392
|
installApprovalCapabilityProbe(server.server);
|
|
385
|
-
|
|
393
|
+
// Registry callers pass scopes only: the hosted decision bridge is never enabled from a catalog.
|
|
394
|
+
server = installApprovalApp(server, { scopes: options?.scopes }, isApprovalGatedAction);
|
|
386
395
|
const rawScopes = options?.scopes ?? [];
|
|
387
396
|
if (rawScopes.includes('gateway')) {
|
|
388
397
|
const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.111",
|
|
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",
|