@sendinel/mcp-server 1.0.145 → 1.0.147

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.
Files changed (44) hide show
  1. package/build/lib/action-approvals.js +3 -1
  2. package/build/lib/db-error.d.ts +39 -6
  3. package/build/lib/db-error.js +221 -11
  4. package/build/lib/tags.d.ts +10 -0
  5. package/build/lib/tags.js +29 -0
  6. package/build/server.js +5 -3
  7. package/build/tools/ab-testing.js +4 -3
  8. package/build/tools/advisor.js +2 -2
  9. package/build/tools/ai-work.js +14 -13
  10. package/build/tools/analytics.js +26 -26
  11. package/build/tools/approvals.js +3 -1
  12. package/build/tools/automations.js +12 -11
  13. package/build/tools/brand-kit.js +4 -3
  14. package/build/tools/campaign-sets.js +11 -10
  15. package/build/tools/campaigns.js +73 -74
  16. package/build/tools/compound.js +7 -6
  17. package/build/tools/connector.js +4 -3
  18. package/build/tools/contacts.js +116 -86
  19. package/build/tools/content.js +16 -16
  20. package/build/tools/convert-imported.js +8 -7
  21. package/build/tools/data-proposals.js +8 -7
  22. package/build/tools/data.js +39 -38
  23. package/build/tools/delivery-ops.js +32 -32
  24. package/build/tools/drafts.js +10 -9
  25. package/build/tools/forms.js +5 -4
  26. package/build/tools/gdpr.js +10 -9
  27. package/build/tools/landing-pages.js +3 -3
  28. package/build/tools/lists.js +21 -20
  29. package/build/tools/newsletters.js +26 -25
  30. package/build/tools/org.js +26 -23
  31. package/build/tools/segments.js +4 -5
  32. package/build/tools/short-links.js +7 -6
  33. package/build/tools/signatures.js +10 -9
  34. package/build/tools/sites.js +9 -8
  35. package/build/tools/sms.js +14 -13
  36. package/build/tools/social-posts.js +20 -19
  37. package/build/tools/social-replies.js +7 -6
  38. package/build/tools/surveys.js +10 -9
  39. package/build/tools/templates.js +14 -13
  40. package/build/tools/warmup.js +5 -4
  41. package/build/tools/webhooks.js +5 -4
  42. package/package.json +1 -1
  43. package/build/lib/db-errors.d.ts +0 -12
  44. package/build/lib/db-errors.js +0 -20
@@ -1,4 +1,4 @@
1
- import { dbErrorResult } from '../lib/db-errors.js';
1
+ 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';
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { db, projectClientForWriteback } from '../db.js';
9
9
  import { getProjectId } from '../project.js';
10
- import { dbToolError, toolError } from '../lib/db-error.js';
10
+ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
11
11
  import { resolveSegmentContactIds } from '../lib/send-audience.js';
12
12
  import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
13
13
  import { logConsent, logAudit } from '../audit.js';
