@sendinel/mcp-server 1.0.146 → 1.0.148

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 (42) 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/server.js +5 -3
  5. package/build/tools/ab-testing.js +4 -3
  6. package/build/tools/advisor.js +2 -2
  7. package/build/tools/ai-work.js +14 -13
  8. package/build/tools/analytics.js +33 -32
  9. package/build/tools/approvals.js +3 -1
  10. package/build/tools/automations.js +12 -11
  11. package/build/tools/brand-kit.js +4 -3
  12. package/build/tools/campaign-sets.js +11 -10
  13. package/build/tools/campaigns.js +81 -79
  14. package/build/tools/compound.js +7 -6
  15. package/build/tools/connector.js +4 -3
  16. package/build/tools/contacts.js +44 -45
  17. package/build/tools/content.js +16 -16
  18. package/build/tools/convert-imported.js +8 -7
  19. package/build/tools/data-proposals.js +8 -7
  20. package/build/tools/data.js +39 -38
  21. package/build/tools/delivery-ops.js +32 -32
  22. package/build/tools/drafts.js +10 -9
  23. package/build/tools/forms.js +5 -4
  24. package/build/tools/gdpr.js +10 -9
  25. package/build/tools/landing-pages.js +3 -3
  26. package/build/tools/lists.js +21 -20
  27. package/build/tools/newsletters.js +26 -25
  28. package/build/tools/org.js +26 -23
  29. package/build/tools/segments.js +4 -5
  30. package/build/tools/short-links.js +7 -6
  31. package/build/tools/signatures.js +10 -9
  32. package/build/tools/sites.js +9 -8
  33. package/build/tools/sms.js +14 -13
  34. package/build/tools/social-posts.js +20 -19
  35. package/build/tools/social-replies.js +7 -6
  36. package/build/tools/surveys.js +10 -9
  37. package/build/tools/templates.js +14 -13
  38. package/build/tools/warmup.js +5 -4
  39. package/build/tools/webhooks.js +5 -4
  40. package/package.json +1 -1
  41. package/build/lib/db-errors.d.ts +0 -12
  42. package/build/lib/db-errors.js +0 -20
@@ -1,3 +1,4 @@
1
+ import { isSentRow } from "@sendinel/domain";
1
2
  import { readPeriodCampaigns, campaignPeriodFields, filterPlanContacts } from "@sendinel/domain";
2
3
  import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
3
4
  import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
