@sendinel/mcp-server 1.0.124 → 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,
@@ -311,6 +311,9 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
311
311
  catch { /* Missing display data omits the project line. */ }
312
312
  const approveUrl = approvalPageUrl(data.id);
313
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
+ : '';
314
317
  const result = await elicitApproval({
315
318
  requires_approval: true,
316
319
  ...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
@@ -323,7 +326,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
323
326
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
324
327
  approve_url: approveUrl,
325
328
  preview_text: previewText,
326
- 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.`,
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}`,
327
330
  }, { action, params: frozenParams, summary, projectName }, getProjectId());
328
331
  if (result.decision_status === 'approved') {
329
332
  return { ...result, message: 'Approved. Call this tool again with the same arguments plus approval_id to run it.' };
@@ -382,9 +385,15 @@ export async function checkApproval(approvalId, options = {}) {
382
385
  }
383
386
  if (data.used_at || data.status === 'used')
384
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
+ }
385
394
  if (data.status === 'approved' && (!isHumanApprovalChannel(data.decided_via) ||
386
395
  (isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
387
- (!['mcp_app', 'mcp_elicitation'].includes(data.decided_via) ||
396
+ (!['mcp_app', 'mcp_elicitation', 'chat_confirm'].includes(data.decided_via) ||
388
397
  !await humanApprovalEligible(data.action, getProjectId(), { projectId: getProjectId(),
389
398
  actorId: data.decided_by, oauthTokenId: data.oauth_token_id, authKind: 'oauth', channelVerified: true }))))) {
390
399
  return { approved: false, status: 'human_decision_required' };
@@ -423,6 +432,8 @@ export async function checkApproval(approvalId, options = {}) {
423
432
  .eq('decided_via', data.decided_via)
424
433
  .eq('params', JSON.stringify(data.params))
425
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);
426
437
  const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
427
438
  const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
428
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
+ }
@@ -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.124",
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",