@sendinel/mcp-server 1.0.166 → 1.0.167

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.
@@ -0,0 +1,18 @@
1
+ interface StoredHealth {
2
+ spf_valid?: boolean | null;
3
+ dkim_valid?: boolean | null;
4
+ dmarc_valid?: boolean | null;
5
+ check_date?: string | null;
6
+ }
7
+ /** DNS observations come only from domain_health, never inferred from registration status. */
8
+ export declare function storedDomainDns(health?: StoredHealth | null): {
9
+ spf: boolean | null;
10
+ dkim: boolean | null;
11
+ dmarc: boolean | null;
12
+ all_verified: boolean | null;
13
+ verification_source: 'stored_domain_health';
14
+ checked_at: string | null;
15
+ };
16
+ export declare function readLatestStoredDomainHealth(domainId: string): any;
17
+ export declare function storedDnsReasons(dns: ReturnType<typeof storedDomainDns>): string[];
18
+ export {};
@@ -0,0 +1,34 @@
1
+ import { db } from '../db.js';
2
+ import { getProjectId } from '../project.js';
3
+ /** DNS observations come only from domain_health, never inferred from registration status. */
4
+ export function storedDomainDns(health) {
5
+ const spf = health?.spf_valid ?? null;
6
+ const dkim = health?.dkim_valid ?? null;
7
+ const dmarc = health?.dmarc_valid ?? null;
8
+ return {
9
+ spf, dkim, dmarc,
10
+ all_verified: [spf, dkim, dmarc].every(value => typeof value === 'boolean') ? Boolean(spf && dkim && dmarc) : null,
11
+ verification_source: 'stored_domain_health',
12
+ checked_at: health?.check_date ?? null,
13
+ };
14
+ }
15
+ export function readLatestStoredDomainHealth(domainId) {
16
+ return db().schema('email').from('domain_health')
17
+ .select('spf_valid, dkim_valid, dmarc_valid, check_date, sites!inner(domain_id)')
18
+ .eq('sites.project_id', getProjectId()).eq('sites.domain_id', domainId)
19
+ .order('check_date', { ascending: false }).order('created_at', { ascending: false }).order('id', { ascending: false })
20
+ .limit(1).maybeSingle();
21
+ }
22
+ export function storedDnsReasons(dns) {
23
+ const reasons = [];
24
+ if (dns.spf === false)
25
+ reasons.push('SPF record missing');
26
+ if (dns.dkim === false)
27
+ reasons.push('DKIM not configured');
28
+ if (dns.dmarc === false)
29
+ reasons.push('DMARC record missing');
30
+ if ([dns.spf, dns.dkim, dns.dmarc].some(value => value === null)) {
31
+ reasons.push('DNS authentication has not been checked in stored domain health');
32
+ }
33
+ return reasons;
34
+ }
@@ -6,9 +6,9 @@ import { z } from 'zod';
6
6
  import { db, adminDb, requestContext } from '../db.js';
7
7
  import { getProjectId } from '../project.js';
8
8
  import { resolveProjectClient } from '../byod-client.js';
9
- import { decrypt } from '../crypto.js';
10
9
  import { logAudit } from '../audit.js';
11
10
  import { dbToolError, toolError } from '../lib/db-error.js';
11
+ import { storedDomainDns, storedDnsReasons } from '../lib/domain-health.js';
12
12
  const txt = (text) => ({ content: [{ type: 'text', text }] });
13
13
  const j = (data) => txt(JSON.stringify(data, null, 2));
14
14
  const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
@@ -579,21 +579,22 @@ export function registerAnalyticsTools(server) {
579
579
  idempotentHint: true,
580
580
  openWorldHint: false,
581
581
  },
582
- description: 'SPF/DKIM/DMARC status and warmup progress for a site',
582
+ description: 'Stored domain_health SPF/DKIM/DMARC history and warmup progress for a site. Each row adds a DNS summary with verification_source and checked_at; null means unchecked, never verified.',
583
583
  inputSchema: {
584
584
  site_id: z.string().uuid().describe('Site UUID'),
585
585
  days: z.number().int().min(1).max(90).optional().describe('Recent days to return, default 7'),
586
586
  },
