@sendinel/mcp-server 1.0.100 → 1.0.102

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.
@@ -9,7 +9,11 @@ import { type SegmentRules } from '@sendinel/domain';
9
9
  * Every read throws on failure: an unknown audience must never read as "nobody" or "everyone".
10
10
  */
11
11
  export type SendClass = 'broadcast' | 'product';
12
- /** Contact ids matching segment rules (same semantics as the segments tools and the dashboard). */
12
+ /**
13
+ * Contact ids matching segment rules (same semantics as the segments tools and the dashboard).
14
+ * Throws SegmentRulesError for invalid rules (#4270): a malformed rule never widens to everyone,
15
+ * and `{ match_all: true }` is the only way to ask for every contact.
16
+ */
13
17
  export declare function resolveSegmentContactIds(projectId: string, rules: SegmentRules): Promise<string[]>;
14
18
  export type SendAudience = {
15
19
  /** People the send will enroll. */
@@ -15,9 +15,15 @@ async function loadFieldTypes(projectId) {
15
15
  }
16
16
  return fields;
17
17
  }
18
- /** Contact ids matching segment rules (same semantics as the segments tools and the dashboard). */
18
+ /**
19
+ * Contact ids matching segment rules (same semantics as the segments tools and the dashboard).
20
+ * Throws SegmentRulesError for invalid rules (#4270): a malformed rule never widens to everyone,
21
+ * and `{ match_all: true }` is the only way to ask for every contact.
22
+ */
19
23
  export async function resolveSegmentContactIds(projectId, rules) {
20
24
  const tree = normalizeSegmentRulesToTree(rules);
25
+ if (tree.match_none)
26
+ return [];
21
27
  const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
22
28
  const q = buildSegmentBaseQuery(db(), projectId);
23
29
  const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
@@ -1,3 +1,4 @@
1
+ import { readPeriodCampaigns, campaignPeriodFields } from "@sendinel/domain";
1
2
  import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
2
3
  import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
3
4
  import { z } from 'zod';
@@ -851,9 +852,10 @@ export function registerAnalyticsTools(server) {
851
852
  days: z.number().int().min(1).max(365).optional().describe('Lookback period in days, default 30'),
852
853
  site_id: z.string().uuid().optional(),
853
854
  campaign_id: z.string().uuid().optional(),
855
+ period: z.string().min(1).optional().describe('Return one enrollment period in by_period; existing totals keep their date window.'),
854
856
  group_by: z.enum(['campaign', 'site', 'company', 'day', 'week']).optional(),
855
857
  },
856
- }, async ({ days = 30, site_id, campaign_id, group_by }) => {
858
+ }, async ({ days = 30, site_id, campaign_id, group_by, period }) => {
857
859
  const projectId = getProjectId();
858
860
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
859
861
  let q = db()
@@ -868,20 +870,28 @@ export function registerAnalyticsTools(server) {
868
870
  const { data: logs, error } = await q;
869
871
  if (error)
870
872
  return txt(`Error: ${error.message}`);
873
+ let periodCampaigns;
874
+ try {
875
+ periodCampaigns = campaign_id || group_by === 'campaign' ? await readPeriodCampaigns(db(), projectId, { siteId: site_id, campaignId: campaign_id, start: since }) : new Map();
876
+ }
877
+ catch {
878
+ return txt("Error: Could not load results by period.");
879
+ }
880
+ const selectedFields = (id) => campaignPeriodFields(periodCampaigns.get(id)?.result, period);
871
881
  const rows = logs ?? [];
872
- if (!rows.length)
882
+ if (!rows.length && !periodCampaigns.size)
873
883
  return j({ message: `No email activity in the last ${days} days.`, total: 0 });
874
884
  const { data: attributionRows, error: attributionError } = await readRevenue({ sb: db(), projectId, siteId: site_id, campaignId: campaign_id, start: since });
875
885
  if (attributionError)
876
886
  return txt(`Error: ${attributionError.message}`);
877
887
  const scopedAttributionRows = attributionRows ?? [];
878
- const computeMetrics = (set) => {
879
- const total = set.length;
880
- const delivered = set.filter(isDeliveredRow).length;
881
- const opened = countPeople(set, isOpenedRow);
882
- const clicked = countPeople(set, isClickedRow);
883
- const bounced = set.filter(isBouncedRow).length;
884
- const complained = set.filter(isComplainedRow).length;
888
+ const computeMetrics = (set, counts) => {
889
+ const total = counts?.sent ?? set.length;
890
+ const delivered = counts?.delivered ?? set.filter(isDeliveredRow).length;
891
+ const opened = counts?.opened ?? countPeople(set, isOpenedRow);
892
+ const clicked = counts?.clicked ?? countPeople(set, isClickedRow);
893
+ const bounced = counts?.bounced ?? set.filter(isBouncedRow).length;
894
+ const complained = counts?.complained ?? set.filter(isComplainedRow).length;
885
895
  return {
886
896
  total_sent: total, delivered, opened, clicked, bounced, complained,
887
897
  delivery_rate: total > 0 ? `${percentRate(delivered, total, 1)?.toFixed(1)}%` : 'N/A',
@@ -892,7 +902,7 @@ export function registerAnalyticsTools(server) {
892
902
  click_to_open: opened > 0 ? `${percentRate(clicked, opened, 1)?.toFixed(1)}%` : 'N/A',
893
903
  };
894
904
  };
895
- const overall = computeMetrics(rows);
905
+ const overall = computeMetrics(rows, campaign_id ? periodCampaigns.get(campaign_id)?.result.totals : undefined);
896
906
  const trueConversionAnalytics = buildTrueConversionAnalytics({
897
907
  logs: rows,
898
908
  attributionRows: scopedAttributionRows,
@@ -913,9 +923,13 @@ export function registerAnalyticsTools(server) {
913
923
  byKey[key] = [];
914
924
  byKey[key].push(r);
915
925
  });
926
+ if (group_by === 'campaign')
927
+ for (const id of periodCampaigns.keys())
928
+ byKey[id] ??= [];
916
929
  grouped = Object.entries(byKey).map(([id, set]) => ({
917
930
  [`${group_by}_id`]: id,
918
- ...computeMetrics(set),
931
+ ...computeMetrics(set, group_by === 'campaign' ? periodCampaigns.get(id)?.result.totals : undefined),
932
+ ...(group_by === 'campaign' ? selectedFields(id) : {}),
919
933
  })).sort((a, b) => b.total_sent - a.total_sent);
920
934
  }
921
935
  else if (group_by === 'company') {
@@ -987,6 +1001,7 @@ export function registerAnalyticsTools(server) {
987
1001
  return j({
988
1002
  period: `Last ${days} days`,
989
1003
  overall,
1004
+ ...(campaign_id ? selectedFields(campaign_id) : {}),
990
1005
  true_conversion_analytics: trueConversionAnalytics,
991
1006
  best_send_times_utc: bestHours,
992
1007
  trend_vs_prior_period: trend,
@@ -1,7 +1,8 @@
1
+ import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
1
2
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
4
- import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
5
+ import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, } from '@sendinel/domain';
5
6
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
6
7
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
7
8
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -14,7 +15,7 @@ import { checkPlanLimit } from '../plan-limits.js';
14
15
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
15
16
  import { fireWebhookEvent } from '../lib/webhook-events.js';
16
17
  import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
17
- import { resolveSendAudience } from '../lib/send-audience.js';
18
+ import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
18
19
  import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
19
20
  import { getAnthropicModelId } from '../anthropic-model.js';
20
21
  import { assertUsableStopReason } from '@sendinel/domain';
@@ -643,13 +644,14 @@ export function registerCampaignTools(server) {
643
644
  description: 'List campaigns, optionally filtered by site and/or status',
644
645
  inputSchema: {
645
646
  site_id: z.string().uuid().optional(),
647
+ period: z.string().min(1).optional().describe("Return one enrollment period in by_period; totals remain all-time."),
646
648
  status: z.enum(['draft', 'active', 'paused', 'completed', 'archived']).optional(),
647
649
  limit: z.number().int().min(1).max(100).optional().default(25),
648
650
  },
649
- }, async ({ site_id, status, limit }) => {
651
+ }, async ({ site_id, status, limit, period }) => {
650
652
  let q = db()
651
653
  .from('campaigns')
652
- .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at')
654
+ .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key')
653
655
  .eq('project_id', getProjectId())
654
656
  .order('created_at', { ascending: false })
655
657
  .limit(limit ?? 25);
@@ -664,10 +666,16 @@ export function registerCampaignTools(server) {
664
666
  // counts that cannot exceed total_sent. null = the people-count lookup failed (unknown, not 0).
665
667
  const rows = data ?? [];
666
668
  const engagement = await countUniqueEngagementByCampaign(db(), getProjectId(), rows.map((row) => row.id));
667
- const withPeople = rows.map((row) => ({
668
- ...row,
669
- unique_opened: engagement.ok ? engagement.opened.get(row.id) ?? 0 : null,
670
- unique_clicked: engagement.ok ? engagement.clicked.get(row.id) ?? 0 : null,
669
+ const withPeople = await Promise.all(rows.map(async (row) => {
670
+ const periods = await readCampaignPeriods(db(), getProjectId(), row);
671
+ const { period_key: _periodKey, ...legacy } = row;
672
+ return {
673
+ ...legacy,
674
+ ...campaignPeriodFields(periods, period),
675
+ ...(periods?.totals ? { total_sent: periods.totals.sent } : {}),
676
+ unique_opened: periods?.totals?.opened ?? (engagement.ok ? engagement.opened.get(row.id) ?? 0 : null),
677
+ unique_clicked: periods?.totals?.clicked ?? (engagement.ok ? engagement.clicked.get(row.id) ?? 0 : null),
678
+ };
671
679
  }));
672
680
  return { content: [{ type: 'text', text: JSON.stringify(withPeople, null, 2) }] };
673
681
  });
@@ -1156,10 +1164,7 @@ export function registerCampaignTools(server) {
1156
1164
  .maybeSingle();
1157
1165
  if (segmentErr || !segment)
1158
1166
  return j({ error: segmentErr?.message ?? 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
1159
- const rules = {
1160
- ...(segment.rules ?? {}),
1161
- ...(segment.site_id ? { site_id: segment.site_id } : {}),
1162
- };
1167
+ const rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
1163
1168
  // #3669: the same audience rules as send_broadcast and the dashboard: paged, signed up to this
1164
1169
  // sender, consent gate applied. The count shown is the count enrolled.
1165
1170
  let audience;
@@ -1167,6 +1172,9 @@ export function registerCampaignTools(server) {
1167
1172
  audience = await resolveSendAudience(projectId, site_id, rules);
1168
1173
  }
1169
1174
  catch (error) {
1175
+ // #4270: a malformed segment enrolls nobody and says why.
1176
+ if (isSegmentRulesError(error))
1177
+ return j({ error: error.message, code: 'INVALID_SEGMENT_RULES', errors: error.errors });
1170
1178
  return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
1171
1179
  }
1172
1180
  const contactIds = audience.contactIds;
@@ -1415,7 +1423,7 @@ export function registerCampaignTools(server) {
1415
1423
  return j({ error: segmentErr.message, code: 'SEGMENT_LOOKUP_FAILED' });
1416
1424
  if (!segment)
1417
1425
  return j({ error: 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
1418
- rules = { ...(segment.rules ?? {}), ...(segment.site_id ? { site_id: segment.site_id } : {}) };
1426
+ rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
1419
1427
  segmentName = segment.name;
1420
1428
  }
1421
1429
  else {
@@ -1433,6 +1441,9 @@ export function registerCampaignTools(server) {
1433
1441
  resolved = await resolveSendAudience(projectId, site.id, rules, sendClass);
1434
1442
  }
1435
1443
  catch (error) {
1444
+ // #4270: malformed segment or parsed audience rules send to nobody and say why.
1445
+ if (isSegmentRulesError(error))
1446
+ return j({ error: error.message, code: 'INVALID_SEGMENT_RULES', errors: error.errors, rules });
1436
1447
  return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
1437
1448
  }
1438
1449
  if (resolved.contactIds.length === 0) {
@@ -1876,6 +1887,10 @@ Adapt your tone accordingly:
1876
1887
  async function launchFromPlan(plan) {
1877
1888
  let segId = segment_id;
1878
1889
  if (segment_description) {
1890
+ // #4270: never save a segment from rules the segment engine can't use.
1891
+ const rulesCheck = validateSegmentRules(plan.rules);
1892
+ if (!rulesCheck.ok)
1893
+ return txt(`Error: Could not use the audience rules. ${rulesCheck.errors[0]} Nothing was created.`);
1879
1894
  const { data: seg, error: segErr } = await db()
1880
1895
  .from('segments')
1881
1896
  .insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
@@ -1998,8 +2013,22 @@ Adapt your tone accordingly:
1998
2013
  q = q.lte(column, rule.before);
1999
2014
  return q;
2000
2015
  }
2001
- // Helper to get segment contact IDs (simplified inline)
2016
+ // Helper to get segment contact IDs (simplified inline). #4270: rules are validated first
2017
+ // (SegmentRulesError), and anything this flat helper cannot evaluate (rule trees, markers,
2018
+ // custom_property, company filters, multi-tag) goes through the shared evaluator instead of
2019
+ // being ignored, which used to widen those segments to everyone.
2020
+ const INLINE_RULE_KEYS = new Set([
2021
+ 'lifecycle_stage', 'payment', 'min_score', 'max_score', 'source', 'subscribed_after', 'subscribed_before',
2022
+ 'has_opened', 'has_clicked', 'tag', 'site_id', 'exclude_suppressed', 'metadata',
2023
+ ]);
2002
2024
  const getContactIds = async (rules) => {
2025
+ const check = validateSegmentRules(rules);
2026
+ if (!check.ok)
2027
+ throw new SegmentRulesError(check.errors);
2028
+ const inlineCanEvaluate = Object.keys(rules).every((key) => rules[key] === undefined || INLINE_RULE_KEYS.has(key))
2029
+ && (rules.tag === undefined || typeof rules.tag === 'string');
2030
+ if (!inlineCanEvaluate)
2031
+ return resolveSegmentContactIds(projectId, rules);
2003
2032
  let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false).is('cooled_at', null);
2004
2033
  if (rules.lifecycle_stage)
2005
2034
  q = q.eq('lifecycle_stage', rules.lifecycle_stage);
@@ -2065,7 +2094,14 @@ Adapt your tone accordingly:
2065
2094
  catch {
2066
2095
  return txt(`Error: Failed to parse segment from "${segment_description}"`);
2067
2096
  }
2068
- contactIds = await getContactIds(parsedRules);
2097
+ try {
2098
+ contactIds = await getContactIds(parsedRules);
2099
+ }
2100
+ catch (error) {
2101
+ if (isSegmentRulesError(error))
2102
+ return txt(`Error: Could not use the audience parsed from "${segment_description}". ${error.message}`);
2103
+ throw error;
2104
+ }
2069
2105
  contactCount = contactIds.length;
2070
2106
  segmentName = `${name} — segment`;
2071
2107
  }
@@ -2080,7 +2116,14 @@ Adapt your tone accordingly:
2080
2116
  return txt('Error: segment not found');
2081
2117
  segmentName = seg.name;
2082
2118
  parsedRules = seg.rules;
2083
- contactIds = await getContactIds(parsedRules);
2119
+ try {
2120
+ contactIds = await getContactIds(parsedRules);
2121
+ }
2122
+ catch (error) {
2123
+ if (isSegmentRulesError(error))
2124
+ return txt(`Error: Segment "${seg.name}" has rules that can't be used. ${error.message}`);
2125
+ throw error;
2126
+ }
2084
2127
  contactCount = contactIds.length;
2085
2128
  }
2086
2129
  if (!confirmed) {
@@ -3409,8 +3452,8 @@ Return ONLY valid JSON with this exact shape:
3409
3452
  openWorldHint: false,
3410
3453
  },
3411
3454
  description: 'Get full details of a single campaign including all steps, current enrollment counts, and settings.',
3412
- inputSchema: { campaign_id: z.string().uuid() },
3413
- }, async ({ campaign_id }) => {
3455
+ inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
3456
+ }, async ({ campaign_id, period }) => {
3414
3457
  const projectId = getProjectId();
3415
3458
  const { data: campaign, error: campErr } = await db()
3416
3459
  .from('campaigns')
@@ -3465,6 +3508,7 @@ Return ONLY valid JSON with this exact shape:
3465
3508
  const visibilityError = stepsError ?? enrollError ?? sentError ?? queuedEmailError ?? failedSendError ?? queueError ?? dlqError;
3466
3509
  if (visibilityError)
3467
3510
  return j({ error: visibilityError.message, code: 'CAMPAIGN_VISIBILITY_QUERY_FAILED' });
3511
+ const periods = await readCampaignPeriods(db(), projectId, campaign);
3468
3512
  const outstandingRows = queueRows ?? [];
3469
3513
  const pendingOrRetryRows = outstandingRows.filter((row) => row.status !== 'processing');
3470
3514
  const retryingCount = outstandingRows.filter((row) => row.status === 'retry').length;
@@ -3478,7 +3522,9 @@ Return ONLY valid JSON with this exact shape:
3478
3522
  const unresolvedCount = dlqUnresolvedCount ?? 0;
3479
3523
  return j({
3480
3524
  ...campaign,
3481
- total_sent: sentCount ?? 0,
3525
+ total_sent: periods?.totals?.sent ?? sentCount ?? 0,
3526
+ ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
3527
+ ...campaignPeriodFields(periods, period),
3482
3528
  steps: steps ?? [],
3483
3529
  active_enrollments: enrollCount ?? 0,
3484
3530
  send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
@@ -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, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
4
+ import { buildPreviewAttentionEnvelope, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
5
5
  import { z } from 'zod';
6
6
  import { callSendinelV1 } from '../lib/v1-client.js';
7
7
  import { db, projectClientForWriteback } from '../db.js';
@@ -2103,11 +2103,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2103
2103
  q = q.eq('company_id', params.company_id);
2104
2104
  const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
2105
2105
  if (params.custom_property) {
2106
- ({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
2106
+ try {
2107
+ // #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
2108
+ ({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
2109
+ }
2110
+ catch (error) {
2111
+ if (isSegmentRulesError(error))
2112
+ return txt(`Error: ${error.message}`);
2113
+ throw error;
2114
+ }
2107
2115
  }
2108
2116
  if (params.site_id) {
2109
2117
  // Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
2110
- const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId);
2118
+ // Deliberately everyone on the site, so allowEmpty.
2119
+ const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
2111
2120
  if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0)
2112
2121
  return j({ total: 0, sample: [] });
2113
2122
  q = siteFiltered.query;
@@ -2155,11 +2164,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2155
2164
  if (params.tag)
2156
2165
  countQ = countQ.contains('tags', [params.tag]);
2157
2166
  if (params.custom_property) {
2158
- ({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
2167
+ ({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
2159
2168
  }
2160
2169
  // The total must count the same site the sample came from (#3674).
2161
2170
  if (params.site_id)
2162
- ({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId));
2171
+ ({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
2163
2172
  const { count } = await countQ;
2164
2173
  return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
2165
2174
  });
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
2
+ import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, isSegmentRulesError, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
3
3
  import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { logAudit } from '../audit.js';
@@ -28,7 +28,10 @@ async function loadFieldTypes(projectId) {
28
28
  }
29
29
  async function getSegmentContactIds(rules) {
30
30
  const projectId = getProjectId();
31
+ // Throws SegmentRulesError for invalid rules (#4270) instead of widening to everyone.
31
32
  const tree = normalizeSegmentRulesToTree(rules);
33
+ if (tree.match_none)
34
+ return [];
32
35
  const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
33
36
  const q = buildSegmentBaseQuery(db(), projectId);
34
37
  const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
@@ -357,7 +360,16 @@ export function registerListTools(server) {
357
360
  },
358
361
  }, async ({ list_id, rules, confirmed, approval_id }) => {
359
362
  const projectId = getProjectId();
360
- const normalizedRules = normalizeSegmentRulesToTree(rules);
363
+ let normalizedRules;
364
+ try {
365
+ normalizedRules = normalizeSegmentRulesToTree(rules);
366
+ }
367
+ catch (rulesError) {
368
+ // #4270: invalid (or empty) filter rules add nobody and say why. Everyone is { match_all: true }.
369
+ if (isSegmentRulesError(rulesError))
370
+ return j({ error: rulesError.message, code: 'INVALID_SEGMENT_RULES', errors: rulesError.errors });
371
+ throw rulesError;
372
+ }
361
373
  const approvalParams = { list_id, rules: normalizedRules };
362
374
  const { data: list, error: listError } = await db()
363
375
  .from('contact_lists')
@@ -5,7 +5,7 @@ import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { getAnthropicModelId } from '../anthropic-model.js';
7
7
  import { assertUsableStopReason } from '@sendinel/domain';
8
- import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
8
+ import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
9
9
  async function loadFieldTypes(projectId) {
10
10
  const client = db();
11
11
  const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
@@ -27,7 +27,10 @@ async function loadFieldTypes(projectId) {
27
27
  }
28
28
  async function getSegmentContactIds(rules) {
29
29
  const projectId = getProjectId();
30
+ // Throws SegmentRulesError for invalid rules (#4270) instead of widening to everyone.
30
31
  const tree = normalizeSegmentRulesToTree(rules);
32
+ if (tree.match_none)
33
+ return [];
31
34
  const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
32
35
  // Shared base query (#3084). This used to be hand-written here and omitted
33
36
  // is_test / source_deleted_at / the cooled exclusion the dashboard applies,
@@ -53,6 +56,13 @@ async function getSegmentContactIds(rules) {
53
56
  }
54
57
  const txt = (text) => ({ content: [{ type: 'text', text }] });
55
58
  const j = (data) => txt(JSON.stringify(data, null, 2));
59
+ /**
60
+ * #4270: invalid segment rules are an error response naming the first problem,
61
+ * never an audience of everyone. Returns null for any other error.
62
+ */
63
+ function invalidRules(error) {
64
+ return isSegmentRulesError(error) ? txt(`Error: ${error.message}`) : null;
65
+ }
56
66
  export function registerSegmentTools(server) {
57
67
  server.registerTool('create_segment', {
58
68
  title: 'Create Segment',
@@ -69,9 +79,19 @@ export function registerSegmentTools(server) {
69
79
  rules: z.record(z.string(), z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.<key>. tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
70
80
  },
71
81
  }, async ({ name, description, rules }) => {
82
+ let storedRules;
83
+ try {
84
+ storedRules = prepareSegmentRulesForStorage(rules);
85
+ }
86
+ catch (rulesError) {
87
+ const invalid = invalidRules(rulesError);
88
+ if (invalid)
89
+ return invalid;
90
+ throw rulesError;
91
+ }
72
92
  const { data, error } = await db()
73
93
  .from('segments')
74
- .insert({ project_id: getProjectId(), name, description, rules: normalizeSegmentRulesToTree(rules) })
94
+ .insert({ project_id: getProjectId(), name, description, rules: storedRules })
75
95
  .select('id, name, description, rules, created_at')
76
96
  .single();
77
97
  if (error)
@@ -105,7 +125,17 @@ export function registerSegmentTools(server) {
105
125
  return txt(`Error: ${error.message}`);
106
126
  const segments = [];
107
127
  for (const seg of data ?? []) {
108
- const ids = await getSegmentContactIds(seg.rules);
128
+ let ids;
129
+ try {
130
+ ids = await getSegmentContactIds(seg.rules);
131
+ }
132
+ catch (rulesError) {
133
+ if (!isSegmentRulesError(rulesError))
134
+ throw rulesError;
135
+ // #4270: one stored segment with invalid rules matches nobody; its count is unknown, not 0.
136
+ segments.push({ ...seg, contact_count: null, rules_error: rulesError.message });
137
+ continue;
138
+ }
109
139
  segments.push({ ...seg, contact_count: ids.length });
110
140
  }
111
141
  return j(segments);
@@ -131,8 +161,17 @@ export function registerSegmentTools(server) {
131
161
  updates.name = name;
132
162
  if (description !== undefined)
133
163
  updates.description = description;
134
- if (rules)
135
- updates.rules = normalizeSegmentRulesToTree(rules);
164
+ if (rules) {
165
+ try {
166
+ updates.rules = prepareSegmentRulesForStorage(rules);
167
+ }
168
+ catch (rulesError) {
169
+ const invalid = invalidRules(rulesError);
170
+ if (invalid)
171
+ return invalid;
172
+ throw rulesError;
173
+ }
174
+ }
136
175
  const { data, error } = await db()
137
176
  .from('segments')
138
177
  .update(updates)
@@ -199,7 +238,16 @@ export function registerSegmentTools(server) {
199
238
  else {
200
239
  return txt('Provide either segment_id or rules.');
201
240
  }
202
- const ids = await getSegmentContactIds(segRules);
241
+ let ids;
242
+ try {
243
+ ids = await getSegmentContactIds(segRules);
244
+ }
245
+ catch (rulesError) {
246
+ const invalid = invalidRules(rulesError);
247
+ if (invalid)
248
+ return invalid;
249
+ throw rulesError;
250
+ }
203
251
  const sample = ids.slice(0, 10);
204
252
  let sampleContacts = [];
205
253
  if (sample.length) {
@@ -243,9 +291,12 @@ export function registerSegmentTools(server) {
243
291
  return txt(`Error: Failed to parse rules from "${description}"`);
244
292
  let parsedRules;
245
293
  try {
246
- parsedRules = normalizeSegmentRulesToTree(JSON.parse(raw.replace(/```json?\n?|```/g, '').trim()));
294
+ parsedRules = prepareSegmentRulesForStorage(JSON.parse(raw.replace(/```json?\n?|```/g, '').trim()));
247
295
  }
248
- catch {
296
+ catch (parseError) {
297
+ // #4270: parsed rules that are missing or malformed are an error, never "everyone".
298
+ if (isSegmentRulesError(parseError))
299
+ return txt(`Error: Could not use the rules parsed from "${description}". ${parseError.message}`);
249
300
  return txt(`Error: Failed to parse rules from "${description}"`);
250
301
  }
251
302
  const ids = await getSegmentContactIds(parsedRules);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.100",
3
+ "version": "1.0.102",
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",