@sendinel/mcp-server 1.0.172 → 1.0.173

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.
@@ -2,7 +2,7 @@ import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
2
2
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
4
4
  import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
5
- import { buildPreviewAttentionEnvelope, buildSafeIlikeOrFilter, literalIlikePattern, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, validateContactPropertyPatch, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
5
+ import { buildPreviewAttentionEnvelope, buildSafeIlikeOrFilter, literalIlikePattern, applySegmentRules, assertValidSegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, validateContactPropertyPatch, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
6
6
  import { z } from 'zod';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { db, projectClientForWriteback } from '../db.js';
@@ -2315,6 +2315,17 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2315
2315
  }, async (params) => {
2316
2316
  const projectId = getProjectId();
2317
2317
  const sampleSize = params.sample_size ?? 5;
2318
+ // Validate the same flat-rule shape as saved segments before any query.
2319
+ // No filters deliberately means all contacts for this estimation tool.
2320
+ const { sample_size: _sampleSize, ...rules } = params;
2321
+ try {
2322
+ assertValidSegmentRules(rules, { allowEmpty: true });
2323
+ }
2324
+ catch (error) {
2325
+ if (isSegmentRulesError(error))
2326
+ return toolError({ error: error.message, code: 'validation_failed' });
2327
+ throw error;
2328
+ }
2318
2329
  let q = db()
2319
2330
  .from('subscribers')
2320
2331
  .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
@@ -2352,7 +2363,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2352
2363
  }
2353
2364
  catch (error) {
2354
2365
  if (isSegmentRulesError(error))
2355
- return txt(`Error: ${dbToolError(error).error}`);
2366
+ return toolError({ error: error.message, code: 'validation_failed' });
2356
2367
  throw error;
2357
2368
  }
2358
2369
  }
@@ -2365,11 +2376,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2365
2376
  q = siteFiltered.query;
2366
2377
  }
2367
2378
  if (params.company_domain) {
2368
- const { data: companies } = await db()
2379
+ const { data: companies, error } = await db()
2369
2380
  .from('companies')
2370
2381
  .select('id')
2371
2382
  .eq('project_id', projectId)
2372
2383
  .eq('domain', params.company_domain.trim().toLowerCase());
2384
+ if (error)
2385
+ return dbErrorResult(error);
2373
2386
  const ids = (companies ?? []).map((company) => company.id);
2374
2387
  if (!ids.length)
2375
2388
  return j({ total: 0, sample: [] });
@@ -2377,7 +2390,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2377
2390
  }
2378
2391
  const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
2379
2392
  if (error)
2380
- return txt(`Error: ${dbToolError(error).error}`);
2393
+ return dbErrorResult(error);
2381
2394
  // Get full count separately
2382
2395
  let countQ = db()
2383
2396
  .from('subscribers')
@@ -2412,7 +2425,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2412
2425
  // The total must count the same site the sample came from (#3674).
2413
2426
  if (params.site_id)
2414
2427
  ({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
2415
- const { count } = await countQ;
2428
+ const { count, error: countError } = await countQ;
2429
+ if (countError)
2430
+ return dbErrorResult(countError);
2416
2431
  return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
2417
2432
  });
2418
2433
  server.registerTool('identify_inactive', {
@@ -63,7 +63,9 @@ const j = (data) => txt(JSON.stringify(data, null, 2));
63
63
  * never an audience of everyone. Returns null for any other error.
64
64
  */
65
65
  function invalidRules(error) {
66
- return isSegmentRulesError(error) ? txt(`Error: ${error.message}`) : null;
66
+ return isSegmentRulesError(error)
67
+ ? toolError({ error: error.message, code: 'validation_failed' })
68
+ : null;
67
69
  }
68
70
  export function registerSegmentTools(server) {
69
71
  server.registerTool('create_segment', {
@@ -89,7 +91,7 @@ export function registerSegmentTools(server) {
89
91
  catch (rulesError) {
90
92
  const invalid = invalidRules(rulesError);
91
93
  if (invalid)
92
- return toolError({ error: rulesError instanceof Error ? rulesError.message : 'Invalid segment rules', code: 'validation_failed' });
94
+ return invalid;
93
95
  throw rulesError;
94
96
  }
95
97
  const { data, error } = await db()
@@ -100,7 +102,7 @@ export function registerSegmentTools(server) {
100
102
  if (error?.code === '23505')
101
103
  return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
102
104
  if (error)
103
- return toolError(dbToolError(error));
105
+ return dbErrorResult(error);
104
106
  // Count against the stored (normalized) tree, not the raw input: a tree skips
105
107
  // applySegmentRules' SQL push-down and filters in JS, which is what list_segments
106
108
  // does for this same segment a moment later. Pushing the flat rules down instead
@@ -171,7 +173,7 @@ export function registerSegmentTools(server) {
171
173
  updates.name = name;
172
174
  if (description !== undefined)
173
175
  updates.description = description;
174
- if (rules) {
176
+ if (rules !== undefined) {
175
177
  try {
176
178
  updates.rules = prepareSegmentRulesForStorage(rules);
177
179
  }
@@ -242,11 +244,13 @@ export function registerSegmentTools(server) {
242
244
  .eq('id', segment_id)
243
245
  .eq('project_id', getProjectId())
244
246
  .maybeSingle();
245
- if (error || !seg)
247
+ if (error)
248
+ return dbErrorResult(error);
249
+ if (!seg)
246
250
  return txt('Error: segment not found');
247
251
  segRules = seg.rules;
248
252
  }
249
- else if (rules) {
253
+ else if (rules !== undefined) {
250
254
  segRules = rules;
251
255
  }
252
256
  else {
@@ -265,10 +269,12 @@ export function registerSegmentTools(server) {
265
269
  const sample = ids.slice(0, 10);
266
270
  let sampleContacts = [];
267
271
  if (sample.length) {
268
- const { data } = await db()
272
+ const { data, error } = await db()
269
273
  .from('subscribers')
270
274
  .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, tags')
271
275
  .in('id', sample);
276
+ if (error)
277
+ return dbErrorResult(error);
272
278
  sampleContacts = data ?? [];
273
279
  }
274
280
  return j({ total_contacts: ids.length, sample: sampleContacts });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.172",
3
+ "version": "1.0.173",
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",