@@ -335,7 +335,7 @@ async function resolveSubscriberViewSiteId(projectId, siteParam) {
335
335
  .eq('active', true)
336
336
  .order('name');
337
337
  if (error)
338
- return { error: error.message };
338
+ return { ...dbToolError(error) };
339
339
  const all = (sites ?? []);
340
340
  if (SUBSCRIBER_VIEW_UUID_RE.test(siteParam)) {
341
341
  return { siteId: all.some((site) => site.id === siteParam) ? siteParam : undefined };
@@ -425,7 +425,7 @@ function isMissingRelationshipError(error) {
425
425
  return false;
426
426
  if (error.code === 'PGRST200')
427
427
  return true;
428
- return /could not find a relationship/i.test(error.message ?? '');
428
+ return /could not find a relationship/i.test((error ? dbToolError(error).error : undefined) ?? '');
429
429
  }
430
430
  async function hasCompaniesEmbed(projectId) {
431
431
  const cached = companiesEmbedCache.get(projectId);
@@ -574,7 +574,7 @@ async function exportSubscriberData(projectId, contactId) {
574
574
  .eq('project_id', projectId)
575
575
  .maybeSingle();
576
576
  if (contactError)
577
- return { error: contactError.message };
577
+ return { ...dbToolError(contactError) };
578
578
  if (!contact)
579
579
  return { error: 'Subscriber not found', code: 'NOT_FOUND' };
580
580
  const { data: emailHistory, error: logError } = await db()
@@ -585,7 +585,7 @@ async function exportSubscriberData(projectId, contactId) {
585
585
  .not('to_email', 'like', 'sha256:%')
586
586
  .order('sent_at', { ascending: false });
587
587
  if (logError)
588
- return { error: logError.message };
588
+ return { ...dbToolError(logError) };
589
589
  const { data: suppression, error: suppressionError } = await db()
590
590
  .from('suppressions')
591
591
  .select('reason, created_at')
@@ -594,7 +594,7 @@ async function exportSubscriberData(projectId, contactId) {
594
594
  .not('email', 'like', 'sha256:%')
595
595
  .maybeSingle();
596
596
  if (suppressionError)
597
- return { error: suppressionError.message };
597
+ return { ...dbToolError(suppressionError) };
598
598
  return {
599
599
  contact: {
600
600
  id: contact.id,
@@ -713,7 +713,7 @@ export function registerContactTools(server) {
713
713
  }, async () => {
714
714
  const { data, error } = await db().from('hygiene_config').select('*').eq('project_id', getProjectId()).maybeSingle();
715
715
  if (error)
716
- return j({ error: error.message });
716
+ return j(dbToolError(error));
717
717
  return j(data ?? { enabled: false, inactive_days: 90, action: 'tag', tag_name: 'cold', suppress_after_days: 180, activity_property: null, last_run_at: null });
718
718
  });
719
719
  server.registerTool('update_hygiene_config', {
@@ -746,7 +746,7 @@ export function registerContactTools(server) {
746
746
  row.activity_property = activityProperty;
747
747
  const { error } = await db().from('hygiene_config').upsert(row, { onConflict: 'project_id' });
748
748
  if (error)
749
- return j({ error: error.message });
749
+ return j(dbToolError(error));
750
750
  return j({ updated: true });
751
751
  });
752
752
  server.registerTool('run_list_hygiene', {
@@ -766,7 +766,7 @@ export function registerContactTools(server) {
766
766
  // read past 1000 is now safe as a follow-up.
767
767
  const { data: contacts, error: readError } = await db().from('subscribers').select('id, email, first_name, last_name, custom_properties, tags, last_engaged_at').eq('project_id', projectId).lt('last_engaged_at', cutoff).limit(1000);
768
768
  if (readError)
769
- return j({ error: readError.message });
769
+ return j(dbToolError(readError));
770
770
  let tagged = 0;
771
771
  let skipped = 0;
772
772
  let failed = 0;
@@ -907,7 +907,7 @@ export function registerContactTools(server) {
907
907
  q = q.contains('tags', [`segment:${segmentId}`]);
908
908
  const { data: sample, error } = await q;
909
909
  if (error)
910
- return txt(`Error: ${error.message}`);
910
+ return txt(`Error: ${dbToolError(error).error}`);
911
911
  const apiKey = process.env.ANTHROPIC_API_KEY;
912
912
  if (!apiKey)
913
913
  return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_UNAVAILABLE' });
@@ -983,7 +983,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
983
983
  .eq('project_id', projectId)
984
984
  .maybeSingle();
985
985
  if (viewError)
986
- return txt(`Error: ${viewError.message}`);
986
+ return txt(`Error: ${dbToolError(viewError).error}`);
987
987
  if (!view)
988
988
  return txt('Error: View not found');
989
989
  viewFilters = normalizeSubscriberViewFilters(view.filters);
@@ -1049,7 +1049,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1049
1049
  .select('contact_id, subscribed')
1050
1050
  .eq('site_id', statusSiteId);
1051
1051
  if (siteSubscriptionsError)
1052
- return txt(`Error: ${siteSubscriptionsError.message}`);
1052
+ return txt(`Error: ${dbToolError(siteSubscriptionsError).error}`);
1053
1053
  if (viewStatus === 'not_subscribed') {
1054
1054
  const subscribedIds = (siteSubscriptions ?? [])
1055
1055
  .map((subscription) => subscription.contact_id)
@@ -1081,7 +1081,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1081
1081
  }
1082
1082
  const { data, error } = await q;
1083
1083
  if (error)
1084
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1084
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1085
1085
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1086
1086
  });
1087
1087
  server.registerTool('list_subscriber_views', {
@@ -1101,7 +1101,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1101
1101
  .eq('project_id', getProjectId())
1102
1102
  .order('created_at', { ascending: true });
1103
1103
  if (error)
1104
- return txt(`Error: ${error.message}`);
1104
+ return txt(`Error: ${dbToolError(error).error}`);
1105
1105
  return j({ views: data ?? [] });
1106
1106
  });
1107
1107
  server.registerTool('create_subscriber_view', {
@@ -1219,7 +1219,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1219
1219
  .select('id')
1220
1220
  .maybeSingle();
1221
1221
  if (error)
1222
- return txt(`Error: ${error.message}`);
1222
+ return txt(`Error: ${dbToolError(error).error}`);
1223
1223
  if (!data)
1224
1224
  return txt('View not found');
1225
1225
  return j({ deleted: true, view_id });
@@ -1250,7 +1250,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1250
1250
  }
1251
1251
  const { data: companies, error } = await query;
1252
1252
  if (error)
1253
- return txt(`Error: ${error.message}`);
1253
+ return txt(`Error: ${dbToolError(error).error}`);
1254
1254
  const rows = companies ?? [];
1255
1255
  if (!rows.length)
1256
1256
  return j([]);
@@ -1265,7 +1265,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1265
1265
  .order('id')
1266
1266
  .range(offset, offset + 999);
1267
1267
  if (subscriberError)
1268
- return txt(`Error: ${subscriberError.message}`);
1268
+ return txt(`Error: ${dbToolError(subscriberError).error}`);
1269
1269
  for (const subscriber of subscribers ?? []) {
1270
1270
  const companyId = typeof subscriber.company_id === 'string' ? subscriber.company_id : null;
1271
1271
  if (!companyId)
@@ -1299,6 +1299,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1299
1299
  company_id: z.string().uuid().optional(),
1300
1300
  company_name: z.string().optional(),
1301
1301
  company_domain: z.string().optional(),
1302
+ tags: z.array(z.string()).optional().describe('Initial tags for new subscribers; existing contacts keep their stored tags.'),
1302
1303
  source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
1303
1304
  utm_source: z.string().optional(),
1304
1305
  utm_medium: z.string().optional(),
@@ -1306,8 +1307,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1306
1307
  utm_content: z.string().optional(),
1307
1308
  utm_term: z.string().optional(),
1308
1309
  },
1309
- }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
1310
+ }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
1310
1311
  try {
1312
+ const normalizedTags = normalizeTagInput(tags ?? []);
1313
+ const warnings = tagInputWarnings(normalizedTags);
1314
+ const tagWarnings = warnings.length ? { warnings } : {};
1311
1315
  email = email.trim().toLowerCase();
1312
1316
  const projectId = getProjectId();
1313
1317
  // Block distribution list / system addresses
@@ -1351,6 +1355,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1351
1355
  let contact;
1352
1356
  if (!existingContact) {
1353
1357
  const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1358
+ if (tags !== undefined)
1359
+ row.tags = normalizedTags.tags;
1354
1360
  if (customProperties)
1355
1361
  row.custom_properties = customProperties;
1356
1362
  if (!needsConfirm)
@@ -1426,15 +1432,15 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1426
1432
  if (needsConfirm) {
1427
1433
  // Create opt-in token (confirmation email must be sent externally in MCP context)
1428
1434
  await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1429
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1435
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
1430
1436
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1431
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1437
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1432
1438
  }
1433
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1439
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
1434
1440
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1435
1441
  if (!existing)
1436
1442
  logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1437
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
1443
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, ...existingMessage }, null, 2) }] };
1438
1444
  }
1439
1445
  catch (error) {
1440
1446
  return dbErrorResult(error);
@@ -1524,7 +1530,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1524
1530
  .upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: false })
1525
1531
  .select('id, email, first_name, last_name, custom_properties');
1526
1532
  if (uErr)
1527
- return { content: [{ type: 'text', text: `Error: ${uErr.message}` }] };
1533
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(uErr).error}` }] };
1528
1534
  const contactRows = upserted ?? [];
1529
1535
  const subRows = contactRows.map((c) => ({ contact_id: c.id, site_id, subscribed: true }));
1530
1536
  if (subRows.length) {
@@ -1557,7 +1563,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1557
1563
  .select('id, email, first_name, last_name, custom_properties')
1558
1564
  .single();
1559
1565
  if (error)
1560
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1566
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1561
1567
  if (data) {
1562
1568
  // Cascade to site subscriptions
1563
1569
  await db()
@@ -1630,7 +1636,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1630
1636
  q = q.ilike('subject', `%${subject_contains.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
1631
1637
  const { data, error } = await q;
1632
1638
  if (error)
1633
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1639
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1634
1640
  const fetched = (data ?? []);
1635
1641
  const hasMore = fetched.length > pageSize;
1636
1642
  // source: where the email came from. A campaign id wins; otherwise the stored category (transactional, test, marketing).
@@ -1665,7 +1671,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1665
1671
  q = q.eq('reason', reason);
1666
1672
  const { data, error } = await q;
1667
1673
  if (error)
1668
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1674
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1669
1675
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1670
1676
  });
1671
1677
  server.registerTool('add_suppression', {
@@ -1689,7 +1695,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1689
1695
  .select()
1690
1696
  .single();
1691
1697
  if (error)
1692
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1698
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1693
1699
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1694
1700
  });
1695
1701
  server.registerTool('list_subscribers_by_segment', {
@@ -1728,7 +1734,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1728
1734
  segmentIds = await resolveSegmentContactIds(projectId, segment.rules);
1729
1735
  }
1730
1736
  catch (error) {
1731
- return toolError(isSegmentRulesError(error) ? { error: error.message, code: 'validation_failed' } : { error: 'Unable to evaluate segment', code: 'internal_error' });
1737
+ return toolError(isSegmentRulesError(error) ? { ...dbToolError(error), code: 'validation_failed' } : { error: 'Unable to evaluate segment', code: 'internal_error' });
1732
1738
  }
1733
1739
  if (!segmentIds.length)
1734
1740
  return j([]);
@@ -1815,6 +1821,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1815
1821
  }, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
1816
1822
  try {
1817
1823
  const projectId = getProjectId();
1824
+ const normalizedTags = tags !== undefined ? normalizeTagInput(tags) : undefined;
1825
+ if (normalizedTags)
1826
+ tags = normalizedTags.tags;
1818
1827
  let propertyWarnings = [];
1819
1828
  let fields;
1820
1829
  if (custom_properties !== undefined) {
@@ -2000,7 +2009,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2000
2009
  ...(updates.tags !== undefined ? { tags: updates.tags } : {}),
2001
2010
  ...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
2002
2011
  });
2003
- const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
2012
+ const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings, ...(normalizedTags ? tagInputWarnings(normalizedTags) : [])];
2004
2013
  return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
2005
2014
  }