587
587
  }, async ({ site_id, days = 7 }) => {
588
- const { data, error } = await db()
588
+ const { data, error } = await db().schema('email')
589
589
  .from('domain_health')
590
- .select('*')
590
+ .select('*, sites!inner(project_id)')
591
591
  .eq('site_id', site_id)
592
- .order('check_date', { ascending: false })
592
+ .eq('sites.project_id', getProjectId())
593
+ .order('check_date', { ascending: false }).order('created_at', { ascending: false }).order('id', { ascending: false })
593
594
  .limit(days);
594
595
  if (error)
595
- return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
596
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
596
+ return toolError(dbToolError(error, 'Unable to load domain health'));
597
+ return j((data ?? []).map(({ sites: _sites, ...row }) => ({ ...row, dns: storedDomainDns(row) })));
597
598
  });
598
599
  server.registerTool('get_engagement_insights', {
599
600
  title: 'Get Engagement Insights',
@@ -700,9 +701,9 @@ export function registerAnalyticsTools(server) {
700
701
  readOnlyHint: true,
701
702
  destructiveHint: false,
702
703
  idempotentHint: true,
703
- openWorldHint: true,
704
+ openWorldHint: false,
704
705
  },
705
- description: 'Run a deliverability check for a site: DNS records (SPF/DKIM/DMARC), warmup status, bounce/complaint rates, and risk assessment',
706
+ description: 'Read stored domain_health DNS observations (SPF/DKIM/DMARC), warmup status, bounce/complaint rates and risk assessment. Uses the same DNS source as refresh_domain_dns, get_domain_health and list_domains; null means unchecked. No live provider or DNS calls.',
706
707
  inputSchema: {
707
708
  site_id: z.string().uuid(),
708
709
  },
@@ -717,95 +718,25 @@ export function registerAnalyticsTools(server) {
717
718
  if (siteErr || !site)
718
719
  return txt(`Error: Site not found — ${(siteErr ? dbToolError(siteErr).error : undefined) ?? 'not found'}`);
719
720
  const domain = site.sending_domain;
720
- const dnsResults = { spf: null, dkim: null, dmarc: null };
721
- let verificationSource = 'dns_lookup';
722
- let resendDomainStatus = null;
723
- // Try Resend domain verification API first — avoids false negatives from
724
- // raw DNS lookups (Resend uses varying DKIM selectors per domain vintage).
725
- try {
726
- // Resolve Resend API key: site key → project key → env
727
- let apiKey = null;
728
- const { data: siteRow } = await adminDb()
729
- .from('sites')
730
- .select('provider_api_key_enc')
731
- .eq('id', site_id)
732
- .eq('project_id', projectId)
733
- .maybeSingle();
734
- if (siteRow?.provider_api_key_enc) {
735
- apiKey = decrypt(siteRow.provider_api_key_enc);
736
- }
737
- else {
738
- const { data: projRow } = await adminDb()
739
- .from('projects')
740
- .select('provider_api_key_enc')
741
- .eq('id', projectId)
742
- .maybeSingle();
743
- if (projRow?.provider_api_key_enc) {
744
- apiKey = decrypt(projRow.provider_api_key_enc);
745
- }
746
- else if (process.env.RESEND_API_KEY) {
747
- apiKey = process.env.RESEND_API_KEY;
748
- }
749
- }
750
- if (apiKey) {
751
- const { Resend } = await import('resend');
752
- const resend = new Resend(apiKey);
753
- const { data: domainList } = await resend.domains.list();
754
- const domains = domainList?.data ?? [];
755
- const match = domains.find((d) => d.name === domain);
756
- if (match) {
757
- resendDomainStatus = match.status;
758
- if (match.status === 'verified') {
759
- verificationSource = 'resend_api';
760
- dnsResults.spf = { found: true, verified_by: 'resend_api', note: 'Resend handles SPF authentication at infrastructure level' };
761
- dnsResults.dkim = { found: true, verified_by: 'resend_api', note: 'DKIM signing managed by Resend (selector may vary)' };
762
- dnsResults.dmarc = { found: true, verified_by: 'resend_api', note: 'Domain verified by Resend — DMARC alignment confirmed' };
763
- }
764
- }
765
- }
766
- }
767
- catch {
768
- // Resend API unavailable or key missing — fall back to DNS lookup
769
- }
770
- // Fall back to raw DNS checks if Resend API didn't confirm verification
771
- if (verificationSource === 'dns_lookup') {
772
- try {
773
- const dns = await import('dns/promises');
774
- try {
775
- const txtRecords = await dns.resolveTxt(domain);
776
- const spfRecord = txtRecords.flat().find((r) => r.startsWith('v=spf1'));
777
- dnsResults.spf = { found: !!spfRecord, record: spfRecord ?? null, includes_resend: spfRecord?.includes('include:amazonses.com') || spfRecord?.includes('include:_spf.resend.com') || false };
778
- }
779
- catch {
780
- dnsResults.spf = { found: false, error: 'No SPF record found' };
781
- }
782
- try {
783
- const dmarcRecords = await dns.resolveTxt(`_dmarc.${domain}`);
784
- const dmarcRecord = dmarcRecords.flat().find((r) => r.startsWith('v=DMARC1'));
785
- const policy = dmarcRecord?.match(/p=(\w+)/)?.[1] ?? 'unknown';
786
- dnsResults.dmarc = { found: !!dmarcRecord, record: dmarcRecord ?? null, policy, strict: policy === 'quarantine' || policy === 'reject' };
787
- }
788
- catch {
789
- dnsResults.dmarc = { found: false, error: 'No DMARC record found' };
790
- }
791
- try {
792
- const cnames = await dns.resolveCname(`resend._domainkey.${domain}`);
793
- dnsResults.dkim = { found: cnames.length > 0, cname: cnames[0] ?? null };
794
- }
795
- catch {
796
- dnsResults.dkim = { found: false, error: 'No DKIM CNAME found for resend._domainkey' };
797
- }
798
- }
799
- catch { /* dns module unavailable */ }
800
- }
801
- const { data: healthRows } = await db()
721
+ const { data: healthRows, error: healthError } = await db().schema('email')
802
722
  .from('domain_health')
803
- .select('*')
723
+ .select('*, sites!inner(project_id)')
804
724
  .eq('site_id', site_id)
725
+ .eq('sites.project_id', projectId)
805
726
  .order('check_date', { ascending: false })
727
+ .order('created_at', { ascending: false }).order('id', { ascending: false })
806
728
  .limit(14);
729
+ if (healthError)
730
+ return toolError(dbToolError(healthError, 'Unable to load domain health'));
807
731
  const health = healthRows ?? [];
808
732
  const latest = health[0];
733
+ const storedDns = storedDomainDns(latest);
734
+ const verificationSource = storedDns.verification_source;
735
+ const dnsResults = {
736
+ spf: { found: storedDns.spf, verified_by: storedDns.spf === true ? verificationSource : null, record: null, includes_resend: null, error: null, note: 'Stored SPF observation; raw DNS was not queried.' },
737
+ dkim: { found: storedDns.dkim, verified_by: storedDns.dkim === true ? verificationSource : null, cname: null, error: null, note: 'Stored DKIM observation; raw DNS was not queried.' },
738
+ dmarc: { found: storedDns.dmarc, verified_by: storedDns.dmarc === true ? verificationSource : null, record: null, policy: null, strict: null, error: null, note: 'Stored DMARC observation; raw DNS was not queried.' },
739
+ };
809
740
  const warmupDay = latest?.warmup_day ?? null;
810
741
  const warmupLimit = latest?.warmup_daily_limit ?? null;
811
742
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
@@ -821,18 +752,7 @@ export function registerAnalyticsTools(server) {
821
752
  const complained = logs.filter(isComplainedRow).length;
822
753
  const bounceRate = percentRate(bounced, totalSent, 8) ?? 0;
823
754
  const complaintRate = percentRate(complained, totalSent, 8) ?? 0;
824
- const risks = [];
825
- if (verificationSource === 'dns_lookup') {
826
- if (!dnsResults.spf?.found)
827
- risks.push('SPF record missing');
828
- if (!dnsResults.dkim?.found)
829
- risks.push('DKIM not configured');
830
- if (!dnsResults.dmarc?.found)
831
- risks.push('DMARC record missing');
832
- }
833
- if (resendDomainStatus && resendDomainStatus !== 'verified') {
834
- risks.push(`Resend domain status is '${resendDomainStatus}' — domain verification incomplete`);
835
- }
755
+ const risks = storedDnsReasons(storedDns);
836
756
  if (bounceRate > 5)
837
757
  risks.push(`HIGH bounce rate: ${bounceRate.toFixed(1)}%`);
838
758
  else if (bounceRate > 2)
@@ -846,7 +766,8 @@ export function registerAnalyticsTools(server) {
846
766
  return j({
847
767
  site: { slug: site.slug, domain, from_email: site.from_email },
848
768
  verification_source: verificationSource,
849
- resend_domain_status: resendDomainStatus,
769
+ resend_domain_status: null, // Retained for compatibility; no live Resend query is made.
770
+ checked_at: storedDns.checked_at,
850
771
  dns: dnsResults,
851
772
  warmup: { day: warmupDay, daily_limit: warmupLimit, completed: warmupDay ? warmupDay >= 43 : null },
852
773
  recent_7d: { total_sent: totalSent, bounced, complained, bounce_rate: `${bounceRate.toFixed(2)}%`, complaint_rate: `${complaintRate.toFixed(3)}%` },
@@ -190,27 +190,30 @@ async function campaignTimezoneWarnings(campaign) {
190
190
  const sendWindow = campaign.send_window ?? (campaign.send_window_inherit !== false ? site?.send_window : null);
191
191
  return sendWindow && !validTimezone(site?.timezone) ? [SEND_WINDOW_TIMEZONE_WARNING] : [];
192
192
  }
193
- async function campaignTriggerOutput(campaign) {
194
- let seenEvents;
195
- const config = normalizeTriggerConfig(campaign.trigger_config);
196
- if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length) {
197
- try {
198
- // /v1/events -> intakeContactEvent stores event_type=custom and title=event.
199
- // Read all pages: a capped sample cannot establish that an event was never seen.
200
- const rows = await readAllRows(db().from('contact_events').select('id, title')
201
- .eq('project_id', getProjectId()).eq('event_type', 'custom'));
202
- seenEvents = new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
203
- }
204
- catch {
205
- seenEvents = null;
206
- }
193
+ async function readCampaignTriggerHistory(campaigns) {
194
+ if (!campaigns.some(campaign => normalizeTriggerValue(campaign.trigger_event_name)
195
+ || normalizeTriggerConfig(campaign.trigger_config).events.length))
196
+ return undefined;
197
+ try {
198
+ // /v1/events stores event_type=custom and title=event. Read every page once per response.
199
+ const rows = await readAllRows(db().from('contact_events').select('id, title')
200
+ .eq('project_id', getProjectId()).eq('event_type', 'custom'));
201
+ return new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
207
202
  }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ function buildCampaignTriggerOutput(campaign, seenEvents) {
208
208
  return {
209
209
  trigger_kind: campaignTriggerKind(campaign),
210
210
  fires_on: buildCampaignFiresOn(campaign),
211
211
  trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign, seenEvents),
212
212
  };
213
213
  }
214
+ async function campaignTriggerOutput(campaign) {
215
+ return buildCampaignTriggerOutput(campaign, await readCampaignTriggerHistory([campaign]));
216
+ }
214
217
  function applyCampaignSendAtField(updates, sendAt, campaignType) {
215
218
  if (sendAt === undefined)
216
219
  return null;
@@ -780,14 +783,14 @@ export function registerCampaignTools(server) {
780
783
  // counts that cannot exceed total_sent. null = the people-count lookup failed (unknown, not 0).
781
784
  const rows = data ?? [];
782
785
  const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
786
+ const seenEvents = await readCampaignTriggerHistory(rows);
783
787
  const withPeople = await Promise.all(rows.map(async (row) => {
784
788
  const periods = await readCampaignPeriods(db(), getProjectId(), row);
785
789
  return {
786
790
  ...row,
787
791
  reentry_mode: row.period_key ? "per_event_period" : "once_ever",
788
792
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
789
- fires_on: buildCampaignFiresOn(row),
790
- trigger_diagnostics: buildCampaignTriggerDiagnostics(row),
793
+ ...buildCampaignTriggerOutput(row, seenEvents),
791
794
  ...campaignPeriodFields(periods, period),
792
795
  total_sent: periods?.totals?.sent ?? (engagement.ok ? engagement.sent.get(row.id) ?? 0 : null),
793
796
  unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
@@ -1734,14 +1737,27 @@ export function registerCampaignTools(server) {
1734
1737
  }
1735
1738
  const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
1736
1739
  const identity = new Set(['id', 'status', 'created_at', 'updated_at', 'campaign_steps']);
1740
+ const configurationFields = [
1741
+ 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'send_class',
1742
+ 'period_key', 'reentry_mode', 'campaign_step_branches', 'segment_id',
1743
+ 'unsub_footer_template', 'campaign_goals', 'topic', 'send_at', 'send_time', 'timezone',
1744
+ 'goal_type', 'goal_value', 'goal_action', 'goal_target_campaign_id',
1745
+ ];
1746
+ // Keep the exhaustive machine-readable inventory, with important settings first.
1737
1747
  const fields_not_copied = [...new Set([
1748
+ ...configurationFields.filter(key => key in original || [
1749
+ 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
1750
+ 'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
1751
+ ].includes(key)),
1738
1752
  ...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
1739
- 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
1740
- 'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
1741
1753
  ])];
1754
+ const counters = fields_not_copied.filter(key => /^(?:total_|unique_)|_rate$/.test(key));
1755
+ const warnings = fields_not_copied.filter(key => configurationFields.includes(key)).map(field => `${field} was not copied${field === 'trigger_config' ? ' (including where filters)' : ''}; review and configure the draft before activation.`);
1756
+ if (counters.length)
1757
+ warnings.push(`Counters and reporting statistics are reset for the draft: ${counters.join(', ')}.`);
1742
1758
  await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
1743
1759
  return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
1744
- warnings: fields_not_copied.map(field => `${field} was not copied; review and configure the draft before activation.`) });
1760
+ warnings });
1745
1761
  });
1746
1762
  server.registerTool('list_sequence_templates', {
1747
1763
  title: 'List Sequence Templates',
@@ -2446,7 +2462,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2446
2462
  idempotentHint: true,
2447
2463
  openWorldHint: false,
2448
2464
  },
2449
- description: 'Preview email through the real send renderer: final campaign/site footer, unsubscribe text and send_class scope, sender and physical address. Uses sample contact data; unresolved_tags lists remaining tags. Provide campaign_id, step_id, draft_id, or raw body_html/subject.',
2465
+ description: 'Preview email through the real send renderer: final campaign/site footer, unsubscribe text and send_class scope, sender and physical address. Uses sample contact data; unresolved_tags lists every remaining {{ opener, including malformed and unclosed tags; supported tags keep key names and malformed tags use literal text. Provide campaign_id, step_id, draft_id, or raw body_html/subject.',
2450
2466
  inputSchema: {
2451
2467
  site_id: z.string().uuid().optional().describe('Site UUID for sender tags and footers'),
2452
2468
  campaign_id: z.string().uuid().optional().describe('Campaign UUID; previews its first step in send order. Use step_id to choose a specific step.'),
@@ -2996,7 +3012,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2996
3012
  idempotentHint: true,
2997
3013
  openWorldHint: false,
2998
3014
  },
2999
- description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}}, merge tags with no fallback, placeholder text left from an import ("Not specified in source material"), links to your sending domain, stock phrases, excessive CTAs, and a missing sign-off (only when your brand kit or site sets one). Pass trigger_event_name or event_keys to also check that each merge tag has data behind it.',
3015
+ description: 'Validate an email template for variable errors and brand voice compliance. Checks for unknown {{variables}} in both subject and body as errors, merge tags with no fallback, placeholder text left from an import ("Not specified in source material"), links to your sending domain, stock phrases, excessive CTAs, and a missing sign-off (only when your brand kit or site sets one). Pass trigger_event_name or event_keys to also check that each merge tag has data behind it.',
3000
3016
  inputSchema: {
3001
3017
  html: z.string().describe('HTML email content to validate'),
3002
3018
  site_id: z.string().uuid().optional().describe('Site UUID — uses the site brand voice config for sign-off check'),
@@ -1711,7 +1711,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1711
1711
  idempotentHint: true,
1712
1712
  openWorldHint: false,
1713
1713
  },
1714
- description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
1714
+ description: 'List contacts using a saved segment_id or inline filters for engagement score, lifecycle stage, site and company. Saved segment_id and inline filters are mutually exclusive. Useful for building re-engagement campaigns or identifying high-value contacts.',
1715
1715
  inputSchema: {
1716
1716
  segment_id: z.string().uuid().optional().describe('Saved segment; mutually exclusive with inline audience filters'),
1717
1717
  lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
@@ -7,6 +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
11
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
11
12
  function asRecord(value) {
12
13
  return value && typeof value === 'object' ? value : {};
@@ -146,7 +147,7 @@ export function registerDeliveryOpsTools(server) {
146
147
  idempotentHint: true,
147
148
  openWorldHint: false,
148
149
  },
149
- description: 'Returns stored SPF/DKIM/DMARC from the latest health check; does NOT query DNS live. Use recheck_domain_dns for a live re-check.',
150
+ description: 'Returns stored SPF/DKIM/DMARC from the latest health check, with verification_source=stored_domain_health and checked_at; does NOT query DNS live. Use recheck_domain_dns for a live re-check.',
150
151
  inputSchema: { domain_id: z.string().uuid() },
151
152
  }, async ({ domain_id }) => {
152
153
  const { data, error } = await db().from('domains')
@@ -158,22 +159,10 @@ export function registerDeliveryOpsTools(server) {
158
159
  return toolError(dbToolError(error, 'Unable to load domain'));
159
160
  if (!data)
160
161
  return toolError({ error: 'Domain not found', code: 'NOT_FOUND' });
161
- const { data: health, error: healthError } = await db().from('domain_health')
162
- .select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
163
- .eq('sites.project_id', getProjectId())
164
- .eq('sites.domain_id', domain_id)
165
- .order('check_date', { ascending: false })
166
- .limit(1)
167
- .maybeSingle();
162
+ const { data: health, error: healthError } = await readLatestStoredDomainHealth(domain_id);
168
163
  if (healthError)
169
164
  return toolError(dbToolError(healthError, 'Unable to load domain health'));
170
- const spf = health?.spf_valid ?? null;
171
- const dkim = health?.dkim_valid ?? null;
172
- const dmarc = health?.dmarc_valid ?? null;
173
- const allVerified = [spf, dkim, dmarc].every((value) => typeof value === 'boolean')
174
- ? Boolean(spf && dkim && dmarc)
175
- : null;
176
- return j({ spf, dkim, dmarc, all_verified: allVerified });
165
+ return j(storedDomainDns(health));
177
166
  });
178
167
  server.registerTool('recheck_domain_dns', {
179
168
  title: 'Recheck Domain DNS Live',
@@ -802,7 +791,7 @@ export function registerDeliveryOpsTools(server) {
802
791
  idempotentHint: true,
803
792
  openWorldHint: false,
804
793
  },
805
- description: 'List sending domains with Sendinel readiness (ready true/false/null, reasons, provider_detail). Raw provider values, including EE tracking_status_raw, are labelled detail only and do not drive readiness.',
794
+ description: 'List sending domains with stored domain_health DNS observations and Sendinel readiness (ready true/false/null, reasons, provider_detail). Registration status, provider and verified_at remain saved metadata; unknown DNS never implies ready. Raw provider values, including EE tracking_status_raw, are labelled detail only.',
806
795
  inputSchema: { site_id: z.string().uuid().optional() },
807
796
  }, async ({ site_id }) => {
808
797
  const projectId = getProjectId();
@@ -829,9 +818,20 @@ export function registerDeliveryOpsTools(server) {
829
818
  return j(dbToolError(error));
830
819
  if (!data)
831
820
  return j({ error: 'Domain query returned no result' });
832
- return j({ domains: data.map((domain) => ({
833
- ...domain, readiness: computeDomainReadiness(domain),
834
- })), count: data.length });
821
+ const domains = [];
822
+ for (const domain of data) {
823
+ const { data: health, error: healthError } = await readLatestStoredDomainHealth(domain.id);
824
+ if (healthError)
825
+ return toolError(dbToolError(healthError, 'Unable to load domain health'));
826
+ const dns = storedDomainDns(health);
827
+ const readiness = computeDomainReadiness(domain);
828
+ const auth = [dns.spf, dns.dkim, dns.dmarc];
829
+ readiness.ready = readiness.ready === false || auth.includes(false) ? false
830
+ : readiness.ready === null || auth.includes(null) ? null : true;
831
+ readiness.reasons.push(...storedDnsReasons(dns));
832
+ domains.push({ ...domain, dns, readiness });
833
+ }
834
+ return j({ domains, count: domains.length });
835
835
  });
836
836
  server.registerTool('register_domain', {
837
837
  title: 'Register Domain',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.166",
3
+ "version": "1.0.167",
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",