@sendinel/mcp-server 1.0.174 → 1.0.176

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.
@@ -37,6 +37,36 @@ import { callSendinelV1 } from '../lib/v1-client.js';
37
37
  import { countUniqueEngagementByCampaign } from '../lib/campaign-engagement.js';
38
38
  const txt = (text) => ({ content: [{ type: 'text', text }] });
39
39
  const j = (data) => txt(JSON.stringify(data, null, 2));
40
+ /** Diagnose a rejected DELETE, without exposing Postgres diagnostics or guessing missing counts. */
41
+ async function campaignDeleteConflict(campaignId) {
42
+ const sources = [
43
+ { table: 'ai_drafts', column: 'campaign_id', singular: 'AI draft', plural: 'AI drafts' },
44
+ { table: 'email_log', column: 'campaign_id', singular: 'email history record', plural: 'email history records' },
45
+ { table: 'revenue_attribution', column: 'campaign_id', singular: 'revenue attribution record', plural: 'revenue attribution records' },
46
+ { table: 'campaign_goals', column: 'target_campaign_id', singular: 'campaign goal target', plural: 'campaign goal targets' },
47
+ ];
48
+ // The caller has verified ownership of this campaign. Count all references to its UUID;
49
+ // campaign_goals has no project_id, and filtering by the owning campaign misses targets.
50
+ const results = await Promise.allSettled(sources.map(async (source) => {
51
+ const { count, error } = await db().schema('email').from(source.table)
52
+ .select('id', { count: 'exact', head: true }).eq(source.column, campaignId);
53
+ return !error && typeof count === 'number' && Number.isInteger(count) && count >= 0 ? count : null;
54
+ }));
55
+ const linked = [];
56
+ const unknown = [];
57
+ results.forEach((result, index) => {
58
+ const source = sources[index];
59
+ const count = result.status === 'fulfilled' ? result.value : null;
60
+ if (count === null)
61
+ unknown.push(source.table);
62
+ else if (count > 0)
63
+ linked.push(`${count} ${count === 1 ? source.singular : source.plural} (${source.table})`);
64
+ });
65
+ const reason = linked.length
66
+ ? `This campaign still has ${linked.join(', ')} linked to it, so it cannot be deleted.`
67
+ : 'This campaign cannot be deleted because linked records still reference it.';
68
+ return unknown.length ? `${reason} Counts for ${unknown.join(', ')} are unavailable.` : reason;
69
+ }
40
70
  const ACTIVATION_APPROVAL_DESCRIPTION = 'Activation-only: requires_approval gates campaign activation. It does not stop sends of an already-active campaign or sends triggered by other paths.';
41
71
  const REENTRY_DESCRIPTION = 'Re-entry defaults to once ever. per_event_period uses a flat event-data period_key on event-triggered campaigns only. Changing a live campaign returns an approval request. Existing enrollments without a period cannot re-enter; sends wait for campaign-cycle approval.';
42
72
  const reentryFields = {
@@ -3613,6 +3643,10 @@ Return ONLY valid JSON with this exact shape:
3613
3643
  .delete()
3614
3644
  .eq('id', campaign_id)
3615
3645
  .eq('project_id', projectId);
3646
+ if (error?.code === '23503') {
3647
+ // A rejected statement made no changes, so the same approval is safe to retry.
3648
+ return markNotExecuted(toolError({ code: 'conflict', error: await campaignDeleteConflict(campaign_id) }));
3649
+ }
3616
3650
  if (error)
3617
3651
  return j(dbToolError(error));
3618
3652
  return j({ success: true, deleted: campaign.name });
@@ -2337,55 +2337,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2337
2337
  return toolError({ error: error.message, code: 'validation_failed' });
2338
2338
  throw error;
2339
2339
  }
