@sendinel/mcp-server 1.0.119 → 1.0.121
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/lib/action-approvals.js +5 -1
- package/build/lib/approval-app.js +5 -6
- package/build/lib/approval-elicitation-relay.d.ts +19 -0
- package/build/lib/approval-elicitation-relay.js +124 -0
- package/build/lib/approval-elicitation.d.ts +13 -3
- package/build/lib/approval-elicitation.js +16 -9
- package/build/lib/approval-human-callback.js +5 -3
- package/build/lib/approval-human-eligibility.d.ts +13 -0
- package/build/lib/approval-human-eligibility.js +39 -0
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
1
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
3
|
import { GATED_ACTIONS, PROTECTED_ACTIONS } from './approval-actions.js';
|
|
3
4
|
export { isApprovalGatedAction } from './approval-actions.js';
|
|
@@ -363,7 +364,10 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
363
364
|
if (data.used_at || data.status === 'used')
|
|
364
365
|
return { approved: false, status: 'consumed' };
|
|
365
366
|
if (data.status === 'approved' && (!isHumanApprovalChannel(data.decided_via) ||
|
|
366
|
-
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard'
|
|
367
|
+
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
|
|
368
|
+
(!['mcp_app', 'mcp_elicitation'].includes(data.decided_via) ||
|
|
369
|
+
!await humanApprovalEligible(data.action, getProjectId(), { projectId: getProjectId(),
|
|
370
|
+
actorId: data.decided_by, oauthTokenId: data.oauth_token_id, authKind: 'oauth', channelVerified: true }))))) {
|
|
367
371
|
return { approved: false, status: 'human_decision_required' };
|
|
368
372
|
}
|
|
369
373
|
if (data.status !== 'approved' || !options.consume) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { buildApprovalPreview } from '@sendinel/domain';
|
|
4
4
|
import { APPROVAL_APP, APPROVAL_APP_SESSION_META } from './approval-app-conventions.js';
|
|
@@ -66,11 +66,10 @@ function live(approval) {
|
|
|
66
66
|
*/
|
|
67
67
|
export async function offerApprovalApp(approval) {
|
|
68
68
|
const runtime = calls.getStore(), actor = identity();
|
|
69
|
-
if (isHumanRequestableAdminTool(approval.action))
|
|
70
|
-
return {};
|
|
71
69
|
if (!runtime || !runtime.canDecide || !actor || !approvalAppEnabled() || approval.tier === 'protected' || !live(approval))
|
|
72
70
|
return {};
|
|
73
|
-
if (await resolveApprovalChannel(runtime.server.server) !== 'mcp_app'
|
|
71
|
+
if (await resolveApprovalChannel(runtime.server.server) !== 'mcp_app' ||
|
|
72
|
+
!await humanApprovalEligible(approval.action, actor.projectId))
|
|
74
73
|
return {};
|
|
75
74
|
return { _meta: { ui: { [APPROVAL_APP.resultUriKey]: resourceUri(approval.id) } } };
|
|
76
75
|
}
|
|
@@ -95,7 +94,7 @@ export function installApprovalApp(server, options = {}, approvalTool = () => fa
|
|
|
95
94
|
if (!bound.canDecide || !actor || !approvalAppEnabled() || await resolveApprovalChannel(server.server) !== 'mcp_app')
|
|
96
95
|
throw unavailable();
|
|
97
96
|
const approval = await loadOwnedPendingApproval(id, actor);
|
|
98
|
-
if (!approval ||
|
|
97
|
+
if (!approval || !await humanApprovalEligible(approval.action, actor.projectId))
|
|
99
98
|
throw unavailable();
|
|
100
99
|
// One outstanding render session per approval. Re-render invalidates the old view.
|
|
101
100
|
const token = await mintApprovalAppSession(approval, actor);
|
|
@@ -124,7 +123,7 @@ export function installApprovalApp(server, options = {}, approvalTool = () => fa
|
|
|
124
123
|
return refused();
|
|
125
124
|
const approval = await loadOwnedPendingApproval(approval_id, actor);
|
|
126
125
|
// Single guarded claim before the callback, so concurrent clicks cannot reuse a session.
|
|
127
|
-
if (!approval ||
|
|
126
|
+
if (!approval || !await humanApprovalEligible(approval.action, actor.projectId) || !await claimApprovalAppSession(app_session, approval, actor))
|
|
128
127
|
return refused();
|
|
129
128
|
const connectionId = `oauth:${actor.oauthTokenId}`;
|
|
130
129
|
const callback = createHumanApprovalCallback({ approvalId: approval.id, projectId: actor.projectId,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ElicitRequest, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
|
|
3
|
+
export declare function isElicitationResponse(body: unknown): boolean;
|
|
4
|
+
/** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
|
|
5
|
+
* No decision can enter this path via a tools/call, model text, or nested tool arguments.
|
|
6
|
+
*/
|
|
7
|
+
export declare function recordElicitationResponse(body: unknown, identity: HumanDecisionIdentity): Promise<void>;
|
|
8
|
+
/** Insert before sending, using the exact wire id (a UUID, since each stateless SDK starts
|
|
9
|
+
* its request counter at zero). Poll the persisted prompt; never await a response on the old
|
|
10
|
+
* SDK Server, because the client's answer necessarily arrives at a fresh Server via POST.
|
|
11
|
+
*/
|
|
12
|
+
export declare function relayElicitation(options: {
|
|
13
|
+
approvalId: string;
|
|
14
|
+
action: string;
|
|
15
|
+
identity: HumanDecisionIdentity;
|
|
16
|
+
params: ElicitRequest['params'];
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
send(message: JSONRPCRequest): Promise<void>;
|
|
19
|
+
}): Promise<'approved' | 'rejected' | 'decision_pending'>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
3
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
4
|
+
const TABLE = 'mcp_elicitation_prompts';
|
|
5
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
6
|
+
function email() {
|
|
7
|
+
const source = approvalStore();
|
|
8
|
+
return typeof source.schema === 'function' ? source.schema('email') : source;
|
|
9
|
+
}
|
|
10
|
+
export function isElicitationResponse(body) {
|
|
11
|
+
const message = object(body);
|
|
12
|
+
return !!message && !('method' in message) && 'id' in message && ('result' in message || 'error' in message);
|
|
13
|
+
}
|
|
14
|
+
/** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
|
|
15
|
+
* No decision can enter this path via a tools/call, model text, or nested tool arguments.
|
|
16
|
+
*/
|
|
17
|
+
export async function recordElicitationResponse(body, identity) {
|
|
18
|
+
const message = object(body);
|
|
19
|
+
if (!isElicitationResponse(message) || message?.jsonrpc !== '2.0' ||
|
|
20
|
+
(typeof message.id !== 'string' && typeof message.id !== 'number') ||
|
|
21
|
+
('error' in message) || !identity.oauthTokenId || !identity.actorId)
|
|
22
|
+
return;
|
|
23
|
+
const result = object(message.result), content = object(result?.content);
|
|
24
|
+
if (result?.action !== 'accept' || (content?.decision !== 'approve' && content?.decision !== 'reject'))
|
|
25
|
+
return;
|
|
26
|
+
try {
|
|
27
|
+
const client = email(), now = new Date().toISOString();
|
|
28
|
+
const { data: prompt, error } = await client.from(TABLE).select('*')
|
|
29
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('jsonrpc_request_id', String(message.id))
|
|
30
|
+
.eq('status', 'pending').gt('expires_at', now).maybeSingle();
|
|
31
|
+
if (error || !prompt || prompt.oauth_token_id !== identity.oauthTokenId || prompt.user_id !== identity.actorId ||
|
|
32
|
+
prompt.jsonrpc_request_id !== String(message.id) || prompt.status !== 'pending' ||
|
|
33
|
+
!(Date.parse(prompt.expires_at) > Date.now()) || prompt.channel !== 'mcp_elicitation')
|
|
34
|
+
return;
|
|
35
|
+
const { data: approval, error: approvalError } = await client.from('action_approvals')
|
|
36
|
+
.select('id, project_id, oauth_token_id, action, tier, status, used_at, expires_at')
|
|
37
|
+
.eq('id', prompt.approval_id).eq('project_id', prompt.project_id)
|
|
38
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('status', 'pending').is('used_at', null)
|
|
39
|
+
.gt('expires_at', now).maybeSingle();
|
|
40
|
+
if (approvalError || !approval || approval.id !== prompt.approval_id || approval.project_id !== prompt.project_id ||
|
|
41
|
+
approval.oauth_token_id !== prompt.oauth_token_id || approval.status !== 'pending' || approval.used_at != null ||
|
|
42
|
+
approval.tier !== 'gated' || typeof approval.action !== 'string' || !approval.action ||
|
|
43
|
+
!(Date.parse(approval.expires_at) > Date.now()))
|
|
44
|
+
return;
|
|
45
|
+
// All-project grants' answer POSTs have no project argument. The authenticated token/user
|
|
46
|
+
// must match the stored prompt; authorize against THAT approval's project, never a body field.
|
|
47
|
+
if (!await humanApprovalEligible(approval.action, prompt.project_id, { ...identity, projectId: prompt.project_id }))
|
|
48
|
+
return;
|
|
49
|
+
const { data, error: rpcError } = await client.rpc('record_elicitation_decision', {
|
|
50
|
+
p_prompt_id: prompt.id, p_oauth_token_id: identity.oauthTokenId, p_user_id: identity.actorId,
|
|
51
|
+
p_decision: content.decision, p_project_id: prompt.project_id, p_action: approval.action,
|
|
52
|
+
});
|
|
53
|
+
if (rpcError || data !== true)
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Never reveal whether a prompt exists, and never fall back to non-atomic REST writes.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Insert before sending, using the exact wire id (a UUID, since each stateless SDK starts
|
|
61
|
+
* its request counter at zero). Poll the persisted prompt; never await a response on the old
|
|
62
|
+
* SDK Server, because the client's answer necessarily arrives at a fresh Server via POST.
|
|
63
|
+
*/
|
|
64
|
+
export async function relayElicitation(options) {
|
|
65
|
+
const pending = 'decision_pending';
|
|
66
|
+
const { identity, signal } = options;
|
|
67
|
+
if (signal.aborted || !identity.projectId || !identity.oauthTokenId || !identity.actorId ||
|
|
68
|
+
!await humanApprovalEligible(options.action, identity.projectId, identity))
|
|
69
|
+
return pending;
|
|
70
|
+
const client = email(), now = Date.now(), requestId = randomUUID();
|
|
71
|
+
const promptId = randomUUID();
|
|
72
|
+
try {
|
|
73
|
+
const { error: expireError } = await client.from(TABLE).update({ status: 'expired' })
|
|
74
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('status', 'pending').lt('expires_at', new Date(now).toISOString());
|
|
75
|
+
if (expireError)
|
|
76
|
+
return pending;
|
|
77
|
+
// Bounded per-token purge; an old backlog is drained in chunks, never one unbounded delete.
|
|
78
|
+
const { data: old, error: oldError } = await client.from(TABLE).select('id')
|
|
79
|
+
.eq('oauth_token_id', identity.oauthTokenId).lt('created_at', new Date(now - 3_600_000).toISOString()).limit(100);
|
|
80
|
+
if (oldError)
|
|
81
|
+
return pending;
|
|
82
|
+
if (old?.length) {
|
|
83
|
+
const { error: deleteError } = await client.from(TABLE).delete().eq('oauth_token_id', identity.oauthTokenId)
|
|
84
|
+
.in('id', old.map((row) => row.id));
|
|
85
|
+
if (deleteError)
|
|
86
|
+
return pending;
|
|
87
|
+
}
|
|
88
|
+
const { data: approval, error: approvalError } = await client.from('action_approvals')
|
|
89
|
+
.select('id, expires_at').eq('id', options.approvalId).eq('project_id', identity.projectId)
|
|
90
|
+
.eq('oauth_token_id', identity.oauthTokenId).eq('action', options.action)
|
|
91
|
+
.eq('tier', 'gated').eq('status', 'pending').is('used_at', null).gt('expires_at', new Date(now).toISOString()).maybeSingle();
|
|
92
|
+
if (approvalError || !approval || !(Date.parse(approval.expires_at) > Date.now()) || signal.aborted)
|
|
93
|
+
return pending;
|
|
94
|
+
const { error } = await client.from(TABLE).insert({ id: promptId, approval_id: options.approvalId,
|
|
95
|
+
project_id: identity.projectId, oauth_token_id: identity.oauthTokenId, user_id: identity.actorId,
|
|
96
|
+
jsonrpc_request_id: requestId, channel: 'mcp_elicitation', status: 'pending',
|
|
97
|
+
created_at: new Date(now).toISOString(), expires_at: new Date(Math.min(now + 120_000, Date.parse(approval.expires_at))).toISOString() });
|
|
98
|
+
if (error || signal.aborted)
|
|
99
|
+
return pending;
|
|
100
|
+
// The id is created here and passed unchanged both to the row and the actual transport.
|
|
101
|
+
await options.send({ jsonrpc: '2.0', id: requestId, method: 'elicitation/create', params: options.params });
|
|
102
|
+
while (!signal.aborted && Date.now() < now + 60_000) {
|
|
103
|
+
const { data: prompt, error: readError } = await client.from(TABLE).select('status, answer, expires_at')
|
|
104
|
+
.eq('id', promptId).eq('oauth_token_id', identity.oauthTokenId).eq('user_id', identity.actorId).maybeSingle();
|
|
105
|
+
if (signal.aborted || readError || !prompt || !(Date.parse(prompt.expires_at) > Date.now()))
|
|
106
|
+
return pending;
|
|
107
|
+
if (prompt.status === 'answered')
|
|
108
|
+
return prompt.answer === 'approve' ? 'approved' : prompt.answer === 'reject' ? 'rejected' : pending;
|
|
109
|
+
if (prompt.status !== 'pending')
|
|
110
|
+
return pending;
|
|
111
|
+
await new Promise(resolve => {
|
|
112
|
+
const timer = setTimeout(done, 750);
|
|
113
|
+
function done() { clearTimeout(timer); signal.removeEventListener('abort', done); resolve(); }
|
|
114
|
+
signal.addEventListener('abort', done, { once: true });
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
done();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// Refused inserts/updates, missing migrations and disconnected streams keep approve_url.
|
|
122
|
+
}
|
|
123
|
+
return pending;
|
|
124
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
|
|
2
|
+
import type { ElicitRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
1
3
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
4
|
import type { RequestId } from '@modelcontextprotocol/sdk/types.js';
|
|
3
5
|
type Decision = 'approve' | 'reject';
|
|
@@ -10,9 +12,17 @@ type HumanCallback = {
|
|
|
10
12
|
}): Promise<boolean>;
|
|
11
13
|
};
|
|
12
14
|
/** Injected at authenticated transport startup, never taken from tool arguments. */
|
|
13
|
-
type TransportBinding = {
|
|
15
|
+
export type TransportBinding = {
|
|
14
16
|
projectId: string;
|
|
15
17
|
userId?: string | null;
|
|
18
|
+
oauthTokenId?: string | null;
|
|
19
|
+
identity?: HumanDecisionIdentity;
|
|
20
|
+
relay?(options: {
|
|
21
|
+
approvalId: string;
|
|
22
|
+
action: string;
|
|
23
|
+
params: ElicitRequest['params'];
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
}): Promise<ElicitationDecision>;
|
|
16
26
|
createHumanDecision(binding: {
|
|
17
27
|
approvalId: string;
|
|
18
28
|
projectId: string;
|
|
@@ -25,9 +35,9 @@ type RequestExtra = {
|
|
|
25
35
|
requestId?: RequestId;
|
|
26
36
|
signal?: AbortSignal;
|
|
27
37
|
};
|
|
28
|
-
export declare function installApprovalElicitation(server: McpServer, binding: TransportBinding): void;
|
|
38
|
+
export declare function installApprovalElicitation(server: Pick<McpServer, 'server'>, binding: TransportBinding): void;
|
|
29
39
|
/** Called by the registry wrapper with SDK-owned extra, outside parsed model arguments. */
|
|
30
|
-
export declare function runWithApprovalElicitation<T>(server: McpServer, extra: RequestExtra | undefined, run: () => T): T;
|
|
40
|
+
export declare function runWithApprovalElicitation<T>(server: Pick<McpServer, 'server'>, extra: RequestExtra | undefined, run: () => T): T;
|
|
31
41
|
type ApprovalFallback = {
|
|
32
42
|
approval_id: string;
|
|
33
43
|
approve_url: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
2
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
2
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
|
|
@@ -24,8 +25,6 @@ export function runWithApprovalElicitation(server, extra, run) {
|
|
|
24
25
|
/** All outcomes retain the ordinary approval link/text. Approval never applies the action. */
|
|
25
26
|
export async function elicitApproval(result, frozen, projectId) {
|
|
26
27
|
const pending = { ...result, decision_status: 'decision_pending' };
|
|
27
|
-
if (isHumanRequestableAdminTool(frozen.action))
|
|
28
|
-
return pending;
|
|
29
28
|
const context = requests.getStore();
|
|
30
29
|
if (!context || context.projectId !== projectId || result.tier !== 'gated')
|
|
31
30
|
return pending;
|
|
@@ -36,7 +35,11 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
36
35
|
channel = 'mcp_elicitation_url';
|
|
37
36
|
if (channel !== 'mcp_elicitation_form' && channel !== 'mcp_elicitation_url')
|
|
38
37
|
return pending;
|
|
39
|
-
if (channel === 'mcp_elicitation_form' && !context.userId
|
|
38
|
+
if (channel === 'mcp_elicitation_form' && (!context.userId ||
|
|
39
|
+
!await humanApprovalEligible(frozen.action, projectId, context.identity)))
|
|
40
|
+
return pending;
|
|
41
|
+
if (channel === 'mcp_elicitation_url' && isHumanRequestableAdminTool(frozen.action) &&
|
|
42
|
+
!await humanApprovalEligible(frozen.action, projectId, context.identity))
|
|
40
43
|
return pending;
|
|
41
44
|
if (context.signal?.aborted)
|
|
42
45
|
return pending;
|
|
@@ -61,14 +64,18 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
61
64
|
`Recipients: ${preview.recipient_count ?? 'unknown'}`, `Footer: ${short(preview.footer_text || 'unknown', 500)}`,
|
|
62
65
|
`Body excerpt:\n${short(preview.body_text, 1500)}`, `Full preview: ${result.approve_url}`].join('\n');
|
|
63
66
|
if (channel === 'mcp_elicitation_form') {
|
|
67
|
+
const params = { mode: 'form', message, requestedSchema: { type: 'object', properties: {
|
|
68
|
+
decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
|
|
69
|
+
note: { type: 'string', title: 'Optional note', maxLength: 500 },
|
|
70
|
+
}, required: ['decision'] } };
|
|
71
|
+
if (context.relay) {
|
|
72
|
+
const status = await Promise.race([context.relay({ approvalId: result.approval_id,
|
|
73
|
+
action: frozen.action, params, signal: controller.signal }), stopped]);
|
|
74
|
+
return controller.signal.aborted ? pending : { ...result, decision_status: status };
|
|
75
|
+
}
|
|
64
76
|
const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
|
|
65
77
|
connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
|
|
66
|
-
const response = await Promise.race([context.server.server.elicitInput(
|
|
67
|
-
requestedSchema: { type: 'object', properties: {
|
|
68
|
-
decision: { type: 'string', enum: ['approve', 'reject'], title: 'Decision' },
|
|
69
|
-
note: { type: 'string', title: 'Optional note', maxLength: 500 },
|
|
70
|
-
}, required: ['decision'] },
|
|
71
|
-
}, options), stopped]);
|
|
78
|
+
const response = await Promise.race([context.server.server.elicitInput(params, options), stopped]);
|
|
72
79
|
if (controller.signal.aborted)
|
|
73
80
|
return pending;
|
|
74
81
|
// Decline/cancel are not explicit rejections. The dashboard link remains usable.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
2
|
+
import { requestContext } from '../db.js';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { approvalStore } from './tester-requests-store.js';
|
|
4
5
|
/**
|
|
@@ -19,10 +20,11 @@ export function createHumanApprovalCallback(binding) {
|
|
|
19
20
|
completed = true;
|
|
20
21
|
const source = approvalStore();
|
|
21
22
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
22
|
-
// #4308 / #4214: admin requests can only be decided in a human dashboard session.
|
|
23
23
|
const { data: approval, error: loadError } = await client.from('action_approvals')
|
|
24
24
|
.select('action').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
|
|
25
|
-
if (loadError || typeof approval?.action !== 'string' || !approval.action ||
|
|
25
|
+
if (loadError || typeof approval?.action !== 'string' || !approval.action ||
|
|
26
|
+
requestContext()?.actorId !== binding.userId ||
|
|
27
|
+
!await humanApprovalEligible(approval.action, binding.projectId))
|
|
26
28
|
return false;
|
|
27
29
|
const now = new Date().toISOString();
|
|
28
30
|
const { data, error } = await client.from('action_approvals')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type HumanDecisionIdentity = {
|
|
2
|
+
projectId?: string;
|
|
3
|
+
actorId?: string | null;
|
|
4
|
+
oauthTokenId?: string | null;
|
|
5
|
+
authKind?: string | null;
|
|
6
|
+
channelVerified?: boolean;
|
|
7
|
+
scopes?: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
/** Shared transport boundary, never populated from tool arguments. Re-read the grant and
|
|
10
|
+
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
11
|
+
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
12
|
+
*/
|
|
13
|
+
export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined): Promise<boolean>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { requestContext } from '../db.js';
|
|
2
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
3
|
+
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
4
|
+
/** Shared transport boundary, never populated from tool arguments. Re-read the grant and
|
|
5
|
+
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
6
|
+
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
7
|
+
*/
|
|
8
|
+
export async function humanApprovalEligible(action, projectId, identity = requestContext()) {
|
|
9
|
+
if (!identity || identity.channelVerified !== true || identity.authKind !== 'oauth' ||
|
|
10
|
+
!identity.actorId || !identity.oauthTokenId || identity.projectId !== projectId || !projectId ||
|
|
11
|
+
identity.scopes?.some(scope => scope === 'tester' || scope === 'agent'))
|
|
12
|
+
return false;
|
|
13
|
+
try {
|
|
14
|
+
const source = approvalStore();
|
|
15
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
16
|
+
const { data: grant, error } = await client.from('mcp_oauth_tokens')
|
|
17
|
+
.select('id, user_id, connection_role, scopes, revoked_at, expires_at')
|
|
18
|
+
.eq('id', identity.oauthTokenId).maybeSingle();
|
|
19
|
+
if (error || !grant || grant.id !== identity.oauthTokenId || grant.user_id !== identity.actorId ||
|
|
20
|
+
grant.connection_role !== null || grant.revoked_at !== null ||
|
|
21
|
+
!(Date.parse(grant.expires_at) > Date.now()) || !Array.isArray(grant.scopes) ||
|
|
22
|
+
grant.scopes.some((scope) => scope === 'tester' || scope === 'agent'))
|
|
23
|
+
return false;
|
|
24
|
+
if (!isHumanRequestableAdminTool(action))
|
|
25
|
+
return true;
|
|
26
|
+
// Same lookup as dashboard getProjectOrgRole: projects.org_id -> org_members.role.
|
|
27
|
+
// Unlike permissive mocks, PostgREST errors (including {data, error}) grant nothing.
|
|
28
|
+
const { data: project, error: projectError } = await client.from('projects')
|
|
29
|
+
.select('org_id').eq('id', projectId).maybeSingle();
|
|
30
|
+
if (projectError || !project?.org_id)
|
|
31
|
+
return false;
|
|
32
|
+
const { data: member, error: roleError } = await client.from('org_members')
|
|
33
|
+
.select('role').eq('org_id', project.org_id).eq('user_id', identity.actorId).maybeSingle();
|
|
34
|
+
return !roleError && (member?.role === 'owner' || member?.role === 'admin');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.121",
|
|
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",
|