2006
2015
  catch (error) {
@@ -2035,7 +2044,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2035
2044
  return txt('Provide either contact_id or email.');
2036
2045
  const { data, error } = await q.maybeSingle();
2037
2046
  if (error)
2038
- return txt(`Error: ${error.message}`);
2047
+ return txt(`Error: ${dbToolError(error).error}`);
2039
2048
  return j(data);
2040
2049
  });
2041
2050
  server.registerTool('enrich_subscriber', {
@@ -2061,7 +2070,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2061
2070
  .eq('id', contact_id)
2062
2071
  .maybeSingle();
2063
2072
  if (error)
2064
- return txt(`Error: ${error.message}`);
2073
+ return txt(`Error: ${dbToolError(error).error}`);
2065
2074
  if (!contact?.email)
2066
2075
  return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2067
2076
  const enriched = await enrichContactFromProviders(projectId, contact.email, provider);
@@ -2099,7 +2108,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2099
2108
  .select(embedOk ? '*, company:companies(id, name, domain)' : '*')
2100
2109
  .single();
2101
2110
  if (updateError)
2102
- return txt(`Error: ${updateError.message}`);
2111
+ return txt(`Error: ${dbToolError(updateError).error}`);
2103
2112
  const appliedFields = [];
2104
2113
  if (!contact.first_name && enriched.firstName)
2105
2114
  appliedFields.push('first_name');
@@ -2136,57 +2145,78 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2136
2145
  idempotentHint: true,
2137
2146
  openWorldHint: false,
2138
2147
  },
2139
- description: 'Add or remove tags from one or more contacts',
2148
+ description: 'Add or remove tags from one or more contacts. Tags are trimmed; empty values are ignored; case variants of an existing tag are not added again. Removal is case-insensitive. Ids not found are returned in not_found.',
2140
2149
  inputSchema: {
2141
2150
  contact_ids: z.array(z.string().uuid()).min(1),
2142
2151
  add_tags: z.array(z.string()).optional(),
2143
2152
  remove_tags: z.array(z.string()).optional(),
2144
2153
  },
2145
2154
  }, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
