@sendinel/mcp-server 1.0.104 → 1.0.105

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.
@@ -46,9 +46,12 @@ export interface ApprovalResult {
46
46
  approve_via: string;
47
47
  /** One-tap approval page; works on a phone and needs no project switching (#3661). */
48
48
  approve_url: string;
49
+ preview_text: string;
49
50
  message: string;
50
51
  }
51
52
  export declare function approvalPageUrl(approvalId: string): string;
53
+ /** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
54
+ export declare function approvalPayloadHash(action: string, params: unknown): string;
52
55
  /**
53
56
  * Request human approval for an action. Creates a pending approval token
54
57
  * and returns instructions for the agent to wait.
@@ -1,6 +1,8 @@
1
- import { triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
1
+ import { createHash } from 'node:crypto';
2
+ import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
2
3
  import { db, requestContext } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
5
+ import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
4
6
  import { fireWebhookEvent } from './webhook-events.js';
5
7
  /**
6
8
  * Action approval tiers:
@@ -303,11 +305,15 @@ function normalizeApprovalParams(value) {
303
305
  if (value && typeof value === 'object') {
304
306
  return Object.fromEntries(Object.entries(value)
305
307
  .filter(([, entryValue]) => entryValue !== undefined)
306
- .sort(([a], [b]) => a.localeCompare(b))
308
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
307
309
  .map(([key, entryValue]) => [key, normalizeApprovalParams(entryValue)]));
308
310
  }
309
311
  return value;
310
312
  }
313
+ /** SHA-256 over JSON-compatible params, with recursive stable key ordering. */
314
+ export function approvalPayloadHash(action, params) {
315
+ return createHash('sha256').update(JSON.stringify(normalizeApprovalParams({ action, params }))).digest('hex');
316
+ }
311
317
  function approvalParamsEqual(expected, actual) {
312
318
  return JSON.stringify(normalizeApprovalParams(expected)) === JSON.stringify(normalizeApprovalParams(actual));
313
319
  }
