@sendinel/mcp-server 1.0.123 → 1.0.125
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/README.md +2 -2
- package/build/db.d.ts +2 -0
- package/build/db.js +1 -0
- package/build/lib/action-approvals.js +24 -5
- package/build/lib/approval-chat-confirm.d.ts +24 -0
- package/build/lib/approval-chat-confirm.js +90 -0
- package/build/lib/approval-elicitation-relay.d.ts +1 -0
- package/build/lib/approval-elicitation-relay.js +2 -1
- package/build/lib/approval-human-eligibility.d.ts +23 -1
- package/build/lib/approval-human-eligibility.js +19 -2
- package/build/server.d.ts +2 -0
- package/build/server.js +1 -1
- package/build/tools/approvals.d.ts +2 -1
- package/build/tools/approvals.js +14 -1
- package/build/tools/gateway.d.ts +1 -0
- package/build/tools/gateway.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
|
|
|
18
18
|
[event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
|
|
19
19
|
inputs, idempotency, subscription requirements, and conditional step examples.
|
|
20
20
|
|
|
21
|
-
Sendinel currently exposes 35 tool groups and
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 342 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
22
22
|
|
|
23
23
|
| Group | Count | Purpose |
|
|
24
24
|
|-------|-------|---------|
|
|
@@ -27,7 +27,7 @@ Sendinel currently exposes 35 tool groups and 341 tools (gateway mode exposes 4
|
|
|
27
27
|
| `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
|
|
28
28
|
| `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
|
|
29
29
|
| `analytics` | 15 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
|
|
30
|
-
| `approvals` |
|
|
30
|
+
| `approvals` | 3 | Poll and list risk-tiered action approvals |
|
|
31
31
|
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
32
32
|
| `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
|
|
33
33
|
| `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
|
package/build/db.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
|
|
|
40
40
|
actorType?: string;
|
|
41
41
|
actorId?: string | null;
|
|
42
42
|
oauthTokenId?: string | null;
|
|
43
|
+
chatConfirmEnabled?: boolean;
|
|
43
44
|
invitationProjectIds?: string[] | null;
|
|
44
45
|
authKind?: string | null;
|
|
45
46
|
oauthClientId?: string | null;
|
|
@@ -66,6 +67,7 @@ export declare function requestContext(): {
|
|
|
66
67
|
actorType?: string;
|
|
67
68
|
actorId?: string | null;
|
|
68
69
|
oauthTokenId?: string | null;
|
|
70
|
+
chatConfirmEnabled?: boolean;
|
|
69
71
|
invitationProjectIds?: string[] | null;
|
|
70
72
|
authKind?: string | null;
|
|
71
73
|
oauthClientId?: string | null;
|
package/build/db.js
CHANGED
|
@@ -78,6 +78,7 @@ export function requestContext() {
|
|
|
78
78
|
actorType: ctx.actorType,
|
|
79
79
|
actorId: ctx.actorId,
|
|
80
80
|
oauthTokenId: ctx.oauthTokenId,
|
|
81
|
+
chatConfirmEnabled: ctx.chatConfirmEnabled,
|
|
81
82
|
invitationProjectIds: ctx.invitationProjectIds,
|
|
82
83
|
authKind: ctx.authKind,
|
|
83
84
|
oauthClientId: ctx.oauthClientId,
|
|
@@ -309,7 +309,12 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
309
309
|
projectName = project.name;
|
|
310
310
|
}
|
|
311
311
|
catch { /* Missing display data omits the project line. */ }
|
|
312
|
-
|
|
312
|
+
const approveUrl = approvalPageUrl(data.id);
|
|
313
|
+
const previewText = buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary;
|
|
314
|
+
const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
|
|
315
|
+
? `\nAsk the user in plain chat: "${previewText.split('\n')[0].trim()}". Only after they say yes, call confirm_approval with approval_id, action, echo (the first line of preview_text) and user_reply (their exact words), then call the original tool again with approval_id.`
|
|
316
|
+
: '';
|
|
317
|
+
const result = await elicitApproval({
|
|
313
318
|
requires_approval: true,
|
|
314
319
|
...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
315
320
|
payload_hash: approvalPayloadHash(action, frozenParams) })),
|
|
@@ -319,10 +324,16 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
319
324
|
summary,
|
|
320
325
|
expires_at: expiresAt,
|
|
321
326
|
approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
|
|
322
|
-
approve_url:
|
|
323
|
-
preview_text:
|
|
324
|
-
message: `
|
|
327
|
+
approve_url: approveUrl,
|
|
328
|
+
preview_text: previewText,
|
|
329
|
+
message: `Tap to approve: ${approveUrl}\n${previewText}\nIt stays open for 24 hours. In a terminal with prompts you can also answer the on-screen Accept/Decline.${chatGuidance}`,
|
|
325
330
|
}, { action, params: frozenParams, summary, projectName }, getProjectId());
|
|
331
|
+
if (result.decision_status === 'approved') {
|
|
332
|
+
return { ...result, message: 'Approved. Call this tool again with the same arguments plus approval_id to run it.' };
|
|
333
|
+
}
|
|
334
|
+
if (result.decision_status === 'rejected')
|
|
335
|
+
return { ...result, message: 'Rejected.' };
|
|
336
|
+
return result;
|
|
326
337
|
}
|
|
327
338
|
const registeredApproval = new AsyncLocalStorage();
|
|
328
339
|
/** Read by the hosted bridge only; no scope or token is changed. */
|
|
@@ -374,9 +385,15 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
374
385
|
}
|
|
375
386
|
if (data.used_at || data.status === 'used')
|
|
376
387
|
return { approved: false, status: 'consumed' };
|
|
388
|
+
// Chat decisions stay bound to the same live human grant at consumption, for every action.
|
|
389
|
+
if (data.status === 'approved' && data.decided_via === 'chat_confirm' &&
|
|
390
|
+
(requestContext()?.oauthTokenId !== data.oauth_token_id || requestContext()?.actorId !== data.decided_by ||
|
|
391
|
+
!await humanApprovalEligible(data.action, getProjectId(), requestContext(), true))) {
|
|
392
|
+
return { approved: false, status: 'human_decision_required' };
|
|
393
|
+
}
|
|
377
394
|
if (data.status === 'approved' && (!isHumanApprovalChannel(data.decided_via) ||
|
|
378
395
|
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
|
|
379
|
-
(!['mcp_app', 'mcp_elicitation'].includes(data.decided_via) ||
|
|
396
|
+
(!['mcp_app', 'mcp_elicitation', 'chat_confirm'].includes(data.decided_via) ||
|
|
380
397
|
!await humanApprovalEligible(data.action, getProjectId(), { projectId: getProjectId(),
|
|
381
398
|
actorId: data.decided_by, oauthTokenId: data.oauth_token_id, authKind: 'oauth', channelVerified: true }))))) {
|
|
382
399
|
return { approved: false, status: 'human_decision_required' };
|
|
@@ -415,6 +432,8 @@ export async function checkApproval(approvalId, options = {}) {
|
|
|
415
432
|
.eq('decided_via', data.decided_via)
|
|
416
433
|
.eq('params', JSON.stringify(data.params))
|
|
417
434
|
.gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
|
|
435
|
+
if (data.decided_via === 'chat_confirm')
|
|
436
|
+
claimQuery.eq('oauth_token_id', requestContext().oauthTokenId).eq('decided_by', requestContext().actorId);
|
|
418
437
|
const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
|
|
419
438
|
const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
|
|
420
439
|
if (claimError || !claimed) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const confirmApprovalInput: {
|
|
3
|
+
approval_id: z.ZodString;
|
|
4
|
+
action: z.ZodString;
|
|
5
|
+
echo: z.ZodString;
|
|
6
|
+
user_reply: z.ZodString;
|
|
7
|
+
};
|
|
8
|
+
declare const inputSchema: z.ZodObject<{
|
|
9
|
+
approval_id: z.ZodString;
|
|
10
|
+
action: z.ZodString;
|
|
11
|
+
echo: z.ZodString;
|
|
12
|
+
user_reply: z.ZodString;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export declare const CHAT_CONFIRM_NOT_ENABLED = "Chat confirm is not enabled for this connection. Use the on-screen prompt or approve_url.";
|
|
15
|
+
/** This opt-in boundary is protected by the Claude Code PreToolUse hook, not elicitation. */
|
|
16
|
+
export declare function confirmApproval(input: z.infer<typeof inputSchema>): Promise<{
|
|
17
|
+
code: string;
|
|
18
|
+
error: string;
|
|
19
|
+
} | {
|
|
20
|
+
decision_status: 'approved';
|
|
21
|
+
approval_id: string;
|
|
22
|
+
next: string;
|
|
23
|
+
}>;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildApprovalPreview } from '@sendinel/domain';
|
|
3
|
+
import { requestContext } from '../db.js';
|
|
4
|
+
import { getProjectId } from '../project.js';
|
|
5
|
+
import { humanApprovalEligible, chatConfirmConnectionEnabled } from './approval-human-eligibility.js';
|
|
6
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
7
|
+
import { approvalPayloadHash } from './action-approvals.js';
|
|
8
|
+
export const confirmApprovalInput = {
|
|
9
|
+
approval_id: z.string().uuid(),
|
|
10
|
+
action: z.string(),
|
|
11
|
+
echo: z.string().max(300),
|
|
12
|
+
user_reply: z.string().min(1).max(500),
|
|
13
|
+
};
|
|
14
|
+
const inputSchema = z.object(confirmApprovalInput);
|
|
15
|
+
const refuse = (code, error, extra = {}) => ({ code, error, ...extra });
|
|
16
|
+
export const CHAT_CONFIRM_NOT_ENABLED = 'Chat confirm is not enabled for this connection. Use the on-screen prompt or approve_url.';
|
|
17
|
+
/** This opt-in boundary is protected by the Claude Code PreToolUse hook, not elicitation. */
|
|
18
|
+
export async function confirmApproval(input) {
|
|
19
|
+
const actor = requestContext();
|
|
20
|
+
if (actor?.chatConfirmEnabled !== true)
|
|
21
|
+
return refuse('chat_confirm_not_enabled', CHAT_CONFIRM_NOT_ENABLED);
|
|
22
|
+
if (!inputSchema.safeParse(input).success)
|
|
23
|
+
return refuse('chat_confirm_invalid_input', 'Invalid confirm_approval arguments. user_reply must contain 1 to 500 characters and echo at most 300.');
|
|
24
|
+
const projectId = getProjectId();
|
|
25
|
+
if (!await humanApprovalEligible(input.action, projectId, actor)) {
|
|
26
|
+
return refuse('chat_confirm_ineligible', 'Chat confirm requires an enabled human OAuth connection and an owner/admin for admin actions.');
|
|
27
|
+
}
|
|
28
|
+
if (!await chatConfirmConnectionEnabled(actor))
|
|
29
|
+
return refuse('chat_confirm_not_enabled', CHAT_CONFIRM_NOT_ENABLED);
|
|
30
|
+
try {
|
|
31
|
+
const source = approvalStore();
|
|
32
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
33
|
+
const { data: approval, error: loadError } = await client.from('action_approvals')
|
|
34
|
+
.select('id, project_id, oauth_token_id, action, tier, status, used_at, requested_at, expires_at, params, summary, payload_hash')
|
|
35
|
+
.eq('id', input.approval_id).eq('project_id', projectId).eq('oauth_token_id', actor.oauthTokenId).maybeSingle();
|
|
36
|
+
if (loadError)
|
|
37
|
+
return refuse('chat_confirm_lookup_failed', 'Unable to verify the approval. Nothing was recorded.');
|
|
38
|
+
if (!approval || approval.project_id !== projectId || approval.oauth_token_id !== actor.oauthTokenId) {
|
|
39
|
+
return refuse('chat_confirm_not_found', 'Approval not found for this connection and project.');
|
|
40
|
+
}
|
|
41
|
+
if (approval.tier !== 'gated')
|
|
42
|
+
return refuse('chat_confirm_tier', 'Chat confirm supports gated approvals only. Protected actions require dashboard approval.');
|
|
43
|
+
if (approval.status !== 'pending' || approval.used_at != null)
|
|
44
|
+
return refuse('chat_confirm_not_pending', 'Approval is already decided or used. Request a new approval.');
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const requested = Date.parse(approval.requested_at);
|
|
47
|
+
if (!(Date.parse(approval.expires_at) > now))
|
|
48
|
+
return refuse('chat_confirm_expired', 'Approval has expired. Request a new approval.');
|
|
49
|
+
if (!Number.isFinite(requested) || requested > now || requested < now - 600_000) {
|
|
50
|
+
return refuse('chat_confirm_too_old', 'Chat confirm requires an approval requested within the last 10 minutes. Request a new approval.');
|
|
51
|
+
}
|
|
52
|
+
if (input.action !== approval.action)
|
|
53
|
+
return refuse('chat_confirm_action_mismatch', 'Action does not match the approval.');
|
|
54
|
+
if (!approval.params || approvalPayloadHash(approval.action, approval.params) !== approval.payload_hash) {
|
|
55
|
+
return refuse('chat_confirm_payload_changed', 'Approval payload could not be verified. Request a new approval.');
|
|
56
|
+
}
|
|
57
|
+
const { data: project, error: projectError } = await client.from('projects').select('name').eq('id', projectId).maybeSingle();
|
|
58
|
+
if (projectError || !project || typeof project.name !== 'string')
|
|
59
|
+
return refuse('chat_confirm_lookup_failed', 'Unable to verify the approval. Nothing was recorded.');
|
|
60
|
+
const echo = buildApprovalPreview({ action: approval.action, params: approval.params, summary: approval.summary,
|
|
61
|
+
projectName: project.name }).plain_summary.split('\n')[0].trim();
|
|
62
|
+
if (!echo || input.echo.trim() !== echo)
|
|
63
|
+
return refuse('chat_confirm_echo_mismatch', `Echo does not match the approval. Use this first line: ${echo}`, { echo });
|
|
64
|
+
// Re-check time and frozen bindings at the write, so replay/concurrent changes cannot win.
|
|
65
|
+
const decidedAt = new Date().toISOString();
|
|
66
|
+
const { data: decided, error: decisionError } = await client.from('action_approvals')
|
|
67
|
+
.update({ status: 'approved', decided_via: 'chat_confirm', decided_by: actor.actorId, decided_at: decidedAt })
|
|
68
|
+
.eq('id', input.approval_id).eq('project_id', projectId).eq('oauth_token_id', actor.oauthTokenId)
|
|
69
|
+
.eq('action', input.action).eq('tier', 'gated').eq('status', 'pending').is('used_at', null)
|
|
70
|
+
.gt('expires_at', decidedAt).eq('requested_at', approval.requested_at)
|
|
71
|
+
.gt('requested_at', new Date(Date.now() - 600_000 - 1).toISOString())
|
|
72
|
+
.eq('payload_hash', approval.payload_hash).eq('params', JSON.stringify(approval.params))
|
|
73
|
+
.select('id, payload_hash').maybeSingle();
|
|
74
|
+
if (decisionError || !decided)
|
|
75
|
+
return refuse('chat_confirm_not_pending', 'Approval is already decided, used or no longer valid. Request a new approval.');
|
|
76
|
+
const { error: auditError } = await client.from('audit_log').insert({
|
|
77
|
+
project_id: projectId, actor_type: 'user', actor_id: actor.actorId,
|
|
78
|
+
action: 'approval.submitted', resource_type: 'approval', resource_id: input.approval_id,
|
|
79
|
+
details: { status: 'approved', channel: 'chat_confirm', user_reply: input.user_reply.replace(/[\p{Cc}]/gu, '').slice(0, 500),
|
|
80
|
+
echo, payload_hash: decided.payload_hash, connection_id: `oauth:${actor.oauthTokenId}` },
|
|
81
|
+
});
|
|
82
|
+
if (auditError)
|
|
83
|
+
return refuse('chat_confirm_audit_failed', 'Approval recorded, but decision audit failed. Check approval status before retrying.');
|
|
84
|
+
return { decision_status: 'approved', approval_id: input.approval_id,
|
|
85
|
+
next: `Call ${input.action} again with the same arguments plus approval_id.` };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return refuse('chat_confirm_failed', 'Chat confirm failed. Check approval status before retrying.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ElicitRequest, JSONRPCRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
|
|
3
|
+
export declare const ELICITATION_ANSWER_WINDOW_MS = 600000;
|
|
3
4
|
export declare function isElicitationResponse(body: unknown): boolean;
|
|
4
5
|
/** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
|
|
5
6
|
* No decision can enter this path via a tools/call, model text, or nested tool arguments.
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { approvalStore } from './tester-requests-store.js';
|
|
4
4
|
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
5
5
|
const TABLE = 'mcp_elicitation_prompts';
|
|
6
|
+
export const ELICITATION_ANSWER_WINDOW_MS = 600_000;
|
|
6
7
|
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
7
8
|
function email() {
|
|
8
9
|
const source = approvalStore();
|
|
@@ -107,7 +108,7 @@ export async function relayElicitation(options) {
|
|
|
107
108
|
const { error } = await client.from(TABLE).insert({ id: promptId, approval_id: options.approvalId,
|
|
108
109
|
project_id: identity.projectId, oauth_token_id: identity.oauthTokenId, user_id: identity.actorId,
|
|
109
110
|
jsonrpc_request_id: requestId, channel: 'mcp_elicitation', status: 'pending',
|
|
110
|
-
created_at: new Date(now).toISOString(), expires_at: new Date(Math.min(now +
|
|
111
|
+
created_at: new Date(now).toISOString(), expires_at: new Date(Math.min(now + ELICITATION_ANSWER_WINDOW_MS, Date.parse(approval.expires_at))).toISOString() });
|
|
111
112
|
if (signal.aborted)
|
|
112
113
|
return exit('request_aborted');
|
|
113
114
|
if (error)
|
|
@@ -4,10 +4,32 @@ export type HumanDecisionIdentity = {
|
|
|
4
4
|
oauthTokenId?: string | null;
|
|
5
5
|
authKind?: string | null;
|
|
6
6
|
channelVerified?: boolean;
|
|
7
|
+
chatConfirmEnabled?: boolean;
|
|
7
8
|
scopes?: readonly string[];
|
|
8
9
|
};
|
|
9
10
|
/** Shared transport boundary, never populated from tool arguments. Re-read the grant and
|
|
10
11
|
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
11
12
|
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
12
13
|
*/
|
|
13
|
-
export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined): Promise<boolean>;
|
|
14
|
+
export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined, requireChatConfirm?: boolean): Promise<boolean>;
|
|
15
|
+
/** Opt-in is a live grant fact as well as a transport snapshot; errors never enable it. */
|
|
16
|
+
export declare function chatConfirmConnectionEnabled(identity?: {
|
|
17
|
+
scopes?: readonly string[];
|
|
18
|
+
approvedTesterAction?: string;
|
|
19
|
+
projectId?: string;
|
|
20
|
+
approvalMode?: string;
|
|
21
|
+
channel?: string;
|
|
22
|
+
channelVerified?: boolean;
|
|
23
|
+
actorType?: string;
|
|
24
|
+
actorId?: string | null;
|
|
25
|
+
oauthTokenId?: string | null;
|
|
26
|
+
chatConfirmEnabled?: boolean;
|
|
27
|
+
invitationProjectIds?: string[] | null;
|
|
28
|
+
authKind?: string | null;
|
|
29
|
+
oauthClientId?: string | null;
|
|
30
|
+
agentLabel?: string | null;
|
|
31
|
+
keyId?: string | null;
|
|
32
|
+
workspaceId?: string | null;
|
|
33
|
+
kid?: string | null;
|
|
34
|
+
v1Caller?: import("../db.js").V1Caller;
|
|
35
|
+
} | undefined): Promise<boolean>;
|
|
@@ -5,8 +5,9 @@ import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
|
5
5
|
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
6
6
|
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
7
7
|
*/
|
|
8
|
-
export async function humanApprovalEligible(action, projectId, identity = requestContext()) {
|
|
8
|
+
export async function humanApprovalEligible(action, projectId, identity = requestContext(), requireChatConfirm = false) {
|
|
9
9
|
if (!identity || identity.channelVerified !== true || identity.authKind !== 'oauth' ||
|
|
10
|
+
(requireChatConfirm && identity.chatConfirmEnabled !== true) ||
|
|
10
11
|
!identity.actorId || !identity.oauthTokenId || identity.projectId !== projectId || !projectId ||
|
|
11
12
|
identity.scopes?.some(scope => scope === 'tester' || scope === 'agent'))
|
|
12
13
|
return false;
|
|
@@ -14,10 +15,11 @@ export async function humanApprovalEligible(action, projectId, identity = reques
|
|
|
14
15
|
const source = approvalStore();
|
|
15
16
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
16
17
|
const { data: grant, error } = await client.from('mcp_oauth_tokens')
|
|
17
|
-
.select('id, user_id, connection_role, scopes, revoked_at, expires_at')
|
|
18
|
+
.select('id, user_id, connection_role, scopes, revoked_at, expires_at, chat_confirm_enabled')
|
|
18
19
|
.eq('id', identity.oauthTokenId).maybeSingle();
|
|
19
20
|
if (error || !grant || grant.id !== identity.oauthTokenId || grant.user_id !== identity.actorId ||
|
|
20
21
|
grant.connection_role !== null || grant.revoked_at !== null ||
|
|
22
|
+
(requireChatConfirm && grant.chat_confirm_enabled !== true) ||
|
|
21
23
|
!(Date.parse(grant.expires_at) > Date.now()) || !Array.isArray(grant.scopes) ||
|
|
22
24
|
grant.scopes.some((scope) => scope === 'tester' || scope === 'agent'))
|
|
23
25
|
return false;
|
|
@@ -37,3 +39,18 @@ export async function humanApprovalEligible(action, projectId, identity = reques
|
|
|
37
39
|
return false;
|
|
38
40
|
}
|
|
39
41
|
}
|
|
42
|
+
/** Opt-in is a live grant fact as well as a transport snapshot; errors never enable it. */
|
|
43
|
+
export async function chatConfirmConnectionEnabled(identity = requestContext()) {
|
|
44
|
+
if (identity?.chatConfirmEnabled !== true || !identity.oauthTokenId)
|
|
45
|
+
return false;
|
|
46
|
+
try {
|
|
47
|
+
const source = approvalStore();
|
|
48
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
49
|
+
const { data, error } = await client.from('mcp_oauth_tokens').select('chat_confirm_enabled')
|
|
50
|
+
.eq('id', identity.oauthTokenId).maybeSingle();
|
|
51
|
+
return !error && data?.chat_confirm_enabled === true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/build/server.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface ToolRegistrationOptions {
|
|
|
4
4
|
scopes?: string[];
|
|
5
5
|
toolGroups?: string[];
|
|
6
6
|
toolAllowlist?: string[];
|
|
7
|
+
/** Server-owned connection opt-in; undefined only for unrestricted registry collectors. */
|
|
8
|
+
chatConfirmEnabled?: boolean;
|
|
7
9
|
}
|
|
8
10
|
declare const GROUP_REGISTRY: Record<string, (server: McpServer, options?: ToolRegistrationOptions) => void>;
|
|
9
11
|
export declare const ALL_TOOL_GROUPS: string[];
|
package/build/server.js
CHANGED
|
@@ -375,7 +375,7 @@ export function registerAllTools(server, options) {
|
|
|
375
375
|
if (rawScopes.includes('gateway')) {
|
|
376
376
|
const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
|
|
377
377
|
const proxied = createScopeProxy(server, permissionScopes, 'gateway');
|
|
378
|
-
registerGatewayTools(proxied, { scopes: permissionScopes, toolGroups: options?.toolGroups, toolAllowlist: options?.toolAllowlist });
|
|
378
|
+
registerGatewayTools(proxied, { scopes: permissionScopes, toolGroups: options?.toolGroups, toolAllowlist: options?.toolAllowlist, chatConfirmEnabled: options?.chatConfirmEnabled === true });
|
|
379
379
|
return;
|
|
380
380
|
}
|
|
381
381
|
const groups = options?.toolGroups === undefined
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
|
|
2
|
+
import type { ToolRegistrationOptions } from '../server.js';
|
|
3
|
+
export declare function registerApprovalTools(server: McpServer, options?: ToolRegistrationOptions): void;
|
package/build/tools/approvals.js
CHANGED
|
@@ -2,9 +2,22 @@ import { z } from 'zod';
|
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { checkApproval } from '../lib/action-approvals.js';
|
|
5
|
+
import { confirmApproval, confirmApprovalInput } from '../lib/approval-chat-confirm.js';
|
|
5
6
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
6
7
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
7
|
-
export function registerApprovalTools(server) {
|
|
8
|
+
export function registerApprovalTools(server, options) {
|
|
9
|
+
if (options?.chatConfirmEnabled !== false && (!options?.scopes || options.chatConfirmEnabled === true) &&
|
|
10
|
+
!options?.scopes?.some(scope => scope === 'tester' || scope === 'agent')) {
|
|
11
|
+
server.registerTool('confirm_approval', {
|
|
12
|
+
title: 'Confirm Approval',
|
|
13
|
+
description: "Records the user's yes. The user's own client shows a confirmation prompt. Never call this without asking the user first.",
|
|
14
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
15
|
+
inputSchema: confirmApprovalInput,
|
|
16
|
+
}, async (args) => {
|
|
17
|
+
const result = await confirmApproval(args);
|
|
18
|
+
return { ...j(result), ...('code' in result ? { isError: true } : {}) };
|
|
19
|
+
});
|
|
20
|
+
}
|
|
8
21
|
server.registerTool('check_approval', {
|
|
9
22
|
title: 'Check Approval',
|
|
10
23
|
annotations: {
|
package/build/tools/gateway.d.ts
CHANGED
package/build/tools/gateway.js
CHANGED
|
@@ -25,12 +25,12 @@ export function matchesToolQuery(entry, query) {
|
|
|
25
25
|
const SEARCH_LIMIT = 10;
|
|
26
26
|
async function buildCatalog(options) {
|
|
27
27
|
const innerServer = new McpServer({ name: 'sendinel-gateway-inner', version: '1.0.0' });
|
|
28
|
-
registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups, toolAllowlist: options.toolAllowlist });
|
|
28
|
+
registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups, toolAllowlist: options.toolAllowlist, chatConfirmEnabled: options.chatConfirmEnabled === true });
|
|
29
29
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
30
30
|
const client = new Client({ name: 'sendinel-gateway', version: '1.0.0' });
|
|
31
31
|
await innerServer.connect(serverTransport);
|
|
32
32
|
await client.connect(clientTransport);
|
|
33
|
-
const toolGroupMap = getRegisteredToolGroups({ scopes: options.scopes, toolGroups: options.toolGroups, toolAllowlist: options.toolAllowlist });
|
|
33
|
+
const toolGroupMap = getRegisteredToolGroups({ scopes: options.scopes, toolGroups: options.toolGroups, toolAllowlist: options.toolAllowlist, chatConfirmEnabled: options.chatConfirmEnabled === true });
|
|
34
34
|
const { tools } = await client.listTools();
|
|
35
35
|
const entries = tools
|
|
36
36
|
.map((tool) => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.125",
|
|
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",
|