@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 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 341 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.
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` | 2 | Poll and list risk-tiered action 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
- return elicitApproval({
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: approvalPageUrl(data.id),
323
- preview_text: buildApprovalPreview({ action, params: frozenParams, summary, projectName }).plain_summary,
324
- message: `This action requires human approval. A notification has been sent to your dashboard. Approval ID: ${data.id}. Check status with: poll this approval_id, or wait for the webhook callback (event: approval.decided). Token expires in 24 hours.`,
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 + 120_000, Date.parse(approval.expires_at))).toISOString() });
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
- export declare function registerApprovalTools(server: McpServer): void;
2
+ import type { ToolRegistrationOptions } from '../server.js';
3
+ export declare function registerApprovalTools(server: McpServer, options?: ToolRegistrationOptions): void;
@@ -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: {
@@ -3,6 +3,7 @@ export interface GatewayOptions {
3
3
  scopes?: string[];
4
4
  toolGroups?: string[];
5
5
  toolAllowlist?: string[];
6
+ chatConfirmEnabled?: boolean;
6
7
  }
7
8
  /**
8
9
  * Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
@@ -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.123",
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",