2340
- let q = db()
2341
- .from('subscribers')
2342
- .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
2343
- .eq('project_id', projectId)
2344
- .eq('global_unsubscribed', false);
2345
- if (params.lifecycle_stage)
2346
- q = q.eq('lifecycle_stage', params.lifecycle_stage);
2347
- if (params.min_score !== undefined)
2348
- q = q.gte('engagement_score', params.min_score);
2349
- if (params.max_score !== undefined)
2350
- q = q.lte('engagement_score', params.max_score);
2351
- if (params.source)
2352
- q = q.eq('source', params.source);
2353
- if (params.subscribed_after)
2354
- q = q.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
2355
- if (params.subscribed_before)
2356
- q = q.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
2357
- if (params.has_opened === true)
2358
- q = q.not('last_opened_at', 'is', null);
2359
- if (params.has_opened === false)
2360
- q = q.is('last_opened_at', null);
2361
- if (params.has_clicked === true)
2362
- q = q.not('last_clicked_at', 'is', null);
2363
- if (params.has_clicked === false)
2364
- q = q.is('last_clicked_at', null);
2365
- if (params.tag)
2366
- q = q.contains('tags', [params.tag]);
2367
- if (params.company_id)
2368
- q = q.eq('company_id', params.company_id);
2369
- const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
2370
- if (params.custom_property) {
2371
- try {
2372
- // #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
2373
- ({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
2374
- }
2375
- catch (error) {
2376
- if (isSegmentRulesError(error))
2377
- return toolError({ error: error.message, code: 'validation_failed' });
2378
- throw error;
2379
- }
2380
- }
2381
- if (params.site_id) {
2382
- // Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
2383
- // Deliberately everyone on the site, so allowEmpty.
2384
- const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
2385
- if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0)
2386
- return j({ total: 0, sample: [] });
2387
- q = siteFiltered.query;
2388
- }
2340
+ let companyIds;
2389
2341
  if (params.company_domain) {
2390
2342
  const { data: companies, error } = await db()
2391
2343
  .from('companies')
@@ -2397,45 +2349,86 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2397
2349
  const ids = (companies ?? []).map((company) => company.id);
2398
2350
  if (!ids.length)
2399
2351
  return j({ total: 0, sample: [] });
2400
- q = q.in('company_id', ids);
2352
+ companyIds = ids;
2401
2353
  }
2354
+ const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
2355
+ const applyEstimateFilters = async (baseQuery) => {
2356
+ let filtered = baseQuery;
2357
+ if (params.lifecycle_stage)
2358
+ filtered = filtered.eq('lifecycle_stage', params.lifecycle_stage);
2359
+ if (params.min_score !== undefined)
2360
+ filtered = filtered.gte('engagement_score', params.min_score);
2361
+ if (params.max_score !== undefined)
2362
+ filtered = filtered.lte('engagement_score', params.max_score);
2363
+ if (params.source)
2364
+ filtered = filtered.eq('source', params.source);
2365
+ if (params.subscribed_after)
2366
+ filtered = filtered.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
2367
+ if (params.subscribed_before)
2368
+ filtered = filtered.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
2369
+ if (params.has_opened === true)
2370
+ filtered = filtered.not('last_opened_at', 'is', null);
2371
+ if (params.has_opened === false)
2372
+ filtered = filtered.is('last_opened_at', null);
2373
+ if (params.has_clicked === true)
2374
+ filtered = filtered.not('last_clicked_at', 'is', null);
2375
+ if (params.has_clicked === false)
2376
+ filtered = filtered.is('last_clicked_at', null);
2377
+ if (params.tag)
2378
+ filtered = filtered.contains('tags', [params.tag]);
2379
+ if (params.company_id)
2380
+ filtered = filtered.eq('company_id', params.company_id);
2381
+ if (companyIds)
2382
+ filtered = filtered.in('company_id', companyIds);
2383
+ if (params.custom_property) {
2384
+ try {
2385
+ // #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
2386
+ const res = await applySegmentRules(filtered, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true });
2387
+ filtered = res.query;
2388
+ }
2389
+ catch (error) {
2390
+ if (isSegmentRulesError(error))
2391
+ return { query: filtered, error: toolError({ error: error.message, code: 'validation_failed' }) };
2392
+ throw error;
2393
+ }
2394
+ }
2395
+ if (params.site_id) {
2396
+ // Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
2397
+ // Deliberately everyone on the site, so allowEmpty.
2398
+ const siteFiltered = await applySegmentRules(filtered, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
2399
+ if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0) {
2400
+ return { query: siteFiltered.query, empty: true };
2401
+ }
2402
+ filtered = siteFiltered.query;
2403
+ }
2404
+ return { query: filtered };
2405
+ };
2406
+ let sampleBase = db()
2407
+ .from('subscribers')
2408
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
2409
+ .eq('project_id', projectId)
2410
+ .eq('global_unsubscribed', false);
2411
+ const sampleFilterResult = await applyEstimateFilters(sampleBase);
2412
+ if (sampleFilterResult.error)
2413
+ return sampleFilterResult.error;
2414
+ if (sampleFilterResult.empty)
2415
+ return j({ total: 0, sample: [] });
2416
+ let q = sampleFilterResult.query;
2402
2417
  const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