2155
+ const normalized = normalizeTagInput(add_tags);
2156
+ add_tags = normalized.tags;
2157
+ remove_tags = remove_tags.map(tag => tag.trim()).filter(Boolean);
2158
+ const warnings = new Set(tagInputWarnings(normalized));
2146
2159
  if (!add_tags.length && !remove_tags.length)
2147
2160
  return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
2148
2161
  const projectId = getProjectId();
2149
2162
  let updated = 0;
2150
2163
  const missing_contact_ids = [];
2164
+ const failed = [];
2151
2165
  for (const cid of contact_ids) {
2152
- const { data: contact, error: readError } = await db()
2153
- .from('subscribers')
2154
- .select('id, email, first_name, last_name, custom_properties, tags')
2155
- .eq('id', cid)
2156
- .eq('project_id', projectId)
2157
- .maybeSingle();
2158
- if (readError)
2159
- return toolError({ ...dbToolError(readError), updated });
2160
- if (!contact) {
2161
- missing_contact_ids.push(cid);
2162
- continue;
2163
- }
2164
- const currentTags = contact.tags ?? [];
2165
- const removeSet = new Set(remove_tags);
2166
- const merged = currentTags.filter((t) => !removeSet.has(t));
2167
- for (const t of add_tags) {
2168
- if (!merged.includes(t))
2169
- merged.push(t);
2166
+ try {
2167
+ const { data: contact, error: readError } = await db()
2168
+ .from('subscribers')
2169
+ .select('id, email, first_name, last_name, custom_properties, tags')
2170
+ .eq('id', cid)
2171
+ .eq('project_id', projectId)
2172
+ .maybeSingle();
2173
+ if (readError) {
2174
+ failed.push({ id: cid, ...dbToolError(readError) });
2175
+ continue;
2176
+ }
2177
+ if (!contact) {
2178
+ missing_contact_ids.push(cid);
2179
+ continue;
2180
+ }
2181
+ const currentTags = contact.tags ?? [];
2182
+ const removeSet = new Set(remove_tags.map(tag => tag.toLowerCase()));
2183
+ const merged = currentTags.filter((t) => !removeSet.has(t.trim().toLowerCase()));
2184
+ for (const tag of add_tags) {
2185
+ const existing = merged.find(value => value.trim().toLowerCase() === tag.toLowerCase());
2186
+ if (existing !== undefined)
2187
+ warnings.add(`${tag} already exists as ${existing}`);
2188
+ else
2189
+ merged.push(tag);
2190
+ }
2191
+ const { data: saved, error } = await db()
2192
+ .from('subscribers')
2193
+ .update({ tags: merged })
2194
+ .eq('id', cid)
2195
+ .eq('project_id', projectId)
2196
+ .select('id').maybeSingle();
2197
+ if (error) {
2198
+ failed.push({ id: cid, ...dbToolError(error) });
2199
+ continue;
2200
+ }
2201
+ if (!saved) {
2202
+ missing_contact_ids.push(cid);
2203
+ continue;
2204
+ }
2205
+ updated++;
2206
+ await writeBackMcpContact(projectId, contact, { tags: merged });
2207
+ await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
2170
2208
  }
2171
- const { data: saved, error } = await db()
2172
- .from('subscribers')
2173
- .update({ tags: merged })
2174
- .eq('id', cid)
2175
- .eq('project_id', projectId)
2176
- .select('id').maybeSingle();
2177
- if (error)
2178
- return toolError({ ...dbToolError(error), updated });
2179
- if (!saved) {
2180
- missing_contact_ids.push(cid);
2181
- continue;
2209
+ catch {
2210
+ failed.push({ id: cid, ...dbToolError({}) });
2182
2211
  }
2183
- updated++;
2184
- await writeBackMcpContact(projectId, contact, { tags: merged });
2185
- await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
2186
2212
  }
2213
+ const result = { missing_contact_ids, not_found: missing_contact_ids, failed, updated, total: contact_ids.length, add_tags, remove_tags, ...(warnings.size ? { warnings: [...warnings] } : {}) };
2214
+ // Preserve the existing coded-error convention when no requested write succeeded.
2215
+ if (!updated && failed.length)
2216
+ return toolError({ ...dbToolError({}), ...result, code: failed[0].code, error: failed[0].error });
2187
2217
  if (!updated)
2188
- return toolError({ error: 'Subscriber not found', code: 'not_found', updated, missing_contact_ids });
2189
- return j({ missing_contact_ids, updated, total: contact_ids.length, add_tags, remove_tags });
2218
+ return toolError({ error: 'Subscriber not found', code: 'not_found', ...result });
2219
+ return j(result);
2190
2220
  });