@@ -318,9 +324,18 @@ function approvalParamsEqual(expected, actual) {
318
324
  export async function requestApproval(action, params, apiKeyId, channelContext, display) {
319
325
  const tier = getApprovalTier(action, params);
320
326
  const summary = buildApprovalSummary(action, params, display);
321
- const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
327
+ const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
322
328
  const { channel, verified } = channelContext ?? currentChannelContext();
323
329
  const actor = requestContext();
330
+ // Ignore any model-supplied reserved field; freeze facts read by the server.
331
+ const cleanParams = withoutApprovalSnapshot(params);
332
+ const snapshot = await captureApprovalSnapshot(action, cleanParams, getProjectId());
333
+ const payload = cleanParams.payload;
334
+ if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
335
+ !approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
336
+ throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
337
+ }
338
+ const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
324
339
  const { data, error } = await db()
325
340
  .from('action_approvals')
326
341
  .insert({
@@ -330,7 +345,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
330
345
  agent_label: actor?.agentLabel ?? null,
331
346
  action,
332
347
  tier,
333
- params,
348
+ params: frozenParams,
349
+ payload_hash: approvalPayloadHash(action, frozenParams),
334
350
  summary,
335
351
  expires_at: expiresAt,
336
352
  channel,
@@ -355,7 +371,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
355
371
  expires_at: expiresAt,
356
372
  approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
357
373
  approve_url: approvalPageUrl(data.id),
358
- 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 30 minutes.`,
374
+ preview_text: buildApprovalPreview({ action, params: frozenParams, summary }).plain_summary,
375
+ 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.`,
359
376
  };
360
377
  }
361
378
  /**
@@ -365,7 +382,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
365
382
  export async function checkApproval(approvalId, options = {}) {
366
383
  const { data, error } = await db()
367
384
  .from('action_approvals')
368
- .select('action, status, decided_by, decided_via, expires_at, used_at, params')
385
+ .select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
369
386
  .eq('id', approvalId)
370
387
  .eq('project_id', getProjectId())
371
388
  .maybeSingle();
@@ -374,17 +391,29 @@ export async function checkApproval(approvalId, options = {}) {
374
391
  if (options.action && data.action !== options.action) {
375
392
  return { approved: false, status: 'action_mismatch' };
376
393
  }
377
- if (options.params && !approvalParamsEqual(options.params, data.params)) {
394
+ if (options.consume && data.payload_hash) {
395
+ if (!options.action || !options.params)
396
+ return { approved: false, status: 'payload_required' };
397
+ if (approvalPayloadHash(options.action, { ...withoutApprovalSnapshot(options.params), ...(data.params?.__approval_snapshot ? { __approval_snapshot: data.params.__approval_snapshot } : {}) }) !== data.payload_hash ||
398
+ approvalPayloadHash(data.action, data.params) !== data.payload_hash) {
399
+ return { approved: false, status: 'payload_changed_after_approval' };
400
+ }
401
+ }
402
+ if (options.params && !approvalParamsEqual(withoutApprovalSnapshot(options.params), withoutApprovalSnapshot(data.params))) {
378
403
  return { approved: false, status: 'params_mismatch' };
379
404
  }
380
- // Check expiry
381
- if (data.status === 'pending' && new Date(data.expires_at) < new Date()) {
382
- await db()
383
- .from('action_approvals')
384
- .update({ status: 'expired' })
385
- .eq('id', approvalId);
405
+ // Approved tokens expire too. Invalid timestamps fail closed. Older rows may have a
406
+ // shorter lifetime; no row can authorize an apply more than 24 hours after its request.
407
+ const requested = data.requested_at == null ? null : Date.parse(data.requested_at);
408
+ const expiry = Math.min(Date.parse(data.expires_at), requested == null ? Infinity : requested + 24 * 60 * 60 * 1000);
409
+ if (!Number.isFinite(expiry) || (requested != null && !Number.isFinite(requested)) || expiry <= Date.now()) {
386
410
  return { approved: false, status: 'expired' };
387
411
  }
412
+ if (data.used_at || data.status === 'used')
413
+ return { approved: false, status: 'consumed' };
414
+ if (data.status === 'approved' && !isHumanApprovalChannel(data.decided_via)) {
415
+ return { approved: false, status: 'human_decision_required' };
416
+ }
388
417
  if (data.status !== 'approved' || !options.consume) {
389
418
  if (data.used_at)
390
419
  return { approved: false, status: 'consumed' };
@@ -394,15 +423,33 @@ export async function checkApproval(approvalId, options = {}) {
394
423
  decided_by: data.decided_by,
395
424
  };
396
425
  }
397
- const { data: claimed, error: claimError } = await db()
426
+ if (data.params?.__approval_snapshot) {
427
+ try {
428
+ const current = await captureApprovalSnapshot(data.action, withoutApprovalSnapshot(data.params), getProjectId());
429
+ if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
430
+ return { approved: false, status: 'target_changed_after_approval' };
431
+ }
432
+ }
433
+ catch {
434
+ return { approved: false, status: 'target_revalidation_failed' };
435
+ }
436
+ }
437
+ if (expiry <= Date.now())
438
+ return { approved: false, status: 'expired' };
439
+ const claimQuery = db()
398
440
  .from('action_approvals')
399
441
  .update({ status: 'used', used_at: new Date().toISOString() })
400
442
  .eq('id', approvalId)
401
443
  .eq('project_id', getProjectId())
402
444
  .eq('status', 'approved')
403
445
  .is('used_at', null)
404
- .select('decided_by')
405
- .maybeSingle();
446
+ .eq('expires_at', data.expires_at)
447
+ .gt('expires_at', new Date().toISOString())
448
+ .eq('decided_via', data.decided_via)
449
+ .eq('params', JSON.stringify(data.params))
450
+ .gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
451
+ const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
452
+ const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
406
453
  if (claimError || !claimed) {
407
454
  return { approved: false, status: 'consumed' };
408
455
  }
@@ -0,0 +1,17 @@
1
+ export declare const MCP_APP_EXTENSION = "io.modelcontextprotocol/ui";
2
+ export declare const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app";
3
+ export type ApprovalClientCapabilities = Readonly<{
4
+ elicitation_form: boolean;
5
+ elicitation_url: boolean;
6
+ mcp_app: boolean;
7
+ }>;
8
+ export type ApprovalChannelAvailable = 'mcp_app' | 'mcp_elicitation_form' | 'mcp_elicitation_url' | 'plain_text';
9
+ /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
10
+ export declare function probeApprovalCapabilities(declared: unknown): ApprovalClientCapabilities;
11
+ export declare function rememberApprovalCapabilities(session: object, declared: unknown): ApprovalClientCapabilities;
12
+ export declare function approval_channel_available(session: object): ApprovalChannelAvailable;
13
+ /** Each SDK Server is a session boundary, including stateless HTTP request instances. */
14
+ export declare function installApprovalCapabilityProbe(session: {
15
+ oninitialized?: () => void;
16
+ getClientCapabilities(): unknown;
17
+ }): void;
@@ -0,0 +1,40 @@
1
+ export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
2
+ export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
3
+ const sessions = new WeakMap();
4
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
5
+ /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
6
+ export function probeApprovalCapabilities(declared) {
7
+ const capabilities = object(declared);
8
+ const elicitation = object(capabilities?.elicitation);
9
+ const extensions = object(capabilities?.extensions);
10
+ const app = object(extensions?.[MCP_APP_EXTENSION]);
11
+ return Object.freeze({
12
+ // The MCP spec defines elicitation:{} as form support for older clients.
13
+ elicitation_form: !!elicitation && (Object.keys(elicitation).length === 0 || !!object(elicitation.form)),
14
+ elicitation_url: !!object(elicitation?.url),
15
+ mcp_app: !!app && Array.isArray(app.mimeTypes) && app.mimeTypes.includes(MCP_APP_MIME_TYPE),
16
+ });
17
+ }
18
+ export function rememberApprovalCapabilities(session, declared) {
19
+ const capabilities = probeApprovalCapabilities(declared);
20
+ sessions.set(session, capabilities);
21
+ return capabilities;
22
+ }
23
+ export function approval_channel_available(session) {
24
+ const capabilities = sessions.get(session);
25
+ if (capabilities?.mcp_app)
26
+ return 'mcp_app';
27
+ if (capabilities?.elicitation_form)
28
+ return 'mcp_elicitation_form';
29
+ if (capabilities?.elicitation_url)
30
+ return 'mcp_elicitation_url';
31
+ return 'plain_text';
32
+ }
33
+ /** Each SDK Server is a session boundary, including stateless HTTP request instances. */
34
+ export function installApprovalCapabilityProbe(session) {
35
+ const previous = session.oninitialized;
36
+ session.oninitialized = () => {
37
+ rememberApprovalCapabilities(session, session.getClientCapabilities());
38
+ previous?.call(session);
39
+ };
40
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Server integration only. The transport creates this closure for one authenticated human
3
+ * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
4
+ * argument. Front ends must call complete only from the matching elicitation/app response.
5
+ */
6
+ export declare function createHumanApprovalCallback(binding: {
7
+ approvalId: string;
8
+ projectId: string;
9
+ connectionId: string;
10
+ userId: string;
11
+ channel: 'mcp_elicitation' | 'mcp_app';
12
+ }): {
13
+ interactionId: `${string}-${string}-${string}-${string}-${string}`;
14
+ complete(response: {
15
+ interactionId: string;
16
+ connectionId: string;
17
+ decision: 'approve' | 'reject';
18
+ }): Promise<boolean>;
19
+ };
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { db } from '../db.js';
3
+ /**
4
+ * Server integration only. The transport creates this closure for one authenticated human
5
+ * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
6
+ * argument. Front ends must call complete only from the matching elicitation/app response.
7
+ */
8
+ export function createHumanApprovalCallback(binding) {
9
+ const interactionId = randomUUID();
10
+ let completed = false;
11
+ return {
12
+ interactionId,
13
+ async complete(response) {
14
+ if (completed || response.interactionId !== interactionId || response.connectionId !== binding.connectionId)
15
+ return false;
16
+ if (response.decision !== 'approve' && response.decision !== 'reject')
17
+ return false;
18
+ completed = true;
19
+ const source = db();
20
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
21
+ const now = new Date().toISOString();
22
+ const { data, error } = await client.from('action_approvals')
23
+ .update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
24
+ decided_by: binding.userId, decided_at: now })
25
+ .eq('id', binding.approvalId).eq('project_id', binding.projectId)
26
+ .eq('status', 'pending').is('used_at', null).gt('expires_at', now)
27
+ .select('id, payload_hash').maybeSingle();
28
+ if (error || !data)
29
+ return false;
30
+ const { error: auditError } = await client.from('audit_log').insert({
31
+ project_id: binding.projectId, actor_type: 'user', actor_id: binding.userId,
32
+ action: 'approval.submitted', resource_type: 'approval', resource_id: binding.approvalId,
33
+ details: { status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
34
+ connection_id: binding.connectionId, interaction_id: interactionId, payload_hash: data.payload_hash },
35
+ });
36
+ if (auditError)
37
+ throw new Error('Approval recorded, but decision audit failed');
38
+ return true;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,5 @@
1
+ type Params = Record<string, any>;
2
+ /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
3
+ export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
4
+ export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
5
+ export {};
@@ -0,0 +1,106 @@
1
+ import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
2
+ import { db } from '../db.js';
3
+ import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
4
+ const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
5
+ 'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
6
+ 'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
7
+ /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
8
+ export async function captureApprovalSnapshot(action, params, projectId) {
9
+ const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
10
+ if (!TARGET_ACTIONS.has(action))
11
+ return snapshot;
12
+ const source = db();
13
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
14
+ const body = params.payload ?? params;
15
+ const ids = [...new Set((Array.isArray(params.campaign_ids) ? params.campaign_ids :
16
+ typeof params.campaign_id === 'string' ? [params.campaign_id] : []).map(String))].sort();
17
+ if (ids.length) {
18
+ snapshot.campaigns = await readAllRows(client.from('campaigns')
19
+ .select('id, name, site_id, type, status, trigger_event_name, trigger_tag, trigger_type, trigger_config, send_class, total_enrolled')
20
+ .eq('project_id', projectId).in('id', ids));
21
+ if (snapshot.campaigns.length !== ids.length)
22
+ throw new Error('Approval target not found');
23
+ snapshot.steps = await readAllRows(client.from('campaign_steps')
24
+ .select('id, campaign_id, step_order, variant_label, subject, body_html, body_text, delay_days, delay_hours, blocks')
25
+ .in('campaign_id', ids));
26
+ let count = 0;
27
+ for (const id of ids) {
28
+ const result = await client.from('campaign_enrollments').select('id', { count: 'exact', head: true }).eq('campaign_id', id);
29
+ if (result.error || result.count == null)
30
+ throw new Error('Approval audience count unavailable');
31
+ count += result.count;
32
+ }
33
+ snapshot.recipient_count = count;
34
+ }
35
+ if (typeof params.issue_id === 'string') {
36
+ const issue = await client.from('newsletter_issues').select('id, newsletter_id, subject, content, preheader, status, scheduled_at')
37
+ .eq('id', params.issue_id).eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
38
+ const newsletter = await client.from('newsletters').select('id, name, site_id, status, sender_name, sender_email')
39
+ .eq('id', params.newsletter_id).eq('project_id', projectId).maybeSingle();
40
+ if (issue.error || !issue.data || newsletter.error || !newsletter.data)
41
+ throw new Error('Approval newsletter unavailable');
42
+ snapshot.issue = issue.data;
43
+ snapshot.newsletter = newsletter.data;
44
+ snapshot.subscriptions = await readAllRows(client.from('newsletter_subscriptions').select('id, subscriber_id')
45
+ .eq('newsletter_id', params.newsletter_id).eq('project_id', projectId).eq('status', 'active').not('subscriber_id', 'is', null));
46
+ snapshot.subscriptions.sort((a, b) => String(a.id).localeCompare(String(b.id)));
47
+ snapshot.recipient_count = snapshot.subscriptions.length;
48
+ }
49
+ const siteIds = [...new Set([body.site_id, snapshot.newsletter?.site_id, ...snapshot.campaigns.map((c) => c.site_id)]
50
+ .filter((id) => typeof id === 'string' && id.length > 0))].sort();
51
+ for (const id of siteIds) {
52
+ const { data, error } = await client.from('sites')
53
+ .select('id, name, from_name, from_email, sending_domain, physical_address, unsub_link_enabled, unsub_footer_template')
54
+ .eq('id', id).eq('project_id', projectId).maybeSingle();
55
+ if (error || !data)
56
+ throw new Error('Approval sender/footer unavailable');
57
+ snapshot.sites.push(data);
58
+ }
59
+ async function readFrozenContacts(contacts) {
60
+ const found = [];
61
+ for (const chunk of chunkIds([...new Set(contacts.map(String))])) {
62
+ const values = await readAllRows(client.from('subscribers').select('id')
63
+ .eq('project_id', projectId).in('id', chunk));
64
+ found.push(...values.map(value => value.id));
65
+ }
66
+ if (found.length !== new Set(contacts).size)
67
+ throw new Error('Approval recipients changed or unavailable');
68
+ return found.sort();
69
+ }
70
+ if (typeof body.segment_id === 'string') {
71
+ const { data, error } = await client.from('segments').select('id, name, rules, site_id')
72
+ .eq('id', body.segment_id).eq('project_id', projectId).maybeSingle();
73
+ if (error || !data)
74
+ throw new Error('Approval segment unavailable');
75
+ snapshot.segment = data;
76
+ // launch_campaign executes a frozen list, so new segment matches cannot expand its audience.
77
+ // The saved segment definition is still compared; deleted original recipients invalidate it.
78
+ const rules = scopeSegmentRulesToSite(data.rules, data.site_id);
79
+ snapshot.contact_ids = action === 'launch_campaign' && Array.isArray(params.generated?.contact_ids)
80
+ ? await readFrozenContacts(params.generated.contact_ids)
81
+ : typeof body.site_id === 'string'
82
+ ? (await resolveSendAudience(projectId, body.site_id, rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort()
83
+ : (await resolveSegmentContactIds(projectId, rules)).sort();
84
+ snapshot.recipient_count = snapshot.contact_ids.length;
85
+ }
86
+ else if (body.rules && typeof body.site_id === 'string') {
87
+ snapshot.contact_ids = (await resolveSendAudience(projectId, body.site_id, body.rules, body.send_class === 'product' ? 'product' : 'broadcast')).contactIds.sort();
88
+ snapshot.recipient_count = snapshot.contact_ids.length;
89
+ }
90
+ else {
91
+ const contacts = body.contact_ids ?? params.generated?.contact_ids;
92
+ if (Array.isArray(contacts)) {
93
+ snapshot.contact_ids = await readFrozenContacts(contacts);
94
+ snapshot.recipient_count = snapshot.contact_ids.length;
95
+ }
96
+ else if (typeof body.to === 'string')
97
+ snapshot.recipient_count = 1;
98
+ }
99
+ snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
100
+ snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
101
+ return snapshot;
102
+ }
103
+ export function withoutApprovalSnapshot(params) {
104
+ const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
105
+ return rest;
106
+ }
package/build/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
1
2
  import { runWithToolContext } from './db.js';
2
3
  import { isApprovalGatedAction } from './lib/action-approvals.js';
3
4
  import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
@@ -377,6 +378,8 @@ export const SENDINEL_SERVER_INSTRUCTIONS = [
377
378
  'On the hosted connection, any tool call (and invoke_tool) also accepts an optional project_id to act on one project for that call only; it is checked each time, and approval-gated tools require it when the connection was switched to another project. In gateway mode, find tools with search_tools (plain words work, for example "send email" or "last campaign"), check arguments with describe_tool, then call invoke_tool.',
378
379
  ].join(' ');
379
380
  export function registerAllTools(server, options) {
381
+ if (server.server)
382
+ installApprovalCapabilityProbe(server.server);
380
383
  const rawScopes = options?.scopes ?? [];
381
384
  if (rawScopes.includes('gateway')) {
382
385
  const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.104",
3
+ "version": "1.0.105",
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",