@@ -42,7 +43,7 @@ export function computeSenderHealthScore(metrics, trackingEnabled) {
42
43
  function sendVolumeByDay(rows) {
43
44
  const byDay = new Map();
44
45
  for (const row of rows) {
45
- if (!row.sent_at)
46
+ if (!row.sent_at || !isSentRow(row))
46
47
  continue;
47
48
  const date = new Date(row.sent_at).toISOString().slice(0, 10);
48
49
  byDay.set(date, (byDay.get(date) ?? 0) + 1);
@@ -133,12 +134,12 @@ function distinctEventCount(events, eventType, messageIds) {
133
134
  .map(event => event.provider_message_id)).size;
134
135
  }
135
136
  function buildTrueConversionAnalytics(params) {
136
- const sent = params.logs.length;
137
+ const sent = countEmailRows(params.logs).sent;
137
138
  const delivered = params.logs.filter(isDeliveredRow).length;
138
139
  const trueRows = params.attributionRows.filter(row => isTrueConversionEvent(row.conversion_event));
139
140
  const campaignDenominators = new Map();
140
141
  for (const row of params.logs) {
141
- if (typeof row.campaign_id !== 'string')
142
+ if (typeof row.campaign_id !== 'string' || !isSentRow(row))
142
143
  continue;
143
144
  campaignDenominators.set(row.campaign_id, (campaignDenominators.get(row.campaign_id) ?? 0) + 1);
144
145
  }
@@ -229,7 +230,7 @@ export function registerAnalyticsTools(server) {
229
230
  query = query.eq('site_id', site_id);
230
231
  const { data, error } = await query;
231
232
  if (error)
232
- return j({ error: error.message });
233
+ return j(dbToolError(error));
233
234
  return j({ events: data ?? [] });
234
235
  });
235
236
  server.registerTool('get_portfolio_analytics', {
@@ -288,9 +289,9 @@ export function registerAnalyticsTools(server) {
288
289
  .eq('role', 'owner'),
289
290
  ]);
290
291
  if (directError)
291
- return txt(`Error: ${directError.message}`);
292
+ return txt(`Error: ${dbToolError(directError).error}`);
292
293
  if (ownerError)
293
- return txt(`Error: ${ownerError.message}`);
294
+ return txt(`Error: ${dbToolError(ownerError).error}`);
294
295
  const directProjectIds = Array.from(new Set((directAccess ?? []).map((row) => row.project_id).filter(Boolean)));
295
296
  const ownedOrgIds = Array.from(new Set((ownedOrgs ?? []).map((row) => row.org_id).filter(Boolean)));
296
297
  const [{ data: directProjects, error: directProjectsError }, { data: ownedProjects, error: ownedProjectsError }] = await Promise.all([
@@ -302,9 +303,9 @@ export function registerAnalyticsTools(server) {
302
303
  : Promise.resolve({ data: [], error: null }),
303
304
  ]);
304
305
  if (directProjectsError)
305
- return txt(`Error: ${directProjectsError.message}`);
306
+ return txt(`Error: ${dbToolError(directProjectsError).error}`);
306
307
  if (ownedProjectsError)
307
- return txt(`Error: ${ownedProjectsError.message}`);
308
+ return txt(`Error: ${dbToolError(ownedProjectsError).error}`);
308
309
  const byId = new Map();
309
310
  for (const project of [...(directProjects ?? []), ...(ownedProjects ?? [])]) {
310
311
  if (ctx.invitationProjectIds != null && !ctx.invitationProjectIds.includes(project.id))
@@ -316,7 +317,7 @@ export function registerAnalyticsTools(server) {
316
317
  ? await adminDb().from('organizations').select('id, name').in('id', orgIds)
317
318
  : { data: [], error: null };
318
319
  if (organizationsError)
319
- return txt(`Error: ${organizationsError.message}`);
320
+ return txt(`Error: ${dbToolError(organizationsError).error}`);
320
321
  const orgNames = new Map((organizations ?? []).map((org) => [org.id, org.name]));
321
322
  return j({
322
323
  projects: Array.from(byId.values())
@@ -353,7 +354,7 @@ export function registerAnalyticsTools(server) {
353
354
  .eq('org_id', project.org_id)
354
355
  .order('name');
355
356
  if (listErr)
356
- return txt(`Error: ${listErr.message}`);
357
+ return txt(`Error: ${dbToolError(listErr).error}`);
357
358
  // Get org info
358
359
  const { data: org } = await adminDb()
359
360
  .from('organizations')
@@ -396,7 +397,7 @@ export function registerAnalyticsTools(server) {
396
397
  p_project_id: project_id,
397
398
  });
398
399
  if (accessError)
399
- return j({ error: accessError.message });
400
+ return j(dbToolError(accessError));
400
401
  if (!hasAccess)
401
402
  return j({ error: 'You do not have access to this project.', code: 'PROJECT_ACCESS_DENIED' });
402
403
  if (ctx.invitationProjectIds != null && !ctx.invitationProjectIds.includes(project_id.toLowerCase())) {
@@ -408,7 +409,7 @@ export function registerAnalyticsTools(server) {
408
409
  .update({ active_project_id: project_id })
409
410
  .eq('id', ctx.oauthTokenId);
410
411
  if (updateError)
411
- return j({ error: updateError.message });
412
+ return j(dbToolError(updateError));
412
413
  const { data: project } = await adminDb()
413
414
  .from('projects')
414
415
  .select('id, name, org_id')
@@ -445,7 +446,7 @@ export function registerAnalyticsTools(server) {
445
446
  .eq('project_id', getProjectId())
446
447
  .order('name');
447
448
  if (error)
448
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
449
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
449
450
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
450
451
  });
451
452
  server.registerTool('get_stats', {
@@ -591,7 +592,7 @@ export function registerAnalyticsTools(server) {
591
592
  .order('check_date', { ascending: false })
592
593
  .limit(days);
593
594
  if (error)
594
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
595
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
595
596
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
596
597
  });
597
598
  server.registerTool('get_engagement_insights', {
@@ -611,7 +612,7 @@ export function registerAnalyticsTools(server) {
611
612
  const { data, error } = await db()
612
613
  .rpc('get_engagement_summary', site_id ? { p_project_id: projectId, p_site_id: site_id } : { p_project_id: projectId });
613
614
  if (error)
614
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
615
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
615
616
  let trackingEnabled = true;
616
617
  if (site_id) {
617
618
  const { data: site, error: siteError } = await db()
@@ -621,7 +622,7 @@ export function registerAnalyticsTools(server) {
621
622
  .eq('id', site_id)
622
623
  .maybeSingle();
623
624
  if (siteError)
624
- return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
625
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(siteError).error}` }] };
625
626
  trackingEnabled = site?.tracking_enabled ?? false;
626
627
  }
627
628
  else {
@@ -630,7 +631,7 @@ export function registerAnalyticsTools(server) {
630
631
  .select('tracking_enabled')
631
632
  .eq('project_id', projectId);
632
633
  if (sitesError)
633
- return { content: [{ type: 'text', text: `Error: ${sitesError.message}` }] };
634
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(sitesError).error}` }] };
634
635
  trackingEnabled = (sites ?? []).some((site) => site.tracking_enabled);
635
636
  }
636
637
  const since30d = new Date(Date.now() - 30 * 86_400_000).toISOString();
@@ -644,7 +645,7 @@ export function registerAnalyticsTools(server) {
644
645
  q = q.eq('site_id', site_id);
645
646
  const { data: logs, error: logsError } = await q;
646
647
  if (logsError)
647
- return { content: [{ type: 'text', text: `Error: ${logsError.message}` }] };
648
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(logsError).error}` }] };
648
649
  const rows = (logs ?? []);
649
650
  const metrics30d = computeAnalyticsMetrics(rows);
650
651
  const metrics7d = computeAnalyticsMetrics(rows.filter(row => row.sent_at && row.sent_at >= since7d));
@@ -714,7 +715,7 @@ export function registerAnalyticsTools(server) {
714
715
  .eq('project_id', projectId)
715
716
  .maybeSingle();
716
717
  if (siteErr || !site)
717
- return txt(`Error: Site not found — ${siteErr?.message ?? 'not found'}`);
718
+ return txt(`Error: Site not found — ${(siteErr ? dbToolError(siteErr).error : undefined) ?? 'not found'}`);
718
719
  const domain = site.sending_domain;
719
720
  const dnsResults = { spf: null, dkim: null, dmarc: null };
720
721
  let verificationSource = 'dns_lookup';
@@ -815,7 +816,7 @@ export function registerAnalyticsTools(server) {
815
816
  .eq('site_id', site_id)
816
817
  .gte('sent_at', sevenDaysAgo);
817
818
  const logs = recentLogs ?? [];
818
- const totalSent = logs.length;
819
+ const totalSent = countEmailRows(logs).sent;
819
820
  const bounced = logs.filter((l) => isBouncedRow(l)).length;
820
821
  const complained = logs.filter(isComplainedRow).length;
821
822
  const bounceRate = percentRate(bounced, totalSent, 8) ?? 0;
@@ -884,7 +885,7 @@ export function registerAnalyticsTools(server) {
884
885
  q = q.eq('campaign_id', campaign_id);
885
886
  const { data: logs, error } = await q;
886
887
  if (error)
887
- return txt(`Error: ${error.message}`);
888
+ return txt(`Error: ${dbToolError(error).error}`);
888
889
  let periodCampaigns;
889
890
  try {
890
891
  periodCampaigns = campaign_id || group_by === 'campaign' ? await readPeriodCampaigns(db(), projectId, { siteId: site_id, campaignId: campaign_id, start: since }) : new Map();
@@ -898,7 +899,7 @@ export function registerAnalyticsTools(server) {
898
899
  return j({ message: `No email activity in the last ${days} days.`, total: 0 });
899
900
  const { data: attributionRows, error: attributionError } = await readRevenue({ sb: db(), projectId, siteId: site_id, campaignId: campaign_id, start: since });
900
901
  if (attributionError)
901
- return txt(`Error: ${attributionError.message}`);
902
+ return txt(`Error: ${dbToolError(attributionError).error}`);
902
903
  const scopedAttributionRows = attributionRows ?? [];
903
904
  const computeMetrics = (set, counts) => {
904
905
  const sharedCounts = counts ?? countEmailRows(set);
@@ -1012,7 +1013,7 @@ export function registerAnalyticsTools(server) {
1012
1013
  if (firstHalf.length > 0 && secondHalf.length > 0) {
1013
1014
  trend.open_rate = `${(parseRate(secondMetrics.open_rate) - parseRate(firstMetrics.open_rate) >= 0 ? '+' : '')}${(parseRate(secondMetrics.open_rate) - parseRate(firstMetrics.open_rate)).toFixed(1)}pp`;
1014
1015
  trend.click_rate = `${(parseRate(secondMetrics.click_rate) - parseRate(firstMetrics.click_rate) >= 0 ? '+' : '')}${(parseRate(secondMetrics.click_rate) - parseRate(firstMetrics.click_rate)).toFixed(1)}pp`;
1015
- trend.volume = `${firstHalf.length} → ${secondHalf.length} emails`;
1016
+ trend.volume = `${firstMetrics.total_sent} → ${secondMetrics.total_sent} emails`;
1016
1017
  }
1017
1018
  return j({
1018
1019
  period: `Last ${days} days`,
@@ -1077,7 +1078,7 @@ export function registerAnalyticsTools(server) {
1077
1078
  .eq('project_id', proj.id)
1078
1079
  .gte('sent_at', since);
1079
1080
  const rows = logs ?? [];
1080
- const sent = rows.length;
1081
+ const sent = countEmailRows(rows).sent;
1081
1082
  const delivered = rows.filter(isDeliveredRow).length;
1082
1083
  const opened = countPeople(rows, isOpenedRow);
1083
1084
  const clicked = countPeople(rows, isClickedRow);
@@ -1154,9 +1155,9 @@ export function registerAnalyticsTools(server) {
1154
1155
  }
1155
1156
  const [subscriptionRes, invoiceRes] = await Promise.all([subscriptionQuery, invoiceQuery]);
1156
1157
  if (subscriptionRes.error)
1157
- return j({ error: subscriptionRes.error.message });
1158
+ return j(dbToolError(subscriptionRes.error));
1158
1159
  if (invoiceRes.error)
1159
- return j({ error: invoiceRes.error.message });
1160
+ return j(dbToolError(invoiceRes.error));
1160
1161
  return j(buildRevenueAnalytics({
1161
1162
  days,
1162
1163
  start,
@@ -1185,14 +1186,14 @@ export function registerAnalyticsTools(server) {
1185
1186
  .eq('project_id', getProjectId())
1186
1187
  .maybeSingle();
1187
1188
  if (siteErr || !site)
1188
- return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
1189
+ return j({ error: (siteErr ? dbToolError(siteErr).error : undefined) ?? 'Site not found', code: 'NOT_FOUND' });
1189
1190
  const { data, error } = await db()
1190
1191
  .from('tenant_email_metrics')
1191
1192
  .select('site_id, window_30d_sent, window_30d_complaints, window_30d_opens, complaint_rate, pause_level, paused_at, pause_reason, updated_at')
1192
1193
  .eq('site_id', siteId)
1193
1194
  .maybeSingle();
1194
1195
  if (error)
1195
- return j({ error: error.message });
1196
+ return j(dbToolError(error));
1196
1197
  const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
1197
1198
  const { count: reputationSignals30d, error: reputationErr } = await db()
1198
1199
  .from('email_events')
@@ -1202,7 +1203,7 @@ export function registerAnalyticsTools(server) {
1202
1203
  .eq('bounce_category', 'reputation_signal')
1203
1204
  .gte('occurred_at', since);
1204
1205
  if (reputationErr)
1205
- return j({ error: reputationErr.message });
1206
+ return j(dbToolError(reputationErr));
1206
1207
  return j({
1207
1208
  site_id: siteId,
1208
1209
  metrics: data ?? null,
@@ -1231,7 +1232,7 @@ export function registerAnalyticsTools(server) {
1231
1232
  .eq('id', site_id)
1232
1233
  .maybeSingle();
1233
1234
  if (siteError)
1234
- return txt(`Error: ${siteError.message}`);
1235
+ return txt(`Error: ${dbToolError(siteError).error}`);
1235
1236
  if (!site)
1236
1237
  return j({ error: 'Site not found', code: 'NOT_FOUND' });
1237
1238
  let q = db()
@@ -1245,7 +1246,7 @@ export function registerAnalyticsTools(server) {
1245
1246
  q = q.is('dismissed_at', null);
1246
1247
  const { data, error } = await q;
1247
1248
  if (error)
1248
- return txt(`Error: ${error.message}`);
1249
+ return txt(`Error: ${dbToolError(error).error}`);
1249
1250
  const rank = { critical: 0, warning: 1, info: 2 };
1250
1251
  return j((data ?? []).sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9)));
1251
1252
  });
@@ -1276,7 +1277,7 @@ export function registerAnalyticsTools(server) {
1276
1277
  q = q.eq('status', p.status);
1277
1278
  const { data, error } = await q;
1278
1279
  if (error)
1279
- return txt(`Error: ${error.message}`);
1280
+ return txt(`Error: ${dbToolError(error).error}`);
1280
1281
  return j({ campaigns: data ?? [] });
1281
1282
  });
1282
1283
  }
@@ -53,7 +53,9 @@ export function registerApprovalTools(server, options) {
53
53
  },
54
54
  }, async ({ approval_id }) => {
55
55
  const result = await checkApproval(approval_id);
56
- return j(result);
56
+ return result.status === 'not_found'
57
+ ? toolError({ ...result, code: 'not_found' })
58
+ : j(result);
57
59
  });
58
60
  server.registerTool('list_pending_approvals', {
59
61
  title: 'List Pending Approvals',
@@ -8,6 +8,7 @@ import { buildVoicePrompt, buildEmailSystemPrompt } from '../lib/voice.js';
8
8
  import { getAnthropicModelId } from '../anthropic-model.js';
9
9
  // Shared with the dashboard, including trigger_config.where event-data filters (#3689).
10
10
  import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText, campaignMatchesTrigger, normalizeTriggerList as normalizeList } from '@sendinel/domain';
11
+ import { dbToolError } from '../lib/db-error.js';
11
12
  const txt = (text) => ({ content: [{ type: 'text', text }] });
12
13
  const j = (data) => txt(JSON.stringify(data, null, 2));
13
14
  async function enrollSubscriberIntoTriggeredCampaigns(input) {
@@ -22,7 +23,7 @@ async function enrollSubscriberIntoTriggeredCampaigns(input) {
22
23
  .eq('project_id', projectId)
23
24
  .maybeSingle();
24
25
  if (subscriberError)
25
- return txt(`Error: ${subscriberError.message}`);
26
+ return txt(`Error: ${dbToolError(subscriberError).error}`);
26
27
  if (!subscriber)
27
28
  return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
28
29
  if (subscriber.global_unsubscribed)
@@ -52,7 +53,7 @@ async function enrollSubscriberIntoTriggeredCampaigns(input) {
52
53
  }
53
54
  const { data: candidateCampaigns, error: campaignError } = await query;
54
55
  if (campaignError)
55
- return txt(`Error: ${campaignError.message}`);
56
+ return txt(`Error: ${dbToolError(campaignError).error}`);
56
57
  const campaigns = campaign_id
57
58
  ? (candidateCampaigns ?? [])
58
59
  : (candidateCampaigns ?? []).filter((campaign) => triggerEvents.some(event => campaignMatchesTrigger(campaign, { event, tags: triggerTags, data: context ?? null }))
@@ -74,7 +75,7 @@ async function enrollSubscriberIntoTriggeredCampaigns(input) {
74
75
  .upsert(rows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true })
75
76
  .select('id, campaign_id');
76
77
  if (enrollError)
77
- return txt(`Error: ${enrollError.message}`);
78
+ return txt(`Error: ${dbToolError(enrollError).error}`);
78
79
  if (inserted?.length) {
79
80
  for (const campaign of campaigns) {
80
81
  const count = inserted.filter((row) => row.campaign_id === campaign.id).length;
@@ -121,7 +122,7 @@ export function registerAutomationTools(server) {
121
122
  q = q.eq('active', true);
122
123
  const { data, error } = await q;
123
124
  if (error)
124
- return txt(`Error: ${error.message}`);
125
+ return txt(`Error: ${dbToolError(error).error}`);
125
126
  return j(data ?? []);
126
127
  });
127
128
  server.registerTool('enroll_automation', {
@@ -175,7 +176,7 @@ export function registerAutomationTools(server) {
175
176
  .eq('project_id', projectId)
176
177
  .maybeSingle();
177
178
  if (campaignError)
178
- return txt(`Error: ${campaignError.message}`);
179
+ return txt(`Error: ${dbToolError(campaignError).error}`);
179
180
  if (!campaign)
180
181
  return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
181
182
  if (campaign.type !== 'triggered')
@@ -196,7 +197,7 @@ export function registerAutomationTools(server) {
196
197
  .select('id, campaign_id, step_index, wait_hours, channel, action_type, content_ref, response_gate')
197
198
  .order('step_index', { ascending: true });
198
199
  if (error)
199
- return txt(`Error: ${error.message}`);
200
+ return txt(`Error: ${dbToolError(error).error}`);
200
201
  return j({
201
202
  campaign_id,
202
203
  campaign_name: campaign.name,
@@ -231,7 +232,7 @@ export function registerAutomationTools(server) {
231
232
  .limit(1)
232
233
  .maybeSingle();
233
234
  if (gateError)
234
- return txt(`Error: ${gateError.message}`);
235
+ return txt(`Error: ${dbToolError(gateError).error}`);
235
236
  if (!gate)
236
237
  return j({ enrolled: 0, skipped: 'no_escalation_ladder_steps' });
237
238
  return enrollSubscriberIntoTriggeredCampaigns({
@@ -262,7 +263,7 @@ export function registerAutomationTools(server) {
262
263
  .eq('project_id', projectId)
263
264
  .maybeSingle();
264
265
  if (campaignError)
265
- return txt(`Error: ${campaignError.message}`);
266
+ return txt(`Error: ${dbToolError(campaignError).error}`);
266
267
  if (!campaign)
267
268
  return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
268
269
  const [{ data: steps, error: stepsError }, { data: enrollments, error: enrollmentsError }] = await Promise.all([
@@ -278,9 +279,9 @@ export function registerAutomationTools(server) {
278
279
  .order('enrolled_at', { ascending: false }),
279
280
  ]);
280
281
  if (stepsError)
281
- return txt(`Error: ${stepsError.message}`);
282
+ return txt(`Error: ${dbToolError(stepsError).error}`);
282
283
  if (enrollmentsError)
283
- return txt(`Error: ${enrollmentsError.message}`);
284
+ return txt(`Error: ${dbToolError(enrollmentsError).error}`);
284
285
  const contactIds = [...new Set((enrollments ?? []).map((row) => row.contact_id))];
285
286
  let contactsById = new Map();
286
287
  if (contactIds.length) {
@@ -523,7 +524,7 @@ End with a CTA to view the full changelog on GitHub.`;
523
524
  .select('id')
524
525
  .single();
525
526
  if (draftErr)
526
- return txt(`Failed to save draft: ${draftErr.message}`);
527
+ return txt(`Failed to save draft: ${dbToolError(draftErr).error}`);
527
528
  await db()
528
529
  .from('content_sources')
529
530
  .update({ last_polled_at: new Date().toISOString(), items_generated: (source.items_generated ?? 0) + 1 })
@@ -5,6 +5,7 @@ import { getProjectId } from '../project.js';
5
5
  import { downloadPublicAsset } from '../lib/ssrf-pinned-fetch.js';
6
6
  import { callSendinelV1 } from '../lib/v1-client.js';
7
7
  import { BRAND_ASSET_KINDS, isValidKind, persistBrandAsset, resolveKind, resolveMime, } from '../lib/brand-asset-persist.js';
8
+ import { dbToolError } from '../lib/db-error.js';
8
9
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
9
10
  // import_brand_kit_text parses free-form Brand Core/Brand Brain/Content Library
10
11
  // markdown into voice fields, colors, templates, and social posts — a different,
@@ -99,7 +100,7 @@ export function registerBrandKitTools(server) {
99
100
  .eq('review_status', 'confirmed')
100
101
  .order('created_at', { ascending: false });
101
102
  if (error)
102
- return j({ error: error.message });
103
+ return j(dbToolError(error));
103
104
  // Project-wide across ALL brand kits — deliberately not scoped to a single
104
105
  // kit_id, so projects with more than one kit aren't silently truncated.
105
106
  let rows = (data ?? []);
@@ -214,7 +215,7 @@ export function registerBrandKitTools(server) {
214
215
  });
215
216
  }
216
217
  catch (error) {
217
- return j({ error: error instanceof Error ? error.message : 'Upload failed' });
218
+ return j({ error: error instanceof Error ? dbToolError(error).error : 'Upload failed' });
218
219
  }
219
220
  });
220
221
  server.registerTool('ingest_brand_assets_by_url', {
@@ -249,7 +250,7 @@ export function registerBrandKitTools(server) {
249
250
  }
250
251
  }
251
252
  catch (error) {
252
- return j({ error: error instanceof Error ? error.message : 'Project lookup failed' });
253
+ return j({ error: error instanceof Error ? dbToolError(error).error : 'Project lookup failed' });
253
254
  }
254
255
  const results = [];
255
256
  for (const asset of assets) {
@@ -12,6 +12,7 @@ import { logAudit } from '../audit.js';
12
12
  import { applyCampaignTargetingFields } from './campaigns.js';
13
13
  import { checkCampaignContent, contentNotReadyResponse, contentWarningView, summarizeContentWarnings } from '../lib/content-sources.js';
14
14
  import { captureBroadcastAudience } from '../lib/approval-snapshot.js';
15
+ import { dbToolError } from '../lib/db-error.js';
15
16
  /**
16
17
  * Campaign sets (#3669): build a whole set of triggered campaigns as drafts in one call, then
17
18
  * activate the set behind one human approval. Built for Cyber Wingman's lifecycle emails
@@ -148,7 +149,7 @@ export async function writeCampaignDrafts(projectId, siteId, drafts, source) {
148
149
  return { error: { error: targetingError, code: 'INVALID_FIELD', campaign: c.name, created_before_failure: createdIds } };
149
150
  const { data: campaign, error } = await db().from('campaigns').insert(insertPayload).select('id').single();
150
151
  if (error || !campaign)
151
- return { error: { error: error?.message ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
152
+ return { error: { error: (error ? dbToolError(error).error : undefined) ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
152
153
  createdIds.push(campaign.id);
153
154
  const rows = c.emails.map((e, k) => ({
154
155
  campaign_id: campaign.id,
@@ -163,7 +164,7 @@ export async function writeCampaignDrafts(projectId, siteId, drafts, source) {
163
164
  }));
164
165
  const { error: stepError } = await db().from('campaign_steps').insert(rows);
165
166
  if (stepError)
166
- return { error: { error: stepError.message, code: 'STEP_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
167
+ return { error: { ...dbToolError(stepError), code: 'STEP_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
167
168
  }
168
169
  return { setId, createdIds };
169
170
  }
@@ -181,7 +182,7 @@ export function registerCampaignSetTools(server) {
181
182
  // Sender: the given one, or the only one. Never guessed between several.
182
183
  const { data: sites, error: sitesErr } = await db().from('sites').select('id, name, from_email, unsub_link_enabled').eq('project_id', projectId);
183
184
  if (sitesErr)
184
- return j({ error: sitesErr.message, code: 'SITE_LOOKUP_FAILED' });
185
+ return j({ ...dbToolError(sitesErr), code: 'SITE_LOOKUP_FAILED' });
185
186
  const site = site_id ? (sites ?? []).find((s) => s.id === site_id) : (sites?.length === 1 ? sites[0] : null);
186
187
  if (!site) {
187
188
  return j({
@@ -227,7 +228,7 @@ export function registerCampaignSetTools(server) {
227
228
  email = { subject: e.subject ?? g.subject, body_html: g.body_html, body_text: g.body_text, blocks: g.blocks, delay_days: e.delay_days ?? 0, delay_hours: e.delay_hours ?? 0 };
228
229
  }
229
230
  catch (error) {
230
- return j({ error: `Nothing was created. Drafting campaigns[${i}].emails[${k}] failed: ${error instanceof Error ? error.message : String(error)}`, code: 'DRAFT_FAILED' });
231
+ return j({ error: `Nothing was created. Drafting campaigns[${i}].emails[${k}] failed: ${error instanceof Error ? dbToolError(error).error : String(error)}`, code: 'DRAFT_FAILED' });
231
232
  }
232
233
  }
233
234
  else {
@@ -263,7 +264,7 @@ export function registerCampaignSetTools(server) {
263
264
  preview = previewOf(loaded.campaigns, loaded.steps);
264
265
  }
265
266
  catch (error) {
266
- return j({ campaign_set_id: setId, campaign_ids: createdIds, status: 'draft', warning: `Created, but the preview could not be read back: ${error instanceof Error ? error.message : String(error)}` });
267
+ return j({ campaign_set_id: setId, campaign_ids: createdIds, status: 'draft', warning: `Created, but the preview could not be read back: ${error instanceof Error ? dbToolError(error).error : String(error)}` });
267
268
  }
268
269
  return j({
269
270
  campaign_set_id: setId,
@@ -292,7 +293,7 @@ export function registerCampaignSetTools(server) {
292
293
  return j({ error: `Call activate_campaigns with only approval_id. Remove: ${extras.join(', ')}.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
293
294
  const { data: row, error: rowErr } = await db().from('action_approvals').select('params').eq('id', approval_id).eq('project_id', projectId).eq('action', 'activate_campaigns').maybeSingle();
294
295
  if (rowErr)
295
- return j({ error: rowErr.message, code: 'APPROVAL_LOOKUP_FAILED' });
296
+ return j({ ...dbToolError(rowErr), code: 'APPROVAL_LOOKUP_FAILED' });
296
297
  if (!row)
297
298
  return j({ error: `Approval ${approval_id} is not_found. Request a new approval.`, code: 'not_found' });
298
299
  const stored = (row.params ?? {});
@@ -303,7 +304,7 @@ export function registerCampaignSetTools(server) {
303
304
  current = await loadSet(projectId, ids);
304
305
  }
305
306
  catch (error) {
306
- return j({ error: `Could not load the campaigns: ${error instanceof Error ? error.message : String(error)}`, code: 'SET_LOOKUP_FAILED' });
307
+ return j({ error: `Could not load the campaigns: ${error instanceof Error ? dbToolError(error).error : String(error)}`, code: 'SET_LOOKUP_FAILED' });
307
308
  }
308
309
  if (current.campaigns.length !== ids.length || campaignSetContentHash(current.campaigns, current.steps) !== stored.content_hash) {
309
310
  return j({ error: 'A campaign or email changed (or was deleted) after approval. Nothing was activated. Request a new approval.', code: 'CONTENT_CHANGED_SINCE_APPROVAL' });
@@ -325,7 +326,7 @@ export function registerCampaignSetTools(server) {
325
326
  .eq('project_id', projectId)
326
327
  .in('status', ['draft', 'paused']);
327
328
  if (error) {
328
- failed.push({ campaign_id: c.id, error: error.message });
329
+ failed.push({ campaign_id: c.id, ...dbToolError(error) });
329
330
  continue;
330
331
  }
331
332
  activated.push(c.id);
@@ -351,7 +352,7 @@ export function registerCampaignSetTools(server) {
351
352
  if (args.campaign_set_id) {
352
353
  const { data, error } = await db().from('campaigns').select('id').eq('project_id', projectId).eq('metadata->>campaign_set_id', args.campaign_set_id);
353
354
  if (error)
354
- return j({ error: error.message, code: 'SET_LOOKUP_FAILED' });
355
+ return j({ ...dbToolError(error), code: 'SET_LOOKUP_FAILED' });
355
356
  ids = [...new Set([...ids, ...(data ?? []).map((r) => r.id)])];
356
357
  }
357
358
  if (!ids.length)
@@ -361,7 +362,7 @@ export function registerCampaignSetTools(server) {
361
362
  set = await loadSet(projectId, ids);
362
363
  }
363
364
  catch (error) {
364
- return j({ error: `Could not load the campaigns: ${error instanceof Error ? error.message : String(error)}`, code: 'SET_LOOKUP_FAILED' });
365
+ return j({ error: `Could not load the campaigns: ${error instanceof Error ? dbToolError(error).error : String(error)}`, code: 'SET_LOOKUP_FAILED' });
365
366
  }
366
367
  const missing = ids.filter((id) => !set.campaigns.some((c) => c.id === id));
367
368
  if (missing.length)