@sendinel/mcp-server 1.0.155 → 1.0.156

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.
@@ -97,14 +97,9 @@ type ToolResult = {
97
97
  text: string;
98
98
  }>;
99
99
  };
100
- /**
101
- * Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
102
- * (no approval_id) records the exact arguments and returns the approval request without running
103
- * the handler; the follow-up call with an approved approval_id runs the handler on those same
104
- * arguments. getApprovalTier() decides when no approval is needed (autonomous mode, special
105
- * cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
106
- * Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
107
- */
100
+ export declare function markNotExecuted<R extends object>(result: R): R;
101
+ /** HTTP statuses that mean the request was refused before it changed anything. */
102
+ export declare function isPreExecutionStatus(status: number | undefined): boolean;
108
103
  export declare function withApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R, options?: {
109
104
  display?: (args: A) => Promise<ApprovalDisplay> | ApprovalDisplay;
110
105
  registeredRequestOnly?: boolean;
@@ -472,7 +472,7 @@ export async function checkApproval(approvalId, options = {}) {
472
472
  approved: true,
473
473
  status: 'approved',
474
474
  decided_by: claimed.decided_by,
475
- ...(options.action === 'send_test_email' ? { consumed_at: consumedAt } : {}),
475
+ ...(options.action && ['send_test_email', 'identify_subscriber', 'emit_contact_event'].includes(options.action) ? { consumed_at: consumedAt } : {}),
476
476
  ...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
477
477
  };
478
478
  }
@@ -487,6 +487,19 @@ function toolText(value) {
487
487
  * cases), so autonomous mode and send_broadcast's never-self-approves rule live in one place.
488
488
  * Add `approval_id: z.string().uuid().optional()` to the tool's inputSchema.
489
489
  */
