@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
@@ -6,6 +6,7 @@ import { getProjectId } from '../project.js';
6
6
  import { buildVoicePrompt, deriveTemplateVariables } from '../lib/voice.js';
7
7
  import { parseModelJson, getAnthropicFastModelId } from '../anthropic-model.js';
8
8
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
9
+ import { dbToolError } from '../lib/db-error.js';
9
10
  // Haiku 5.5 (fast role) thinks before answering; thinking counts toward max_tokens (#4193).
10
11
  const FAST_MIN_MAX_TOKENS = 8000;
11
12
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
@@ -128,13 +129,13 @@ Respond with a JSON array.`,
128
129
  .select('id')
129
130
  .single();
130
131
  if (templateErr)
131
- return j({ error: `Template creation failed: ${templateErr.message}` });
132
+ return j({ error: `Template creation failed: ${dbToolError(templateErr).error}` });
132
133
  const { data: campaign, error: campaignErr } = await sb.from('campaigns')
133
134
  .insert({ name: campaign_name, site_id, project_id: projectId, type: campaign_type, status: 'draft' })
134
135
  .select('id, name, status')
135
136
  .single();
136
137
  if (campaignErr)
137
- return j({ error: `Campaign creation failed: ${campaignErr.message}` });
138
+ return j({ error: `Campaign creation failed: ${dbToolError(campaignErr).error}` });
138
139
  const createdSteps = [];
139
140
  for (let i = 0; i < steps.length; i++) {
140
141
  const step = steps[i];
@@ -153,7 +154,7 @@ Respond with a JSON array.`,
153
154
  .select('id, step_order, subject')
154
155
  .single();
155
156
  if (error)
156
- return j({ error: `Step ${i + 1} creation failed: ${error.message}`, campaign_id: campaign.id });
157
+ return j({ error: `Step ${i + 1} creation failed: ${dbToolError(error).error}`, campaign_id: campaign.id });
157
158
  createdSteps.push(data);
158
159
  }
159
160
  return j({
@@ -236,7 +237,7 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
236
237
  .select('id, name, subject_hint, created_at')
237
238
  .single();
238
239
  if (error)
239
- return j({ error: error.message });
240
+ return j(dbToolError(error));
240
241
  return j({ ok: true, template, preview: generated });
241
242
  });