2403
2418
  if (error)
2404
2419
  return dbErrorResult(error);
2405
- // Get full count separately
2406
- let countQ = db()
2420
+ // Get full count separately using the exact same filters
2421
+ let countBase = db()
2407
2422
  .from('subscribers')
2408
2423
  .select('id', { count: 'exact', head: true })
2409
2424
  .eq('project_id', projectId)
2410
2425
  .eq('global_unsubscribed', false);
2411
- if (params.lifecycle_stage)
2412
- countQ = countQ.eq('lifecycle_stage', params.lifecycle_stage);
2413
- if (params.min_score !== undefined)
2414
- countQ = countQ.gte('engagement_score', params.min_score);
2415
- if (params.max_score !== undefined)
2416
- countQ = countQ.lte('engagement_score', params.max_score);
2417
- if (params.source)
2418
- countQ = countQ.eq('source', params.source);
2419
- if (params.subscribed_after)
2420
- countQ = countQ.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
2421
- if (params.subscribed_before)
2422
- countQ = countQ.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
2423
- if (params.has_opened === true)
2424
- countQ = countQ.not('last_opened_at', 'is', null);
2425
- if (params.has_opened === false)
2426
- countQ = countQ.is('last_opened_at', null);
2427
- if (params.has_clicked === true)
2428
- countQ = countQ.not('last_clicked_at', 'is', null);
2429
- if (params.has_clicked === false)
2430
- countQ = countQ.is('last_clicked_at', null);
2431
- if (params.tag)
2432
- countQ = countQ.contains('tags', [params.tag]);
2433
- if (params.custom_property) {
2434
- ({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
2435
- }
2436
- // The total must count the same site the sample came from (#3674).
2437
- if (params.site_id)
2438
- ({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
2426
+ const countFilterResult = await applyEstimateFilters(countBase);
2427
+ if (countFilterResult.error)
2428
+ return countFilterResult.error;
2429
+ if (countFilterResult.empty)
2430
+ return j({ total: 0, sample: [] });
2431
+ let countQ = countFilterResult.query;
2439
2432
  const { count, error: countError } = await countQ;
2440
2433
  if (countError)
2441
2434
  return dbErrorResult(countError);
@@ -248,14 +248,14 @@ export function registerSegmentTools(server) {
248
248
  if (error)
249
249
  return dbErrorResult(error);
250
250
  if (!seg)
251
- return txt('Error: segment not found');
251
+ return toolError({ error: 'Segment not found', code: 'not_found' });
252
252
  segRules = seg.rules;
253
253
  }
254
254
  else if (rules !== undefined) {
255
255
  segRules = rules;
256
256
  }
257
257
  else {
258
- return txt('Provide either segment_id or rules.');
258
+ return toolError({ error: 'Provide either segment_id or rules.', code: 'validation_failed' });
259
259
  }
260
260
  let ids;
261
261
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.174",
3
+ "version": "1.0.176",
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",