2191
2221
  server.registerTool('bulk_update_subscribers', {
2192
2222
  title: 'Bulk Update Subscribers',
@@ -2324,7 +2354,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2324
2354
  }
2325
2355
  catch (error) {
2326
2356
  if (isSegmentRulesError(error))
2327
- return txt(`Error: ${error.message}`);
2357
+ return txt(`Error: ${dbToolError(error).error}`);
2328
2358
  throw error;
2329
2359
  }
2330
2360
  }
@@ -2349,7 +2379,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2349
2379
  }
2350
2380
  const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
2351
2381
  if (error)
2352
- return txt(`Error: ${error.message}`);
2382
+ return txt(`Error: ${dbToolError(error).error}`);
2353
2383
  // Get full count separately
2354
2384
  let countQ = db()
2355
2385
  .from('subscribers')
@@ -2435,7 +2465,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2435
2465
  .order('email')
2436
2466
  .range(offset, offset + 999);
2437
2467
  if (suppressionError)
2438
- return txt(`Error: ${suppressionError.message}`);
2468
+ return txt(`Error: ${dbToolError(suppressionError).error}`);
2439
2469
  for (const suppression of suppressed ?? [])
2440
2470
  suppressedSet.add(suppression.email);
2441
2471
  if ((suppressed ?? []).length < 1000)