490
+ /**
491
+ * Results a handler marks as "definitively did nothing" (refused or failed before any write or send).
492
+ * withApproval releases the consumed approval for these, so a failed call does not use up the pass.
493
+ */
494
+ const notExecutedResults = new WeakSet();
495
+ export function markNotExecuted(result) {
496
+ notExecutedResults.add(result);
497
+ return result;
498
+ }
499
+ /** HTTP statuses that mean the request was refused before it changed anything. */
500
+ export function isPreExecutionStatus(status) {
501
+ return status !== undefined && [0, 400, 401, 403, 404, 422, 429, 501].includes(status);
502
+ }
490
503
  export function withApproval(action, handler, options = {}) {
491
504
  return async (args, extra) => {
492
505
  const { approval_id, ...params } = args;
@@ -516,14 +529,17 @@ export function withApproval(action, handler, options = {}) {
516
529
  const result = await handler(params, extra);
517
530
  // Keep the atomic claim across the outbound hop. Only a definitive pre-acceptance
518
531
  // failure may release it; transport errors and unknown outcomes can already have sent.
519
- if (action === 'send_test_email' && approval_id && consumedAt && testSendNotAccepted(result)) {
532
+ const releasable = action === 'send_test_email' ? testSendNotAccepted(result) : notExecutedResults.has(result);
533
+ if (approval_id && consumedAt && releasable) {
520
534
  const { data: restored, error } = await approvalStore().from('action_approvals')
521
535
  .update({ status: 'approved', used_at: null })
522
- .eq('id', approval_id).eq('project_id', getProjectId()).eq('action', 'send_test_email')
536
+ .eq('id', approval_id).eq('project_id', getProjectId()).eq('action', action)
523
537
  .eq('status', 'used').eq('used_at', consumedAt)
524
538
  .select('id').maybeSingle();
525
539
  if (error || !restored) {
526
- console.error('Test-send approval could not be restored after a pre-acceptance failure.');
540
+ console.error(action === 'send_test_email'
541
+ ? 'Test-send approval could not be restored after a pre-acceptance failure.'
542
+ : `Approval for ${action} could not be restored after a pre-execution failure.`);
527
543
  }
528
544
  }
529
545
  return result;
@@ -11,7 +11,7 @@ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
11
11
  import { resolveSegmentContactIds } from '../lib/send-audience.js';
12
12
  import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
13
13
  import { logConsent, logAudit } from '../audit.js';
14
- import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
14
+ import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval, markNotExecuted, isPreExecutionStatus } from '../lib/action-approvals.js';
15
15
  import { decrypt } from '../crypto.js';
16
16
  import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
17
17
  import { diffCustomProperties } from '../lib/custom-properties-diff.js';
@@ -612,8 +612,19 @@ export function registerContactTools(server) {
612
612
  event: z.string().trim().min(1),
613
613
  data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
614
614
  idempotency_key: z.string().min(1).optional().describe('Sent as Idempotency-Key. Reuse with the same event payload when retrying.'),
615
+ consent: z.object({
616
+ site_id: z.string().uuid(),
617
+ basis: z.enum(['opt_in', 'existing_customer']),
618
+ source: z.string().trim().min(1).max(100),
619
+ collected_at: z.string().optional(),
620
+ }).optional().describe('Optional consent record for an unknown email (see docs/api-events-and-identify.md). Omit for contacts that already exist.'),
621
+ traits: z.object({
622
+ first_name: z.string().optional(),
623
+ last_name: z.string().optional(),
624
+ properties: z.record(z.string(), z.unknown()).optional(),
625
+ }).optional().describe('Optional contact traits applied with the event (see docs/api-events-and-identify.md).'),
615
626
  },
616
- }, withApproval('emit_contact_event', async ({ email, event, data, idempotency_key }) => {
627
+ }, withApproval('emit_contact_event', async ({ email, event, data, idempotency_key, consent, traits }) => {
617
628
  // Never forward raw transport, database or provider errors to the MCP caller.
618
629
  // Only known public codes survive; even a returned message may contain internal text.
619
630
  const publicCodes = new Set([
@@ -623,7 +634,12 @@ export function registerContactTools(server) {
623
634
  try {
624
635
  const result = await callSendinelV1('/api/v1/events', {
625
636
  method: 'POST',
626
- body: { email, event, ...(data !== undefined ? { data } : {}) },
637
+ body: {
638
+ email, event,
639
+ ...(data !== undefined ? { data } : {}),
640
+ ...(consent !== undefined ? { consent } : {}),
641
+ ...(traits !== undefined ? { traits } : {}),
642
+ },
627
643
  ...(idempotency_key ? { idempotencyKey: idempotency_key } : {}),
628
644
  });
629
645
  if (result.ok)
@@ -631,7 +647,8 @@ export function registerContactTools(server) {
631
647
  const payload = result.error && typeof result.error === 'object' && 'error' in result.error
632
648
  && result.error.error && typeof result.error.error === 'object' ? result.error.error : result.error;
633
649
  const code = payload && typeof payload === 'object' && 'code' in payload ? payload.code : undefined;
634
- return { ...j({ code: typeof code === 'string' && publicCodes.has(code) ? code : 'event_intake_failed', error: 'Could not emit contact event' }), isError: true };
650
+ const failed = { ...j({ code: typeof code === 'string' && publicCodes.has(code) ? code : 'event_intake_failed', error: 'Could not emit contact event' }), isError: true };
651
+ return isPreExecutionStatus(result.status) ? markNotExecuted(failed) : failed;
635
652
  }
636
653
  catch {
637
654
  return { ...j({ code: 'event_intake_failed', error: 'Could not emit contact event' }), isError: true };
@@ -654,7 +671,10 @@ export function registerContactTools(server) {
654
671
  },
655
672
  }, withApproval('identify_subscriber', async (params) => {
656
673
  const result = await callSendinelV1('/api/v1/identify', { method: 'POST', body: params });
657
- return result.ok ? j(result.data) : err(JSON.stringify(result.error));
674
+ if (result.ok)
675
+ return j(result.data);
676
+ const failed = err(JSON.stringify(result.error));
677
+ return isPreExecutionStatus(result.status) ? markNotExecuted(failed) : failed;
658
678
  }));
659
679
  server.registerTool('get_subscriber_timeline', {
660
680
  title: 'Get Subscriber Timeline',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.155",
3
+ "version": "1.0.156",
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",