@sendinel/mcp-server 1.0.38 → 1.0.40

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.
@@ -305,6 +305,33 @@ async function executeBroadcast(input) {
305
305
  }
306
306
  return { ok: true, campaignId: campaign.id, stepId: step.id, enrolled: input.contactIds.length, nextSendAt };
307
307
  }
308
+ /** #3714: a send nobody could receive leaves one audit row (counts by reason, no addresses). */
309
+ function logRefusedSend(kind, siteId, sendClass, audience, extra = {}) {
310
+ const reasons = {};
311
+ if (audience.notOnSender > 0)
312
+ reasons.not_subscribed_to_sender = audience.notOnSender;
313
+ for (const [reason, count] of Object.entries(audience.heldBack)) {
314
+ if (count > 0)
315
+ reasons[`consent_${reason}`] = count;
316
+ }
317
+ return logAudit({
318
+ action: 'send.refused',
319
+ resourceType: 'site',
320
+ resourceId: siteId,
321
+ details: {
322
+ kind,
323
+ outcome: 'nothing_sent',
324
+ send_class: sendClass,
325
+ site_id: siteId,
326
+ campaign_id: null,
327
+ requested: audience.matched,
328
+ enrolled: 0,
329
+ refused: Object.values(reasons).reduce((sum, count) => sum + count, 0),
330
+ reasons,
331
+ ...extra,
332
+ },
333
+ });
334
+ }
308
335
  function heldBackLines(audience) {
309
336
  return {
310
337
  matched: audience.matched,
@@ -909,6 +936,7 @@ export function registerCampaignTools(server) {
909
936
  });
910
937
  }
911
938
  if (contactIds.length === 0) {
939
+ await logRefusedSend('broadcast_to_segment', site_id, 'broadcast', audience, { audience: { segment_id } });
912
940
  return j({
913
941
  error: 'Nobody in this segment can get email from this sender right now.',
914
942
  code: 'EMPTY_SEGMENT',
@@ -1153,6 +1181,7 @@ export function registerCampaignTools(server) {
1153
1181
  return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
1154
1182
  }
1155
1183
  if (resolved.contactIds.length === 0) {
1184
+ await logRefusedSend('send_broadcast', site.id, sendClass, resolved, { audience: segment_id ? { segment_id } : { inline_rules: true } });
1156
1185
  return j({ error: 'Nobody in this audience can get this email from this sender.', code: 'EMPTY_AUDIENCE', audience: heldBackLines(resolved), rules });
1157
1186
  }
1158
1187
  let content;
@@ -2109,17 +2109,22 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2109
2109
  confidence_threshold: z.number().min(0.5).max(1).optional().describe('Minimum similarity (0.5-1.0, default 0.8)'),
2110
2110
  limit: z.number().int().min(1).max(200).optional().describe('Max duplicate groups (default 50)'),
2111
2111
  site_id: z.string().uuid().optional().describe('Filter to contacts subscribed to this site'),
2112
+ include_test: z.boolean().optional().describe('Include is_test (seed/fixture) contacts. Default false, matching the dashboard contact list.'),
2112
2113
  },
2113
- }, async ({ confidence_threshold = 0.8, limit = 50, site_id }) => {
2114
+ }, async ({ confidence_threshold = 0.8, limit = 50, site_id, include_test = false }) => {
2114
2115
  const projectId = getProjectId();
2115
2116
  const maxGroups = Math.min(limit, 200);
2117
+ const CONTACT_SCAN_LIMIT = 2000;
2116
2118
  let q = db()
2117
2119
  .from('subscribers')
2118
- .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source')
2120
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source', { count: 'exact' })
2119
2121
  .eq('project_id', projectId)
2120
2122
  .eq('global_unsubscribed', false)
2121
2123
  .order('created_at', { ascending: true })
2122
- .limit(2000);
2124
+ .limit(CONTACT_SCAN_LIMIT);
2125
+ // #3604: hide seed/fixture rows like every other default contact view.
2126
+ if (!include_test)
2127
+ q = q.not('is_test', 'is', true);
2123
2128
  if (site_id) {
2124
2129
  const { data: subs } = await db()
2125
2130
  .from('site_subscriptions')
@@ -2128,14 +2133,18 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2128
2133
  .eq('subscribed', true);
2129
2134
  const ids = (subs ?? []).map((s) => s.contact_id);
2130
2135
  if (!ids.length)
2131
- return j({ duplicate_groups: [], total_groups: 0 });
2136
+ return j({ duplicate_groups: [], total_groups: 0, coverage: { contacts_scanned: 0, contacts_matching: 0, contact_limit: CONTACT_SCAN_LIMIT, complete: true } });
2132
2137
  q = q.in('id', ids);
2133
2138
  }
2134
- const { data: contacts, error } = await q;
2139
+ const { data: contacts, error, count } = await q;
2135
2140
  if (error)
2136
2141
  return txt(`Error: ${error.message}`);
2142
+ // #3604: say when the scan stopped at the limit instead of presenting a partial scan as complete.
2143
+ const scanned = contacts?.length ?? 0;
2144
+ const matching = typeof count === 'number' ? count : null;
2145
+ const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null : matching <= scanned };
2137
2146
  if (!contacts?.length)
2138
- return j({ duplicate_groups: [], total_groups: 0 });
2147
+ return j({ duplicate_groups: [], total_groups: 0, coverage });
2139
2148
  const lev = (a, b) => {
2140
2149
  const m = a.length, n = b.length;
2141
2150
  const dp = Array.from({ length: m + 1 }, (_, i) => i);
@@ -2211,7 +2220,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2211
2220
  }
2212
2221
  groups.sort((a, b) => b.confidence - a.confidence);
2213
2222
  const result = groups.slice(0, maxGroups);
2214
- return j({ duplicate_groups: result, total_groups: result.length });
2223
+ return j({ duplicate_groups: result, total_groups: result.length, coverage });
2215
2224
  });
2216
2225
  server.registerTool('merge_subscribers', {
2217
2226
  title: 'Merge Subscribers',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
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",