@sendinel/mcp-server 1.0.101 → 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);
@@ -2,7 +2,7 @@ import { readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
2
2
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
3
3
  import { z } from 'zod';
4
4
  import Anthropic from '@anthropic-ai/sdk';
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, } 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';
6
6
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
7
7
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
8
8
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -15,7 +15,7 @@ import { checkPlanLimit } from '../plan-limits.js';
15
15
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
16
16
  import { fireWebhookEvent } from '../lib/webhook-events.js';
17
17
  import { approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
18
- import { resolveSendAudience } from '../lib/send-audience.js';
18
+ import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
19
19
  import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
20
20
  import { getAnthropicModelId } from '../anthropic-model.js';
21
21
  import { assertUsableStopReason } from '@sendinel/domain';
@@ -1164,10 +1164,7 @@ export function registerCampaignTools(server) {
1164
1164
  .maybeSingle();
1165
1165
  if (segmentErr || !segment)
1166
1166
  return j({ error: segmentErr?.message ?? 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
1167
- const rules = {
1168
- ...(segment.rules ?? {}),
1169
- ...(segment.site_id ? { site_id: segment.site_id } : {}),
1170
- };
1167
+ const rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
1171
1168
  // #3669: the same audience rules as send_broadcast and the dashboard: paged, signed up to this
1172
1169
  // sender, consent gate applied. The count shown is the count enrolled.
1173
1170
  let audience;
@@ -1175,6 +1172,9 @@ export function registerCampaignTools(server) {
1175
1172
  audience = await resolveSendAudience(projectId, site_id, rules);
1176
1173
  }
1177
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 });
1178
1178
  return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
1179
1179
  }
1180
1180
  const contactIds = audience.contactIds;
@@ -1423,7 +1423,7 @@ export function registerCampaignTools(server) {
1423
1423
  return j({ error: segmentErr.message, code: 'SEGMENT_LOOKUP_FAILED' });
1424
1424
  if (!segment)
1425
1425
  return j({ error: 'Segment not found', code: 'SEGMENT_NOT_FOUND' });
1426
- rules = { ...(segment.rules ?? {}), ...(segment.site_id ? { site_id: segment.site_id } : {}) };
1426
+ rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
1427
1427
  segmentName = segment.name;
1428
1428
  }
1429
1429
  else {
@@ -1441,6 +1441,9 @@ export function registerCampaignTools(server) {
1441
1441
  resolved = await resolveSendAudience(projectId, site.id, rules, sendClass);
1442
1442
  }
1443
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 });
1444
1447
  return j({ error: `Could not load this audience: ${error instanceof Error ? error.message : String(error)}`, code: 'AUDIENCE_LOOKUP_FAILED' });
1445
1448
  }
1446
1449
  if (resolved.contactIds.length === 0) {
@@ -1884,6 +1887,10 @@ Adapt your tone accordingly:
1884
1887
  async function launchFromPlan(plan) {
1885
1888
  let segId = segment_id;
1886
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.`);
1887
1894
  const { data: seg, error: segErr } = await db()
1888
1895
  .from('segments')
1889
1896
  .insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
@@ -2006,8 +2013,22 @@ Adapt your tone accordingly:
2006
2013
  q = q.lte(column, rule.before);
2007
2014
  return q;
2008
2015
  }
2009
- // 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
+ ]);
2010
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);
2011
2032
  let q = db().from('subscribers').select('id, email').eq('project_id', projectId).eq('global_unsubscribed', false).is('cooled_at', null);
2012
2033
  if (rules.lifecycle_stage)
2013
2034
  q = q.eq('lifecycle_stage', rules.lifecycle_stage);
@@ -2073,7 +2094,14 @@ Adapt your tone accordingly:
2073
2094
  catch {
2074
2095
  return txt(`Error: Failed to parse segment from "${segment_description}"`);
2075
2096
  }
2076
- 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
+ }
2077
2105
  contactCount = contactIds.length;
2078
2106
  segmentName = `${name} — segment`;
2079
2107
  }
@@ -2088,7 +2116,14 @@ Adapt your tone accordingly:
2088
2116
  return txt('Error: segment not found');
2089
2117
  segmentName = seg.name;
2090
2118
  parsedRules = seg.rules;
2091
- 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
+ }
2092
2127
  contactCount = contactIds.length;
2093
2128
  }
2094
2129
  if (!confirmed) {
@@ -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.101",
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",