@sendinel/mcp-server 1.0.45 → 1.0.46

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.
@@ -5,6 +5,7 @@ import { getProjectId } from '../project.js';
5
5
  import { buildVoicePrompt } from '../lib/voice.js';
6
6
  import { getAnthropicModelId } from '../anthropic-model.js';
7
7
  import { dkimRemediationText } from '../lib/email-provider.js';
8
+ import { readAllRows } from '@sendinel/domain';
8
9
  const txt = (text) => ({ content: [{ type: 'text', text }] });
9
10
  const j = (data) => txt(JSON.stringify(data, null, 2));
10
11
  const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -77,8 +78,8 @@ export function registerAdvisorTools(server) {
77
78
  const recommendations = [];
78
79
  let skipped = 0;
79
80
  for (const campaign of campaigns ?? []) {
80
- const { data: logs } = await db().from('email_log').select('status').eq('project_id', projectId).eq('campaign_id', campaign.id).gte('created_at', since).limit(10000);
81
- const rows = logs ?? [];
81
+ const rows = await readAllRows(db().from('email_log').select('status')
82
+ .eq('project_id', projectId).eq('campaign_id', campaign.id).gte('created_at', since));
82
83
  const sends = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
83
84
  if (sends < 100) {
84
85
  skipped++;
@@ -129,22 +130,20 @@ export function registerAdvisorTools(server) {
129
130
  .from('email_log')
130
131
  .select('status, created_at')
131
132
  .eq('project_id', projectId)
132
- .gte('created_at', periodStart.toISOString())
133
- .limit(10000);
133
+ .gte('created_at', periodStart.toISOString());
134
134
  if (site_id)
135
135
  currentQuery = currentQuery.eq('site_id', site_id);
136
- const { data: currentRows } = await currentQuery;
136
+ const currentRows = await readAllRows(currentQuery);
137
137
  // 2. Prior period
138
138
  let priorQuery = db()
139
139
  .from('email_log')
140
140
  .select('status, created_at')
141
141
  .eq('project_id', projectId)
142
142
  .gte('created_at', priorStart.toISOString())
143
- .lt('created_at', periodStart.toISOString())
144
- .limit(10000);
143
+ .lt('created_at', periodStart.toISOString());
145
144
  if (site_id)
146
145
  priorQuery = priorQuery.eq('site_id', site_id);
147
- const { data: priorRows } = await priorQuery;
146
+ const priorRows = await readAllRows(priorQuery);
148
147
  const current = computeMetrics(currentRows ?? []);
149
148
  const prior = computeMetrics(priorRows ?? []);
150
149
  // 3. Domain health (latest per site)
@@ -328,11 +327,10 @@ export function registerAdvisorTools(server) {
328
327
  .from('email_log')
329
328
  .select('subject, status')
330
329
  .eq('project_id', projectId)
331
- .in('status', ['delivered', 'opened', 'clicked'])
332
- .limit(5000);
330
+ .in('status', ['delivered', 'opened', 'clicked']);
333
331
  if (site_id)
334
332
  logQuery = logQuery.eq('site_id', site_id);
335
- const { data: logRows } = await logQuery;
333
+ const logRows = await readAllRows(logQuery);
336
334
  // Aggregate by subject
337
335
  const subjectStats = new Map();
338
336
  for (const row of logRows ?? []) {
@@ -444,14 +442,13 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
444
442
  const days = daysParam ?? 90;
445
443
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
446
444
  // 1. Query email_log with campaign step subjects
447
- const { data: logRows } = await db()
445
+ const logRows = await readAllRows(db()
448
446
  .from('email_log')
449
447
  .select('campaign_id, subject, status')
450
448
  .eq('project_id', projectId)
451
449
  .eq('site_id', site_id)
452
450
  .gte('created_at', since)
453
- .not('campaign_id', 'is', null)
454
- .limit(10000);
451
+ .not('campaign_id', 'is', null));
455
452
  if (!logRows || logRows.length < 20) {
456
453
  return j({
457
454
  error: 'insufficient_data',
@@ -565,16 +562,18 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
565
562
  // 1. Get contact IDs for segment filter
566
563
  let contactFilter = null;
567
564
  if (segment_id) {
568
- const { data: segContacts } = await db()
565
+ const segContacts = await readAllRows(db()
569
566
  .from('subscribers')
570
567
  .select('id')
571
- .eq('project_id', projectId)
572
- .limit(5000);
568
+ .eq('project_id', projectId));
573
569
  // For now, we pass all contacts and filter at query time
574
570
  // A proper segment query would use segment-query.ts, but MCP server
575
571
  // doesn't have that module. We filter by site instead.
576
- contactFilter = (segContacts ?? []).map((c) => c.id);
572
+ contactFilter = segContacts.map((c) => c.id);
577
573
  }
574
+ const coverage = contactFilter
575
+ ? { contacts_matching: contactFilter.length, contacts_used: Math.min(contactFilter.length, 500), complete: contactFilter.length <= 500 }
576
+ : { contacts_matching: null, contacts_used: null, complete: true };
578
577
  // 2. Query email_log
579
578
  let query = db()
580
579
  .from('email_log')
@@ -582,16 +581,16 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
582
581
  .eq('project_id', projectId)
583
582
  .eq('site_id', site_id)
584
583
  .gte('created_at', since)
585
- .in('status', ['delivered', 'opened', 'clicked'])
586
- .limit(10000);
584
+ .in('status', ['delivered', 'opened', 'clicked']);
587
585
  if (contactFilter) {
588
586
  query = query.in('contact_id', contactFilter.slice(0, 500));
589
587
  }
590
- const { data: rows } = await query;
588
+ const rows = await readAllRows(query);
591
589
  if (!rows || rows.length < 30) {
592
590
  return j({
593
591
  error: 'insufficient_data',
594
592
  message: `Only ${rows?.length ?? 0} qualifying emails found in the last ${days} days. Need at least 30 for meaningful analysis.`,
593
+ coverage,
595
594
  });
596
595
  }
597
596
  // 3. Build buckets
@@ -610,6 +609,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
610
609
  metric: metric ?? 'open_rate',
611
610
  period_days: days,
612
611
  total_emails_analyzed: rows.length,
612
+ coverage,
613
613
  best_hours: best3Hours,
614
614
  worst_hours: worst3Hours,
615
615
  best_days: best3Days,
@@ -1,4 +1,4 @@
1
- import { fetchAllRows } from '@sendinel/domain';
1
+ import { fetchAllRows, readAllRows } from '@sendinel/domain';
2
2
  import { z } from 'zod';
3
3
  import { db, adminDb, requestContext } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
@@ -323,10 +323,14 @@ export function registerAnalyticsTools(server) {
323
323
  inputSchema: { period_days: z.number().int().min(1).max(365).optional().default(30) },
324
324
  }, async ({ period_days = 30 }) => {
325
325
  const since = new Date(Date.now() - period_days * 86400000).toISOString();
326
- const { data, error } = await db().from('email_log').select('site_id, status, sent_at, created_at').eq('project_id', getProjectId()).gte('created_at', since).limit(20000);
327
- if (error)
328
- return j({ error: error.message });
329
- const rows = data ?? [];
326
+ let rows;
327
+ try {
328
+ rows = await readAllRows(db().from('email_log').select('site_id, status, sent_at, created_at')
329
+ .eq('project_id', getProjectId()).gte('created_at', since));
330
+ }
331
+ catch (readError) {
332
+ return j({ error: String(readError) });
333
+ }
330
334
  const totals = computeAnalyticsMetrics(rows);
331
335
  const bySite = new Map();
332
336
  for (const row of rows) {
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
3
+ import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, readAllRows, } from '@sendinel/domain';
4
4
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
5
5
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
6
6
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -336,21 +336,24 @@ async function attachBroadcastToExisting(input, match) {
336
336
  .select('contact_id')
337
337
  .eq('campaign_id', match.id)
338
338
  .in('contact_id', ids)
339
- .limit(10000);
339
+ // One enrollment per campaign/contact, and ids has at most 200 entries.
340
+ .limit(200);
340
341
  if (enrolledErr)
341
342
  return { ok: false, error: enrolledErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
342
343
  for (const row of enrolledRows ?? [])
343
344
  skip.add(row.contact_id);
344
- const { data: logRows, error: logErr } = await db()
345
- .from('email_log')
346
- .select('contact_id, status')
347
- .eq('project_id', input.projectId)
348
- .eq('campaign_id', match.id)
349
- .in('contact_id', ids)
350
- .limit(10000);
351
- if (logErr)
352
- return { ok: false, error: logErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
353
- for (const row of logRows ?? [])
345
+ let logRows;
346
+ try {
347
+ logRows = await readAllRows(db().from('email_log')
348
+ .select('contact_id, status')
349
+ .eq('project_id', input.projectId)
350
+ .eq('campaign_id', match.id)
351
+ .in('contact_id', ids));
352
+ }
353
+ catch (readError) {
354
+ return { ok: false, error: String(readError), code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
355
+ }
356
+ for (const row of logRows)
354
357
  if (sentStatuses.has(row.status))
355
358
  skip.add(row.contact_id);
356
359
  }
@@ -1,7 +1,7 @@
1
1
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
3
3
  import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
4
- import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, } from '@sendinel/domain';
4
+ import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
5
5
  import { z } from 'zod';
6
6
  import { db, projectClientForWriteback } from '../db.js';
7
7
  import { getProjectId } from '../project.js';
@@ -625,6 +625,9 @@ export function registerContactTools(server) {
625
625
  }, async () => {
626
626
  const projectId = getProjectId();
627
627
  const cutoff = daysAgo(90);
628
+ // Deliberately NOT paged in #3755: the update below REPLACES each contact's tags with ['cold']
629
+ // instead of adding it. Paging would widen that data loss to every inactive contact. Fix the
630
+ // overwrite first (#3834), then page this read.
628
631
  const { data: contacts } = await db().from('subscribers').select('id, email, tags, last_engaged_at').eq('project_id', projectId).lt('last_engaged_at', cutoff).limit(1000);
629
632
  const ids = (contacts ?? []).map((contact) => contact.id);
630
633
  if (ids.length) {
@@ -647,12 +650,15 @@ export function registerContactTools(server) {
647
650
  },
648
651
  }, async ({ siteId, lookbackDays = 180 }) => {
649
652
  const projectId = getProjectId();
650
- const { data: subs } = await db()
651
- .from('site_subscriptions')
652
- .select('contact_id')
653
- .eq('site_id', siteId)
654
- .eq('subscribed', true);
655
- const ids = (subs ?? []).map((s) => s.contact_id);
653
+ let subs;
654
+ try {
655
+ subs = await readAllRows(db().from('site_subscriptions')
656
+ .select('contact_id').eq('site_id', siteId).eq('subscribed', true));
657
+ }
658
+ catch (readError) {
659
+ return txt(`Error: ${String(readError)}`);
660
+ }
661
+ const ids = subs.map((s) => s.contact_id);
656
662
  if (!ids.length)
657
663
  return j({
658
664
  summary: { total: 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 },
@@ -661,15 +667,17 @@ export function registerContactTools(server) {
661
667
  recommendation: 'No subscribed contacts found for this site.',
662
668
  lookback_days: lookbackDays,
663
669
  });
664
- const { data: contacts, error } = await db()
665
- .from('subscribers')
666
- .select('id, email, last_engaged_at, total_opens, total_clicks, total_emails_sent, custom_properties')
667
- .eq('project_id', projectId)
668
- .eq('global_unsubscribed', false)
669
- .in('id', ids)
670
- .limit(5000);
671
- if (error)
672
- return txt(`Error: ${error.message}`);
670
+ let contacts = [];
671
+ try {
672
+ for (const batch of chunkIds(ids)) {
673
+ contacts.push(...await readAllRows(db().from('subscribers')
674
+ .select('id, email, last_engaged_at, total_opens, total_clicks, total_emails_sent, custom_properties')
675
+ .eq('project_id', projectId).eq('global_unsubscribed', false).in('id', batch)));
676
+ }
677
+ }
678
+ catch (readError) {
679
+ return txt(`Error: ${String(readError)}`);
680
+ }
673
681
  const summary = { total: contacts?.length ?? 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 };
674
682
  const sunset = [];
675
683
  const payingLowEngagement = [];
@@ -2117,15 +2125,17 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2117
2125
  const CONTACT_SCAN_LIMIT = 2000;
2118
2126
  // #3604: PostgREST returns at most 1,000 rows per request, so a single
2119
2127
  // .limit(2000) query only ever read 1,000. Read in pages up to the limit.
2120
- const SCAN_PAGE = 1000;
2121
2128
  let siteIds = null;
2122
2129
  if (site_id) {
2123
- const { data: subs } = await db()
2124
- .from('site_subscriptions')
2125
- .select('contact_id')
2126
- .eq('site_id', site_id)
2127
- .eq('subscribed', true);
2128
- const ids = (subs ?? []).map((s) => s.contact_id);
2130
+ let subs;
2131
+ try {
2132
+ subs = await readAllRows(db().from('site_subscriptions')
2133
+ .select('contact_id').eq('site_id', site_id).eq('subscribed', true));
2134
+ }
2135
+ catch (readError) {
2136
+ return txt(`Error: ${String(readError)}`);
2137
+ }
2138
+ const ids = subs.map((s) => s.contact_id);
2129
2139
  if (!ids.length)
2130
2140
  return j({ duplicate_groups: [], total_groups: 0, coverage: { contacts_scanned: 0, contacts_matching: 0, contact_limit: CONTACT_SCAN_LIMIT, complete: true } });
2131
2141
  siteIds = ids;
@@ -2146,7 +2156,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2146
2156
  catch {
2147
2157
  byodCutoff = null;
2148
2158
  }
2149
- const pageQuery = (from) => {
2159
+ const pageQuery = (from, to, ids) => {
2150
2160
  let q = db()
2151
2161
  .from('subscribers')
2152
2162
  .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source', { count: 'exact' })
@@ -2159,33 +2169,37 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2159
2169
  // no longer saw are hidden there.
2160
2170
  if (byodCutoff)
2161
2171
  q = q.or(`source.neq.database_import,updated_at.gte.${byodCutoff}`);
2162
- if (siteIds)
2163
- q = q.in('id', siteIds);
2172
+ if (ids)
2173
+ q = q.in('id', ids);
2164
2174
  return q.order('created_at', { ascending: true }).order('id', { ascending: true })
2165
- .range(from, Math.min(from + SCAN_PAGE, CONTACT_SCAN_LIMIT) - 1);
2175
+ .range(from, to);
2166
2176
  };
2167
- const contacts = [];
2168
- let count = null;
2169
- let error = null;
2170
- for (let from = 0; from < CONTACT_SCAN_LIMIT; from += SCAN_PAGE) {
2171
- const res = await pageQuery(from);
2172
- if (res.error) {
2173
- error = res.error;
2174
- break;
2177
+ const scanChunk = (ids) => readAllPages((from, to) => pageQuery(from, to, ids), { cap: CONTACT_SCAN_LIMIT });
2178
+ let scan;
2179
+ if (siteIds && siteIds.length > 200) {
2180
+ const all = [];
2181
+ let total = 0;
2182
+ for (const ids of chunkIds(siteIds)) {
2183
+ const part = await scanChunk(ids);
2184
+ if (!part.ok)
2185
+ return txt(`Error: ${part.error.message}`);
2186
+ all.push(...part.rows);
2187
+ total = total === null || part.total === null ? null : total + part.total;
2175
2188
  }
2176
- if (from === 0)
2177
- count = typeof res.count === 'number' ? res.count : null;
2178
- const rows = res.data ?? [];
2179
- contacts.push(...rows);
2180
- if (rows.length < SCAN_PAGE)
2181
- break;
2189
+ all.sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')) || String(a.id).localeCompare(String(b.id)));
2190
+ const rows = all.slice(0, CONTACT_SCAN_LIMIT);
2191
+ scan = { ok: true, rows, cap: CONTACT_SCAN_LIMIT, total, complete: total !== null && total <= rows.length };
2182
2192
  }
2183
- if (error)
2184
- return txt(`Error: ${error.message}`);
2193
+ else {
2194
+ scan = await scanChunk(siteIds);
2195
+ }
2196
+ if (!scan.ok)
2197
+ return txt(`Error: ${scan.error.message}`);
2198
+ const contacts = scan.rows;
2185
2199
  // #3604: say when the scan stopped at the limit instead of presenting a partial scan as complete.
2186
2200
  const scanned = contacts?.length ?? 0;
2187
- const matching = typeof count === 'number' ? count : null;
2188
- const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null : matching <= scanned };
2201
+ const matching = scan.total;
2202
+ const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null : scan.complete };
2189
2203
  if (!contacts?.length)
2190
2204
  return j({ duplicate_groups: [], total_groups: 0, coverage });
2191
2205
  // The matching rule is shared with the dashboard screen (@sendinel/domain, #3743/#3750).
@@ -2,16 +2,17 @@ import { z } from 'zod';
2
2
  import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  import { getAnthropicModelId } from '../anthropic-model.js';
5
+ import { readAllRows } from '@sendinel/domain';
5
6
  const txt = (text) => ({ content: [{ type: 'text', text }] });
6
7
  const j = (data) => txt(JSON.stringify(data, null, 2));
7
8
  async function engagementSnapshot(projectId) {
8
9
  const since = new Date(Date.now() - 7 * 86400000).toISOString();
9
- const [{ data: logs }, { count: audienceSize }, { data: segments }] = await Promise.all([
10
- db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since).limit(10000),
10
+ const [logs, { count: audienceSize }, { data: segments }] = await Promise.all([
11
+ readAllRows(db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since)),
11
12
  db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId).eq('is_test', false).eq('global_unsubscribed', false),
12
13
  db().from('segments').select('id, name, subscriber_count:cached_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
13
14
  ]);
14
- const rows = logs ?? [];
15
+ const rows = logs;
15
16
  const sent = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
16
17
  const opened = rows.filter((row) => row.status === 'opened' || row.status === 'clicked').length;
17
18
  const clicked = rows.filter((row) => row.status === 'clicked').length;
@@ -5,6 +5,7 @@ import { adminDb, db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
+ import { readAllRows } from '@sendinel/domain';
8
9
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
9
10
  const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
10
11
  async function callProviderSwitchApi(path, init) {
@@ -636,41 +637,40 @@ export function registerOrgTools(server) {
636
637
  const fetchWindow = async (fromDate, toDate) => {
637
638
  let query = db()
638
639
  .from('audit_log')
639
- .select('action, actor_id, actor_type, created_at', { count: 'exact' })
640
+ .select('action, actor_id, actor_type, created_at')
640
641
  .eq('project_id', projectId)
641
642
  .gte('created_at', `${fromDate}T00:00:00.000Z`)
642
643
  .lte('created_at', `${toDate}T23:59:59.999Z`)
643
- .order('created_at', { ascending: false })
644
- .range(0, 9999);
644
+ .order('created_at', { ascending: false });
645
645
  if (action)
646
646
  query = query.eq('action', action);
647
647
  if (resource_type)
648
648
  query = query.eq('resource_type', resource_type);
649
649
  if (actor_id)
650
650
  query = query.eq('actor_id', actor_id);
651
- return query;
651
+ return readAllRows(query);
652
652
  };
653
653
  const spanDays = dateSpanDays(dateFrom, dateTo);
654
654
  const previousDateTo = addDaysUtc(dateFrom, -1);
655
655
  const previousDateFrom = addDaysUtc(dateFrom, -spanDays);
656
- const [current, previous] = await Promise.all([
657
- fetchWindow(dateFrom, dateTo),
658
- fetchWindow(previousDateFrom, previousDateTo),
659
- ]);
660
- if (current.error)
661
- return j({ error: current.error.message });
662
- if (previous.error)
663
- return j({ error: previous.error.message });
664
- const rows = current.data ?? [];
665
- const previousRows = previous.data ?? [];
656
+ let rows, previousRows;
657
+ try {
658
+ [rows, previousRows] = await Promise.all([
659
+ fetchWindow(dateFrom, dateTo),
660
+ fetchWindow(previousDateFrom, previousDateTo),
661
+ ]);
662
+ }
663
+ catch (readError) {
664
+ return j({ error: String(readError) });
665
+ }
666
666
  const uniqueActors = new Set(rows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
667
667
  const previousUniqueActors = new Set(previousRows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
668
668
  const dangerousEvents = rows.filter((entry) => isDangerousAuditAction(entry.action)).length;
669
669
  return j({
670
- events: current.count ?? rows.length,
670
+ events: rows.length,
671
671
  uniqueActors,
672
672
  dangerousEvents,
673
- previousEvents: previous.count ?? previousRows.length,
673
+ previousEvents: previousRows.length,
674
674
  previousUniqueActors,
675
675
  dateFrom,
676
676
  dateTo,
@@ -690,15 +690,15 @@ export function registerOrgTools(server) {
690
690
  inputSchema: {},
691
691
  }, async () => {
692
692
  const projectId = getProjectId();
693
- const { data, error } = await db()
694
- .from('audit_log')
695
- .select('action')
696
- .eq('project_id', projectId)
697
- .order('action', { ascending: true })
698
- .limit(1000);
699
- if (error)
700
- return j({ error: error.message });
701
- const actions = Array.from(new Set((data ?? []).map((row) => row.action).filter(Boolean)));
693
+ let data;
694
+ try {
695
+ data = await readAllRows(db().from('audit_log').select('action')
696
+ .eq('project_id', projectId).order('action', { ascending: true }));
697
+ }
698
+ catch (readError) {
699
+ return j({ error: String(readError) });
700
+ }
701
+ const actions = Array.from(new Set(data.map((row) => row.action).filter(Boolean)));
702
702
  return j({ actions });
703
703
  });
704
704
  server.registerTool('get_model_registry', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.45",
3
+ "version": "1.0.46",
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",