@@ -2443,7 +2473,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2443
2473
  }
2444
2474
  const { data: contacts, error } = await q.order('engagement_score', { ascending: true }).limit(sampleLimit);
2445
2475
  if (error)
2446
- return txt(`Error: ${error.message}`);
2476
+ return txt(`Error: ${dbToolError(error).error}`);
2447
2477
  const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
2448
2478
  if (!dry_run && inactive.length > 0) {
2449
2479
  const channelContext = currentChannelContext();
@@ -2475,7 +2505,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2475
2505
  .from('suppressions')
2476
2506
  .upsert(suppressionRows, { onConflict: 'project_id,email', ignoreDuplicates: true });
2477
2507
  if (supErr)
2478
- return txt(`Error suppressing: ${supErr.message}`);
2508
+ return txt(`Error suppressing: ${dbToolError(supErr).error}`);
2479
2509
  return j({ suppressed: inactive.length, days_threshold: days, sample: inactive });
2480
2510
  }
2481
2511
  return j({
@@ -2511,7 +2541,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2511
2541
  .select()
2512
2542
  .single();
2513
2543
  if (error)
2514
- return txt(`Error: ${error.message}`);
2544
+ return txt(`Error: ${dbToolError(error).error}`);
2515
2545
  if (!data)
2516
2546
  return txt(`No suppression found for ${email}.`);
2517
2547
  return j({ removed: true, email, previous_reason: data.reason });
@@ -2594,7 +2624,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2594
2624
  for (const ids of chunkIds(siteIds)) {
2595
2625
  const part = await scanChunk(ids);
2596
2626
  if (!part.ok)
2597
- return txt(`Error: ${part.error.message}`);
2627
+ return txt(`Error: ${dbToolError(part.error).error}`);
2598
2628
  all.push(...part.rows);
2599
2629
  total = total === null || part.total === null ? null : total + part.total;
2600
2630
  }
@@ -2606,7 +2636,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2606
2636
  scan = await scanChunk(siteIds);
2607
2637
  }
2608
2638
  if (!scan.ok)
2609
- return txt(`Error: ${scan.error.message}`);
2639
+ return txt(`Error: ${dbToolError(scan.error).error}`);
2610
2640
  const contacts = scan.rows;
2611
2641
  // #3604: say when the scan stopped at the limit instead of presenting a partial scan as complete.
2612
2642
  const scanned = contacts?.length ?? 0;
@@ -2682,7 +2712,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2682
2712
  p_source_ids: source_ids,
2683
2713
  });