242
243
  server.registerTool('diagnose_delivery_issue', {
@@ -354,7 +355,7 @@ Identify the most likely root cause and give 3-5 concise actionable next steps.`
354
355
  .select('id, name, provider, status, dns_records')
355
356
  .single();
356
357
  if (domainErr)
357
- return j({ error: `Domain registration failed: ${domainErr.message}` });
358
+ return j({ error: `Domain registration failed: ${dbToolError(domainErr).error}` });
358
359
  const { data: site, error: siteErr } = await sb.from('sites')
359
360
  .insert({
360
361
  project_id: projectId,
@@ -372,7 +373,7 @@ Identify the most likely root cause and give 3-5 concise actionable next steps.`
372
373
  .select('id, name, slug, sending_domain, from_name, from_email, provider, created_at')
373
374
  .single();
374
375
  if (siteErr)
375
- return j({ error: `Site creation failed: ${siteErr.message}` });
376
+ return j({ error: `Site creation failed: ${dbToolError(siteErr).error}` });
376
377
  const warmupPlan = [];
377
378
  let volume = Math.max(10, Math.floor(daily_send_volume * 0.05));
378
379
  for (let day = 1; day <= 14; day += 3) {
@@ -4,6 +4,7 @@ import { getProjectId } from '../project.js';
4
4
  import { listConnectorTablesFromDb } from '../lib/connector-table-discovery.js';
5
5
  import { callSendinelV1 } from '../lib/v1-client.js';
6
6
  import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, } from '../lib/action-approvals.js';
7
+ import { dbToolError } from '../lib/db-error.js';
7
8
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
8
9
  const SECRET_RESPONSE_KEYS = new Set([
9
10
  'connector_supabase_key_enc',
@@ -144,7 +145,7 @@ export function registerConnectorTools(server) {
144
145
  data = await getConnectorStatus();
145
146
  }
146
147
  catch (error) {
147
- return j({ error: error instanceof Error ? error.message : 'Connector status query failed' });
148
+ return j({ error: error instanceof Error ? dbToolError(error).error : 'Connector status query failed' });
148
149
  }
149
150
  if (!data || typeof data !== 'object' || typeof data.viewInstalled !== 'boolean') {
150
151
  return j({ error: 'Connector status did not include the required viewInstalled boolean.' });
@@ -166,7 +167,7 @@ export function registerConnectorTools(server) {
166
167
  const { data: project, error } = await adminDb().schema('email').from('projects')
167
168
  .select('connector_type').eq('id', projectId).maybeSingle();
168
169
  if (error)
169
- return j({ error: error.message });
170
+ return j(dbToolError(error));
170
171
  if (project?.connector_type !== 'byod') {
171
172
  return j({ error: 'Connect your Supabase database in Settings before importing from it.' });
172
173
  }
@@ -174,7 +175,7 @@ export function registerConnectorTools(server) {
174
175
  return j({ tables: await listConnectorTablesFromDb(db()) });
175
176
  }
176
177
  catch (tableError) {
177
- return j({ error: tableError instanceof Error ? tableError.message : 'Could not list database tables' });
178
+ return j({ error: tableError instanceof Error ? dbToolError(tableError).error : 'Could not list database tables' });
178
179
  }
179
180
  });
180
181
  server.registerTool('install_connector_schema', {
@@ -1,5 +1,4 @@
1
1
  import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
2
- import { dbErrorResult } from '../lib/db-errors.js';
3
2
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
4
3
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
5
4
  import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
@@ -8,7 +7,7 @@ import { z } from 'zod';
8
7
  import { callSendinelV1 } from '../lib/v1-client.js';
9
8
  import { db, projectClientForWriteback } from '../db.js';
10
9
  import { getProjectId } from '../project.js';
11
- import { dbToolError, toolError } from '../lib/db-error.js';
10
+ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
12
11
  import { resolveSegmentContactIds } from '../lib/send-audience.js';
13
12
  import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
14
13
  import { logConsent, logAudit } from '../audit.js';
@@ -336,7 +335,7 @@ async function resolveSubscriberViewSiteId(projectId, siteParam) {
336
335
  .eq('active', true)
337
336
  .order('name');
338
337
  if (error)
339
- return { error: error.message };
338
+ return { ...dbToolError(error) };
340
339
  const all = (sites ?? []);
341
340
  if (SUBSCRIBER_VIEW_UUID_RE.test(siteParam)) {
342
341
  return { siteId: all.some((site) => site.id === siteParam) ? siteParam : undefined };
@@ -426,7 +425,7 @@ function isMissingRelationshipError(error) {
426
425
  return false;
427
426
  if (error.code === 'PGRST200')
428
427
  return true;
429
- return /could not find a relationship/i.test(error.message ?? '');
428
+ return /could not find a relationship/i.test((error ? dbToolError(error).error : undefined) ?? '');
430
429
  }
431
430
  async function hasCompaniesEmbed(projectId) {
432
431
  const cached = companiesEmbedCache.get(projectId);
@@ -575,7 +574,7 @@ async function exportSubscriberData(projectId, contactId) {
575
574
  .eq('project_id', projectId)
576
575
  .maybeSingle();
577
576
  if (contactError)
578
- return { error: contactError.message };
577
+ return { ...dbToolError(contactError) };
579
578
  if (!contact)
580
579
  return { error: 'Subscriber not found', code: 'NOT_FOUND' };
581
580
  const { data: emailHistory, error: logError } = await db()
@@ -586,7 +585,7 @@ async function exportSubscriberData(projectId, contactId) {
586
585
  .not('to_email', 'like', 'sha256:%')
587
586
  .order('sent_at', { ascending: false });
588
587
  if (logError)
589
- return { error: logError.message };
588
+ return { ...dbToolError(logError) };
590
589
  const { data: suppression, error: suppressionError } = await db()
591
590
  .from('suppressions')
592
591
  .select('reason, created_at')
@@ -595,7 +594,7 @@ async function exportSubscriberData(projectId, contactId) {
595
594
  .not('email', 'like', 'sha256:%')
596
595
  .maybeSingle();
597
596
  if (suppressionError)
598
- return { error: suppressionError.message };
597
+ return { ...dbToolError(suppressionError) };
599
598
  return {
600
599
  contact: {
601
600
  id: contact.id,
@@ -714,7 +713,7 @@ export function registerContactTools(server) {
714
713
  }, async () => {
715
714
  const { data, error } = await db().from('hygiene_config').select('*').eq('project_id', getProjectId()).maybeSingle();
716
715
  if (error)
717
- return j({ error: error.message });
716
+ return j(dbToolError(error));
718
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 });
719
718
  });
720
719
  server.registerTool('update_hygiene_config', {
@@ -747,7 +746,7 @@ export function registerContactTools(server) {
747
746
  row.activity_property = activityProperty;
748
747
  const { error } = await db().from('hygiene_config').upsert(row, { onConflict: 'project_id' });
749
748
  if (error)
750
- return j({ error: error.message });
749
+ return j(dbToolError(error));
751
750
  return j({ updated: true });
752
751
  });
753
752
  server.registerTool('run_list_hygiene', {
@@ -767,7 +766,7 @@ export function registerContactTools(server) {
767
766
  // read past 1000 is now safe as a follow-up.
768
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);
769
768
  if (readError)
770
- return j({ error: readError.message });
769
+ return j(dbToolError(readError));
771
770
  let tagged = 0;
772
771
  let skipped = 0;
773
772
  let failed = 0;
@@ -908,7 +907,7 @@ export function registerContactTools(server) {
908
907
  q = q.contains('tags', [`segment:${segmentId}`]);
909
908
  const { data: sample, error } = await q;
910
909
  if (error)
911
- return txt(`Error: ${error.message}`);
910
+ return txt(`Error: ${dbToolError(error).error}`);
912
911
  const apiKey = process.env.ANTHROPIC_API_KEY;
913
912
  if (!apiKey)
914
913
  return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_UNAVAILABLE' });
@@ -984,7 +983,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
984
983
  .eq('project_id', projectId)
985
984
  .maybeSingle();
986
985
  if (viewError)
987
- return txt(`Error: ${viewError.message}`);
986
+ return txt(`Error: ${dbToolError(viewError).error}`);
988
987
  if (!view)
989
988
  return txt('Error: View not found');
990
989
  viewFilters = normalizeSubscriberViewFilters(view.filters);
@@ -1050,7 +1049,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1050
1049
  .select('contact_id, subscribed')
1051
1050
  .eq('site_id', statusSiteId);
1052
1051
  if (siteSubscriptionsError)
1053
- return txt(`Error: ${siteSubscriptionsError.message}`);
1052
+ return txt(`Error: ${dbToolError(siteSubscriptionsError).error}`);
1054
1053
  if (viewStatus === 'not_subscribed') {
1055
1054
  const subscribedIds = (siteSubscriptions ?? [])
1056
1055
  .map((subscription) => subscription.contact_id)
@@ -1082,7 +1081,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1082
1081
  }
1083
1082
  const { data, error } = await q;
1084
1083
  if (error)
1085
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1084
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1086
1085
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1087
1086
  });
1088
1087
  server.registerTool('list_subscriber_views', {
@@ -1102,7 +1101,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1102
1101
  .eq('project_id', getProjectId())
1103
1102
  .order('created_at', { ascending: true });
1104
1103
  if (error)
1105
- return txt(`Error: ${error.message}`);
1104
+ return txt(`Error: ${dbToolError(error).error}`);
1106
1105
  return j({ views: data ?? [] });
1107
1106
  });
1108
1107
  server.registerTool('create_subscriber_view', {
@@ -1220,7 +1219,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1220
1219
  .select('id')
1221
1220
  .maybeSingle();
1222
1221
  if (error)
1223
- return txt(`Error: ${error.message}`);
1222
+ return txt(`Error: ${dbToolError(error).error}`);
1224
1223
  if (!data)
1225
1224
  return txt('View not found');
1226
1225
  return j({ deleted: true, view_id });
@@ -1251,7 +1250,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1251
1250
  }
1252
1251
  const { data: companies, error } = await query;
1253
1252
  if (error)
1254
- return txt(`Error: ${error.message}`);
1253
+ return txt(`Error: ${dbToolError(error).error}`);
1255
1254
  const rows = companies ?? [];
1256
1255
  if (!rows.length)
1257
1256
  return j([]);
@@ -1266,7 +1265,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1266
1265
  .order('id')
1267
1266
  .range(offset, offset + 999);
1268
1267
  if (subscriberError)
1269
- return txt(`Error: ${subscriberError.message}`);
1268
+ return txt(`Error: ${dbToolError(subscriberError).error}`);
1270
1269
  for (const subscriber of subscribers ?? []) {
1271
1270
  const companyId = typeof subscriber.company_id === 'string' ? subscriber.company_id : null;
1272
1271
  if (!companyId)
@@ -1531,7 +1530,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1531
1530
  .upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: false })
1532
1531
  .select('id, email, first_name, last_name, custom_properties');
1533
1532
  if (uErr)
1534
- return { content: [{ type: 'text', text: `Error: ${uErr.message}` }] };
1533
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(uErr).error}` }] };
1535
1534
  const contactRows = upserted ?? [];
1536
1535
  const subRows = contactRows.map((c) => ({ contact_id: c.id, site_id, subscribed: true }));
1537
1536
  if (subRows.length) {
@@ -1564,7 +1563,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1564
1563
  .select('id, email, first_name, last_name, custom_properties')
1565
1564
  .single();
1566
1565
  if (error)
1567
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1566
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1568
1567
  if (data) {
1569
1568
  // Cascade to site subscriptions
1570
1569
  await db()
@@ -1637,7 +1636,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1637
1636
  q = q.ilike('subject', `%${subject_contains.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
1638
1637
  const { data, error } = await q;
1639
1638
  if (error)
1640
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1639
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1641
1640
  const fetched = (data ?? []);
1642
1641
  const hasMore = fetched.length > pageSize;
1643
1642
  // source: where the email came from. A campaign id wins; otherwise the stored category (transactional, test, marketing).
@@ -1672,7 +1671,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1672
1671
  q = q.eq('reason', reason);
1673
1672
  const { data, error } = await q;
1674
1673
  if (error)
1675
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1674
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1676
1675
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1677
1676
  });
1678
1677
  server.registerTool('add_suppression', {
@@ -1696,7 +1695,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1696
1695
  .select()
1697
1696
  .single();
1698
1697
  if (error)
1699
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1698
+ return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1700
1699
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1701
1700
  });
1702
1701
  server.registerTool('list_subscribers_by_segment', {
@@ -1735,7 +1734,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1735
1734
  segmentIds = await resolveSegmentContactIds(projectId, segment.rules);
1736
1735
  }
1737
1736
  catch (error) {
1738
- 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' });
1739
1738
  }
1740
1739
  if (!segmentIds.length)
1741
1740
  return j([]);
@@ -2045,7 +2044,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2045
2044
  return txt('Provide either contact_id or email.');
2046
2045
  const { data, error } = await q.maybeSingle();
2047
2046
  if (error)
2048
- return txt(`Error: ${error.message}`);
2047
+ return txt(`Error: ${dbToolError(error).error}`);
2049
2048
  return j(data);
2050
2049
  });
2051
2050
  server.registerTool('enrich_subscriber', {
@@ -2071,7 +2070,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2071
2070
  .eq('id', contact_id)
2072
2071
  .maybeSingle();
2073
2072
  if (error)
2074
- return txt(`Error: ${error.message}`);
2073
+ return txt(`Error: ${dbToolError(error).error}`);
2075
2074
  if (!contact?.email)
2076
2075
  return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2077
2076
  const enriched = await enrichContactFromProviders(projectId, contact.email, provider);
@@ -2109,7 +2108,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2109
2108
  .select(embedOk ? '*, company:companies(id, name, domain)' : '*')
2110
2109
  .single();
2111
2110
  if (updateError)
2112
- return txt(`Error: ${updateError.message}`);
2111
+ return txt(`Error: ${dbToolError(updateError).error}`);
2113
2112
  const appliedFields = [];
2114
2113
  if (!contact.first_name && enriched.firstName)
2115
2114
  appliedFields.push('first_name');
@@ -2355,7 +2354,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2355
2354
  }
2356
2355
  catch (error) {
2357
2356
  if (isSegmentRulesError(error))
2358
- return txt(`Error: ${error.message}`);
2357
+ return txt(`Error: ${dbToolError(error).error}`);
2359
2358
  throw error;
2360
2359
  }
2361
2360
  }
@@ -2380,7 +2379,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2380
2379
  }
2381
2380
  const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
2382
2381
  if (error)
2383
- return txt(`Error: ${error.message}`);
2382
+ return txt(`Error: ${dbToolError(error).error}`);
2384
2383
  // Get full count separately
2385
2384
  let countQ = db()
2386
2385
  .from('subscribers')
@@ -2466,7 +2465,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2466
2465
  .order('email')
2467
2466
  .range(offset, offset + 999);
2468
2467
  if (suppressionError)
2469
- return txt(`Error: ${suppressionError.message}`);
2468
+ return txt(`Error: ${dbToolError(suppressionError).error}`);
2470
2469
  for (const suppression of suppressed ?? [])
2471
2470
  suppressedSet.add(suppression.email);
2472
2471
  if ((suppressed ?? []).length < 1000)
@@ -2474,7 +2473,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2474
2473
  }
2475
2474
  const { data: contacts, error } = await q.order('engagement_score', { ascending: true }).limit(sampleLimit);
2476
2475
  if (error)
2477
- return txt(`Error: ${error.message}`);
2476
+ return txt(`Error: ${dbToolError(error).error}`);
2478
2477
  const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
2479
2478
  if (!dry_run && inactive.length > 0) {
2480
2479
  const channelContext = currentChannelContext();
@@ -2506,7 +2505,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2506
2505
  .from('suppressions')
2507
2506
  .upsert(suppressionRows, { onConflict: 'project_id,email', ignoreDuplicates: true });
2508
2507
  if (supErr)
2509
- return txt(`Error suppressing: ${supErr.message}`);
2508
+ return txt(`Error suppressing: ${dbToolError(supErr).error}`);
2510
2509
  return j({ suppressed: inactive.length, days_threshold: days, sample: inactive });
2511
2510
  }
2512
2511
  return j({
@@ -2542,7 +2541,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2542
2541
  .select()
2543
2542
  .single();
2544
2543
  if (error)
2545
- return txt(`Error: ${error.message}`);
2544
+ return txt(`Error: ${dbToolError(error).error}`);
2546
2545
  if (!data)
2547
2546
  return txt(`No suppression found for ${email}.`);
2548
2547
  return j({ removed: true, email, previous_reason: data.reason });
@@ -2625,7 +2624,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2625
2624
  for (const ids of chunkIds(siteIds)) {
2626
2625
  const part = await scanChunk(ids);
2627
2626
  if (!part.ok)
2628
- return txt(`Error: ${part.error.message}`);
2627
+ return txt(`Error: ${dbToolError(part.error).error}`);
2629
2628
  all.push(...part.rows);
2630
2629
  total = total === null || part.total === null ? null : total + part.total;
2631
2630
  }
@@ -2637,7 +2636,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2637
2636
  scan = await scanChunk(siteIds);
2638
2637
  }
2639
2638
  if (!scan.ok)
2640
- return txt(`Error: ${scan.error.message}`);
2639
+ return txt(`Error: ${dbToolError(scan.error).error}`);
2641
2640
  const contacts = scan.rows;
2642
2641
  // #3604: say when the scan stopped at the limit instead of presenting a partial scan as complete.
2643
2642
  const scanned = contacts?.length ?? 0;
@@ -2713,7 +2712,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2713
2712
  p_source_ids: source_ids,
2714
2713
  });
2715
2714
  if (error)
2716
- return txt(`Error: ${error.message}`);
2715
+ return txt(`Error: ${dbToolError(error).error}`);
2717
2716
  logAudit({ action: 'contacts.merged', resourceType: 'contact', resourceId: target_id, details: { source_ids, result: data } });
2718
2717
  return j(data);
2719
2718
  });
@@ -2737,7 +2736,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2737
2736
  ...(siteScope ? { p_site_id: siteScope } : {}),
2738
2737
  });
2739
2738
  if (error)
2740
- return { error: `Error: ${error.message}` };
2739
+ return { error: `Error: ${dbToolError(error).error}` };
2741
2740
  const result = (Array.isArray(data) ? data[0] : data);
2742
2741
  if (typeof result?.has_membership !== 'boolean' || !Array.isArray(result.keys)) {
2743
2742
  return { error: 'Error: get_contact_schema_keys returned an unreadable result' };
@@ -2842,7 +2841,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2842
2841
  if (error) {
2843
2842
  if (error.code === '23505')
2844
2843
  return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
2845
- return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
2844
+ return j({ error: `Error: ${dbToolError(error).error}`, code: 'INTERNAL_ERROR' });
2846
2845
  }
2847
2846
  await logAudit({
2848
2847
  action: 'custom_field.created',
@@ -2873,7 +2872,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2873
2872
  .eq('project_id', projectId)
2874
2873
  .maybeSingle();
2875
2874
  if (currentError)
2876
- return txt(`Error: ${currentError.message}`);
2875
+ return txt(`Error: ${dbToolError(currentError).error}`);
2877
2876
  if (!current)
2878
2877
  return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2879
2878
  const nextType = (type ?? current.type);
@@ -2899,7 +2898,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2899
2898
  .select('id, key, label, type, required, options, display_order')
2900
2899
  .maybeSingle();
2901
2900
  if (error)
2902
- return txt(`Error: ${error.message}`);
2901
+ return txt(`Error: ${dbToolError(error).error}`);
2903
2902
  if (!data)
2904
2903
  return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2905
2904
  await logAudit({
@@ -2925,7 +2924,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2925
2924
  .select('id, key')
2926
2925
  .maybeSingle();
2927
2926
  if (error)
2928
- return txt(`Error: ${error.message}`);
2927
+ return txt(`Error: ${dbToolError(error).error}`);
2929
2928
  if (!data)
2930
2929
  return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2931
2930
  await logAudit({
@@ -2952,7 +2951,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2952
2951
  }, async ({ contact_id, window_hours }) => {
2953
2952
  const { data, error } = await getContactNeighborhood(db(), getProjectId(), contact_id, window_hours);
2954
2953
  if (error)
2955
- return txt(`Error: ${error.message}`);
2954
+ return txt(`Error: ${dbToolError(error).error}`);
2956
2955
  if (data == null)
2957
2956
  return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2958
2957
  return j({ ...data, attention: buildPreviewAttentionEnvelope() });
@@ -2973,7 +2972,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2973
2972
  }, async ({ contact_id, window_hours }) => {
2974
2973
  const { data, error } = await getScheduledTouches(db(), getProjectId(), contact_id, window_hours);
2975
2974
  if (error)
2976
- return txt(`Error: ${error.message}`);
2975
+ return txt(`Error: ${dbToolError(error).error}`);
2977
2976
  if (data == null)
2978
2977
  return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2979
2978
  return j({ ...data, attention: buildPreviewAttentionEnvelope() });
@@ -3000,7 +2999,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
3000
2999
  enrollmentId: enrollment_id,
3001
3000
  });
3002
3001
  if (error)
3003
- return txt(`Error: ${error.message}`);
3002
+ return txt(`Error: ${dbToolError(error).error}`);
3004
3003
  if (data == null)
3005
3004
  return j({ error: 'Not found', code: 'NOT_FOUND' });
3006
3005
  return j(data);
@@ -3025,7 +3024,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
3025
3024
  }
3026
3025
  const { data, error } = await getContactNeighborhood(db(), getProjectId(), contactId);
3027
3026
  const payload = error
3028
- ? { error: error.message }
3027
+ ? { ...dbToolError(error) }
3029
3028
  : data == null
3030
3029
  ? { error: 'Subscriber not found', code: 'NOT_FOUND' }
3031
3030
  : data;
@@ -133,7 +133,7 @@ export function registerContentTools(server) {
133
133
  if (error?.code === '23505')
134
134
  return j({ error: 'slug already exists', code: 'conflict' });
135
135
  if (error)
136
- return j({ error: error.message, code: error.code });
136
+ return j({ ...dbToolError(error), code: error.code });
137
137
  const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai';
138
138
  return j({ asset_link: { ...data, url: `${appUrl.replace(/\/$/, '')}/a/${data.slug}` } });
139
139
  });
@@ -160,7 +160,7 @@ export function registerContentTools(server) {
160
160
  query = query.eq('status', status);
161
161
  const { data, error } = await query;
162
162
  if (error)
163
- return j({ error: error.message });
163
+ return j(dbToolError(error));
164
164
  const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
165
165
  return j({ asset_links: (data ?? []).map((row) => ({ ...row, url: `${appUrl}/a/${row.slug}` })) });
166
166
  });
@@ -192,7 +192,7 @@ export function registerContentTools(server) {
192
192
  .order('viewed_at', { ascending: false })
193
193
  .limit(limit);
194
194
  if (error)
195
- return j({ error: error.message });
195
+ return j(dbToolError(error));
196
196
  return j({ views: data ?? [] });
197
197
  });
198
198
  server.registerTool('list_content_blocks', {
@@ -211,7 +211,7 @@ export function registerContentTools(server) {
211
211
  query = query.eq('category', category);
212
212
  const { data, error } = await query;
213
213
  if (error)
214
- return j({ error: error.message });
214
+ return j(dbToolError(error));
215
215
  return j({ blocks: data ?? [] });
216
216
  });
217
217
  server.registerTool('create_content_block', {
@@ -258,7 +258,7 @@ export function registerContentTools(server) {
258
258
  }, withApproval('delete_content_block', async ({ block_id }) => {
259
259
  const { error } = await db().from('content_blocks').delete().eq('project_id', getProjectId()).eq('id', block_id);
260
260
  if (error)
261
- return j({ error: error.message });
261
+ return j(dbToolError(error));
262
262
  return j({ deleted: true });
263
263
  }));
264
264
  server.registerTool('list_content_sources', {
@@ -277,7 +277,7 @@ export function registerContentTools(server) {
277
277
  .eq('project_id', getProjectId())
278
278
  .order('created_at', { ascending: false });
279
279
  if (error)
280
- return j({ error: error.message });
280
+ return j(dbToolError(error));
281
281
  return j({ sources: (data ?? []).map((row) => ({ id: row.id, name: row.name, type: row.source_type, url: row.feed_url, active: row.active, last_fetched: row.last_polled_at, draft_count: row.items_generated ?? 0 })) });
282
282
  });
283
283
  server.registerTool('trigger_content_pipeline', {
@@ -296,7 +296,7 @@ export function registerContentTools(server) {
296
296
  query = query.eq('id', source_id);
297
297
  const { error } = await query;
298
298
  if (error)
299
- return j({ error: error.message });
299
+ return j(dbToolError(error));
300
300
  return j({ drafts_created: 0, queued_for_pipeline: true });
301
301
  });
302
302
  // Blogs (#2033): the primary agent door for ingesting a blog written by the
@@ -350,7 +350,7 @@ export function registerContentTools(server) {
350
350
  .eq('project_id', getProjectId())
351
351
  .eq('is_active', true);
352
352
  if (destErr)
353
- return j({ error: destErr.message });
353
+ return j(dbToolError(destErr));
354
354
  const target = publish_to.trim().toLowerCase();
355
355
  const match = (dests ?? []).find((d) => d.id === publish_to || String(d.name ?? '').toLowerCase() === target);
356
356
  if (!match) {
@@ -382,7 +382,7 @@ export function registerContentTools(server) {
382
382
  .select('id, title, slug, status, source, scheduled_at, created_at')
383
383
  .single();
384
384
  if (error)
385
- return j({ error: error.message, code: error.code });
385
+ return j({ ...dbToolError(error), code: error.code });
386
386
  return j({ blog_post: data, ...(auto_publish ? { note: scheduled_at ? 'Scheduled — the publish worker will send it at scheduled_at.' : 'Queued for immediate publish — the worker publishes it within a few minutes.' } : {}) });
387
387
  }));
388
388
  server.registerTool('list_blog_posts', {
@@ -410,7 +410,7 @@ export function registerContentTools(server) {
410
410
  query = query.neq('status', 'archived');
411
411
  const { data, error } = await query;
412
412
  if (error)
413
- return j({ error: error.message });
413
+ return j(dbToolError(error));
414
414
  return j({ blog_posts: data ?? [] });
415
415
  });
416
416
  server.registerTool('get_blog_post', {
@@ -432,7 +432,7 @@ export function registerContentTools(server) {
432
432
  .eq('id', blog_post_id)
433
433
  .maybeSingle();
434
434
  if (error)
435
- return j({ error: error.message });
435
+ return j(dbToolError(error));
436
436
  if (!data)
437
437
  return j({ error: 'Blog post not found', code: 'NOT_FOUND' });
438
438
  return j({ blog_post: data });
@@ -460,7 +460,7 @@ export function registerContentTools(server) {
460
460
  .eq('id', blog_post_id)
461
461
  .maybeSingle();
462
462
  if (error)
463
- return j({ error: error.message });
463
+ return j(dbToolError(error));
464
464
  if (!post)
465
465
  return j({ error: 'Blog post not found', code: 'NOT_FOUND' });
466
466
  const tags = Array.isArray(post.tags) ? post.tags.map((tag) => String(tag).toLowerCase()) : [];
@@ -499,7 +499,7 @@ export function registerContentTools(server) {
499
499
  .eq('id', blog_post_id)
500
500
  .maybeSingle();
501
501
  if (error)
502
- return j({ error: error.message });
502
+ return j(dbToolError(error));
503
503
  if (!post)
504
504
  return j({ error: 'Blog post not found', code: 'NOT_FOUND' });
505
505
  const scene = BLOG_IMAGE_SCENES.find((s) => s.id === scene_id);
@@ -542,7 +542,7 @@ export function registerContentTools(server) {
542
542
  .select('id, title, cover_image_url, updated_at')
543
543
  .maybeSingle();
544
544
  if (updateErr)
545
- return j({ error: updateErr.message, code: updateErr.code });
545
+ return j({ ...dbToolError(updateErr), code: updateErr.code });
546
546
  await adminDb().from('audit_log').insert({
547
547
  project_id: projectId,
548
548
  org_id: post.org_id ?? null,
@@ -556,7 +556,7 @@ export function registerContentTools(server) {
556
556
  return j({ scene_id, format: chosenFormat, asset: generated.asset, prompt: generated.prompt, blog_post: updated });
557
557
  }
558
558
  catch (err) {
559
- return j({ error: err instanceof Error ? err.message : 'Image generation failed', code: 'IMAGE_GENERATION_FAILED' });
559
+ return j({ error: err instanceof Error ? dbToolError(err).error : 'Image generation failed', code: 'IMAGE_GENERATION_FAILED' });
560
560
  }
561
561
  });
562
562
  server.registerTool('list_blog_destinations', {
@@ -576,7 +576,7 @@ export function registerContentTools(server) {
576
576
  .eq('is_active', true)
577
577
  .order('created_at', { ascending: false });
578
578
  if (error)
579
- return j({ error: error.message });
579
+ return j(dbToolError(error));
580
580
  return j({ destinations: data ?? [] });
581
581
  });
582
582
  }