@sendinel/mcp-server 1.0.191 → 1.0.193

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
@@ -59,7 +59,9 @@ Sendinel currently exposes 35 tool groups and 349 tools (gateway mode exposes 4
59
59
  | `webhooks` | 5 | Webhook subscription management |
60
60
 
61
61
  `cancel_approval({approval_id})` rejects an unexpired, unused pending/approved
62
- request in the current project for any write-scoped credential.
62
+ request in the current project for any write-scoped credential. It returns
63
+ `status: "rejected"` (kept for compatibility) plus `outcome: "cancelled"` and
64
+ `cancelled: true`.
63
65
  `approve_approval({approval_id})` requires the person's own live OAuth connection
64
66
  (`connection_role: NULL`) and its own pending request in that project. Tester,
65
67
  agent, API key, service-token and scoped-JWT credentials cannot approve. Sends
@@ -100,6 +100,8 @@ export declare function checkApproval(approvalId: string, options?: {
100
100
  }): Promise<Omit<Awaited<ReturnType<typeof checkApprovalState>>, 'decision_channel'> & {
101
101
  decision_channel?: string;
102
102
  decision_evidence?: DecisionEvidence;
103
+ outcome?: 'cancelled';
104
+ cancelled?: true;
103
105
  }>;
104
106
  declare function checkApprovalState(approvalId: string, options?: {
105
107
  consume?: boolean;
@@ -469,7 +469,7 @@ export async function checkApproval(approvalId, options = {}) {
469
469
  .eq('action', 'approval.cancelled').limit(1);
470
470
  const cancel = data?.[0];
471
471
  if (!error && cancel?.actor_id && typeof cancel.details?.channel === 'string') {
472
- return { ...result, decided_by: cancel.actor_id, decision_channel: cancel.details.channel };
472
+ return { ...result, decided_by: cancel.actor_id, decision_channel: cancel.details.channel, outcome: 'cancelled', cancelled: true };
473
473
  }
474
474
  }
475
475
  catch { /* A failed audit read cannot grant authority or change the status. */ }
@@ -4,6 +4,8 @@ export declare function cancelApproval(approvalId: string, transportClientName?:
4
4
  } | {
5
5
  approval_id: string;
6
6
  status: 'rejected';
7
+ outcome: 'cancelled';
8
+ cancelled: true;
7
9
  }>;
8
10
  export declare function approveApproval(approvalId: string, transportClientName?: string): Promise<{
9
11
  code: string;
@@ -53,12 +53,12 @@ export async function cancelApproval(approvalId, transportClientName) {
53
53
  project_id: projectId, actor_type: actor.actorType ?? actor.authKind ?? 'api_key',
54
54
  actor_id: actor.actorId ?? actor.keyId ?? actor.oauthTokenId ?? null,
55
55
  action: 'approval.cancelled', resource_type: 'approval', resource_id: approvalId,
56
- details: { status: 'rejected', channel, client_name: name, auth_kind: actor.authKind ?? null,
56
+ details: { status: 'rejected', outcome: 'cancelled', channel, client_name: name, auth_kind: actor.authKind ?? null,
57
57
  oauth_token_id: actor.oauthTokenId ?? null, api_key_id: actor.keyId ?? null },
58
58
  });
59
59
  if (auditError)
60
60
  return refuse('approval_audit_failed', 'Approval cancelled, but audit failed. Check its status before retrying.');
61
- return { approval_id: approvalId, status: 'rejected' };
61
+ return { approval_id: approvalId, status: 'rejected', outcome: 'cancelled', cancelled: true };
62
62
  }
63
63
  catch {
64
64
  return refuse('approval_failed', 'Cancellation failed. Check approval status before retrying.');
@@ -15,4 +15,17 @@ export declare function storedDomainDns(health?: StoredHealth | null): {
15
15
  };
16
16
  export declare function readLatestStoredDomainHealth(domainId: string): any;
17
17
  export declare function storedDnsReasons(dns: ReturnType<typeof storedDomainDns>): string[];
18
+ export type DnsCheckState = 'not_checked' | 'pass' | 'fail';
19
+ /**
20
+ * Explicit, additive rendering of a stored DNS observation. null never means pass or fail:
21
+ * it is 'not_checked'. dns_note says why this can differ from the domain's `verified` status.
22
+ */
23
+ export declare function dnsCheckSummary(dns: ReturnType<typeof storedDomainDns>): {
24
+ spf: DnsCheckState;
25
+ dkim: DnsCheckState;
26
+ dmarc: DnsCheckState;
27
+ dns_check_status: string;
28
+ checked_at: string;
29
+ dns_note: string | null;
30
+ };
18
31
  export {};
@@ -32,3 +32,20 @@ export function storedDnsReasons(dns) {
32
32
  }
33
33
  return reasons;
34
34
  }
35
+ const dnsState = (value) => value === null ? 'not_checked' : value ? 'pass' : 'fail';
36
+ /**
37
+ * Explicit, additive rendering of a stored DNS observation. null never means pass or fail:
38
+ * it is 'not_checked'. dns_note says why this can differ from the domain's `verified` status.
39
+ */
40
+ export function dnsCheckSummary(dns) {
41
+ const states = { spf: dnsState(dns.spf), dkim: dnsState(dns.dkim), dmarc: dnsState(dns.dmarc) };
42
+ const values = Object.values(states);
43
+ const dns_check_status = values.every(s => s === 'not_checked') ? 'not_checked'
44
+ : values.includes('not_checked') ? 'partially_checked' : 'checked';
45
+ const when = dns.checked_at ? `last DNS check ${dns.checked_at}` : 'DNS never checked (checked_at: never)';
46
+ const basis = 'Domain "verified" (list_domains status) is the provider registration status; SPF/DKIM/DMARC come only from the last stored DNS check';
47
+ const dns_note = dns_check_status === 'checked' && !Object.values(states).includes('fail') ? null
48
+ : dns_check_status === 'not_checked' ? `${basis}. ${when}: not_checked means unknown, not failing. Run refresh_domain_dns.`
49
+ : `${basis}. ${when}; not_checked means unknown, not failing${values.includes('fail') ? '; fail means the stored DNS check found a problem' : ''}.`;
50
+ return { ...states, dns_check_status, checked_at: dns.checked_at ?? 'never', dns_note };
51
+ }
@@ -7,6 +7,7 @@ import { getProjectId } from '../project.js';
7
7
  import { toolError, dbToolError } from '../lib/db-error.js';
8
8
  import { buildVoicePrompt } from '../lib/voice.js';
9
9
  import { getAnthropicModelId } from '../anthropic-model.js';
10
+ import { storedDomainDns, dnsCheckSummary } from '../lib/domain-health.js';
10
11
  import { dkimRemediationText } from '../lib/email-provider.js';
11
12
  const txt = (text) => ({ content: [{ type: 'text', text }] });
12
13
  const j = (data) => txt(JSON.stringify(data, null, 2));
@@ -641,7 +642,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
641
642
  // 2. Fetch latest domain_health
642
643
  const { data: healthRows, error: healthError } = await db()
643
644
  .from('domain_health')
644
- .select('spf_valid, dkim_valid, dmarc_valid, bounce_rate, complaint_rate, warmup_day, warmup_daily_limit, delivery_rate, total_sent')
645
+ .select('check_date, spf_valid, dkim_valid, dmarc_valid, bounce_rate, complaint_rate, warmup_day, warmup_daily_limit, delivery_rate, total_sent')
645
646
  .eq('site_id', site_id)
646
647
  .order('check_date', { ascending: false })
647
648
  .limit(1);
@@ -693,6 +694,10 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
693
694
  issues.push({ type: 'elevated_complaint', severity: 'high', detail: `Complaint rate ${complaintRate}% approaching danger zone` });
694
695
  if (spfFailed || dkimFailed)
695
696
  issues.push({ type: 'dns_invalid', severity: 'high', detail: `Missing DNS: ${spfFailed ? 'SPF' : ''}${spfFailed && dkimFailed ? ' + ' : ''}${dkimFailed ? 'DKIM' : ''}` });
697
+ const storedDns = storedDomainDns(health);
698
+ const dnsUnchecked = [spfValid, dkimValid, dmarcValid].some(v => v === null);
699
+ if (dnsUnchecked)
700
+ issues.push({ type: 'dns_unchecked', severity: 'low', detail: 'SPF/DKIM/DMARC have not been checked in stored domain health (unknown, not failing). Run refresh_domain_dns.' });
696
701
  if (dmarcFailed)
697
702
  issues.push({ type: 'no_dmarc', severity: 'medium', detail: 'DMARC record missing or invalid' });
698
703
  if (unchecked.length)
@@ -806,7 +811,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
806
811
  complaint_rate: `${complaintRate}%`,
807
812
  open_rate: `${openRate}%`,
808
813
  },
809
- dns: { spf_valid: spfValid, dkim_valid: dkimValid, dmarc_valid: dmarcValid },
814
+ dns: { spf_valid: spfValid, dkim_valid: dkimValid, dmarc_valid: dmarcValid, ...dnsCheckSummary(storedDns), verification_source: storedDns.verification_source },
810
815
  warmup: warmupDay != null ? { day: warmupDay, daily_limit: warmupDailyLimit } : null,
811
816
  new_suppressions_7d: newSuppressions ?? 0,
812
817
  issues,
@@ -9,7 +9,7 @@ import { getProjectId } from '../project.js';
9
9
  import { resolveProjectClient } from '../byod-client.js';
10
10
  import { logAudit } from '../audit.js';
11
11
  import { dbToolError, toolError } from '../lib/db-error.js';
12
- import { storedDomainDns, storedDnsReasons } from '../lib/domain-health.js';
12
+ import { storedDomainDns, storedDnsReasons, dnsCheckSummary } from '../lib/domain-health.js';
13
13
  const txt = (text) => ({ content: [{ type: 'text', text }] });
14
14
  const j = (data) => txt(JSON.stringify(data, null, 2));
15
15
  const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
@@ -597,7 +597,7 @@ export function registerAnalyticsTools(server) {
597
597
  .limit(days);
598
598
  if (error)
599
599
  return toolError(dbToolError(error, 'Unable to load domain health'));
600
- return j((data ?? []).map(({ sites: _sites, ...row }) => ({ ...row, dns: storedDomainDns(row) })));
600
+ return j((data ?? []).map(({ sites: _sites, ...row }) => ({ ...row, dns: storedDomainDns(row), dns_check: dnsCheckSummary(storedDomainDns(row)) })));
601
601
  });
602
602
  server.registerTool('get_engagement_insights', {
603
603
  title: 'Get Engagement Insights',
@@ -772,6 +772,7 @@ export function registerAnalyticsTools(server) {
772
772
  resend_domain_status: null, // Retained for compatibility; no live Resend query is made.
773
773
  checked_at: storedDns.checked_at,
774
774
  dns: dnsResults,
775
+ dns_check: dnsCheckSummary(storedDns),
775
776
  warmup: { day: warmupDay, daily_limit: warmupLimit, completed: warmupDay ? warmupDay >= 43 : null },
776
777
  recent_7d: { total_sent: totalSent, bounced, complained, bounce_rate: `${bounceRate.toFixed(2)}%`, complaint_rate: `${complaintRate.toFixed(3)}%` },
777
778
  health_trend: health.slice(0, 7).map((h) => ({ date: h.check_date, sent: h.total_sent, delivery_rate: h.delivery_rate, bounce_rate: h.bounce_rate })),
@@ -23,7 +23,7 @@ export function registerApprovalTools(server, options) {
23
23
  });
24
24
  server.registerTool('cancel_approval', {
25
25
  title: 'Cancel Approval',
26
- description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected and records who cancelled and the request channel in the audit; check_approval returns that cancellation attribution. Cannot approve or undo cancellation.',
26
+ description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Marks it cancelled (stored and reported as status `rejected` for backward compatibility; `outcome: "cancelled"` and `cancelled: true` distinguish a withdrawal from a person’s rejection) and records who cancelled and the request channel in the audit; check_approval returns that cancellation attribution. Cannot approve or undo cancellation.',
27
27
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
28
28
  inputSchema: { approval_id: z.string().uuid() },
29
29
  }, async ({ approval_id }) => {
@@ -59,7 +59,7 @@ export function registerApprovalTools(server, options) {
59
59
  idempotentHint: true,
60
60
  openWorldHint: false,
61
61
  },
62
- description: 'Check the status of a pending action approval. Use this to poll after requesting an action that requires human approval. Returns approved/pending/rejected/expired.',
62
+ description: 'Check the status of a pending action approval. Use this to poll after requesting an action that requires human approval. Returns approved/pending/rejected/expired; a cancelled approval keeps status `rejected` and adds `outcome: "cancelled"` and `cancelled: true`.',
63
63
  inputSchema: {
64
64
  approval_id: z.string().uuid().describe('The approval ID returned by the original tool call'),
65
65
  },
@@ -7,7 +7,7 @@ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestA
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { getProjectId } from '../project.js';
9
9
  import { dbToolError, toolError } from '../lib/db-error.js';
10
- import { readLatestStoredDomainHealth, storedDomainDns, storedDnsReasons } from '../lib/domain-health.js';
10
+ import { readLatestStoredDomainHealth, storedDomainDns, storedDnsReasons, dnsCheckSummary } from '../lib/domain-health.js';
11
11
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
12
12
  function asRecord(value) {
13
13
  return value && typeof value === 'object' ? value : {};
@@ -829,7 +829,7 @@ export function registerDeliveryOpsTools(server) {
829
829
  readiness.ready = readiness.ready === false || auth.includes(false) ? false
830
830
  : readiness.ready === null || auth.includes(null) ? null : true;
831
831
  readiness.reasons.push(...storedDnsReasons(dns));
832
- domains.push({ ...domain, dns, readiness });
832
+ domains.push({ ...domain, dns, readiness, registration_status: domain.status, dns_check: dnsCheckSummary(dns) });
833
833
  }
834
834
  return j({ domains, count: domains.length });
835
835
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.191",
3
+ "version": "1.0.193",
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",