2684
2714
  if (error)
2685
- return txt(`Error: ${error.message}`);
2715
+ return txt(`Error: ${dbToolError(error).error}`);
2686
2716
  logAudit({ action: 'contacts.merged', resourceType: 'contact', resourceId: target_id, details: { source_ids, result: data } });
2687
2717
  return j(data);
2688
2718
  });
@@ -2706,7 +2736,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2706
2736
  ...(siteScope ? { p_site_id: siteScope } : {}),
2707
2737
  });
2708
2738
  if (error)
2709
- return { error: `Error: ${error.message}` };
2739
+ return { error: `Error: ${dbToolError(error).error}` };
2710
2740
  const result = (Array.isArray(data) ? data[0] : data);
2711
2741
  if (typeof result?.has_membership !== 'boolean' || !Array.isArray(result.keys)) {
2712
2742
  return { error: 'Error: get_contact_schema_keys returned an unreadable result' };
@@ -2811,7 +2841,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2811
2841
  if (error) {
2812
2842
  if (error.code === '23505')
2813
2843
  return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
2814
- return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
2844
+ return j({ error: `Error: ${dbToolError(error).error}`, code: 'INTERNAL_ERROR' });
2815
2845
  }
2816
2846
  await logAudit({
2817
2847
  action: 'custom_field.created',
@@ -2842,7 +2872,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2842
2872
  .eq('project_id', projectId)
2843
2873
  .maybeSingle();
2844
2874
  if (currentError)
2845
- return txt(`Error: ${currentError.message}`);
2875
+ return txt(`Error: ${dbToolError(currentError).error}`);
2846
2876
  if (!current)
2847
2877
  return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2848
2878
  const nextType = (type ?? current.type);
@@ -2868,7 +2898,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2868
2898
  .select('id, key, label, type, required, options, display_order')
2869
2899
  .maybeSingle();
2870
2900
  if (error)
2871
- return txt(`Error: ${error.message}`);
2901
+ return txt(`Error: ${dbToolError(error).error}`);
2872
2902
  if (!data)
2873
2903
  return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2874
2904
  await logAudit({
@@ -2894,7 +2924,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2894
2924
  .select('id, key')
2895
2925
  .maybeSingle();
2896
2926
  if (error)
2897
- return txt(`Error: ${error.message}`);
2927
+ return txt(`Error: ${dbToolError(error).error}`);
2898
2928
  if (!data)
2899
2929
  return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2900
2930
  await logAudit({
@@ -2921,7 +2951,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2921
2951
  }, async ({ contact_id, window_hours }) => {
2922
2952
  const { data, error } = await getContactNeighborhood(db(), getProjectId(), contact_id, window_hours);
2923
2953
  if (error)
2924
- return txt(`Error: ${error.message}`);
2954
+ return txt(`Error: ${dbToolError(error).error}`);
2925
2955
  if (data == null)
2926
2956
  return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2927
2957
  return j({ ...data, attention: buildPreviewAttentionEnvelope() });
@@ -2942,7 +2972,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2942
2972
  }, async ({ contact_id, window_hours }) => {
2943
2973
  const { data, error } = await getScheduledTouches(db(), getProjectId(), contact_id, window_hours);
2944
2974
  if (error)
2945
- return txt(`Error: ${error.message}`);
2975
+ return txt(`Error: ${dbToolError(error).error}`);
2946
2976
  if (data == null)
2947
2977
  return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2948
2978
  return j({ ...data, attention: buildPreviewAttentionEnvelope() });
@@ -2969,7 +2999,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2969
2999
  enrollmentId: enrollment_id,
2970
3000
  });
2971
3001
  if (error)
2972
- return txt(`Error: ${error.message}`);
3002
+ return txt(`Error: ${dbToolError(error).error}`);
2973
3003
  if (data == null)
2974
3004
  return j({ error: 'Not found', code: 'NOT_FOUND' });
2975
3005
  return j(data);
@@ -2994,7 +3024,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2994
3024
  }
2995
3025
  const { data, error } = await getContactNeighborhood(db(), getProjectId(), contactId);
2996
3026
  const payload = error
2997
- ? { error: error.message }
3027
+ ? { ...dbToolError(error) }
2998
3028
  : data == null
2999
3029
  ? { error: 'Subscriber not found', code: 'NOT_FOUND' }
3000
3030
  : data;