@sendinel/mcp-server 1.0.139 → 1.0.141

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.
@@ -0,0 +1,16 @@
1
+ /** Public tool errors must not expose SQL, constraint names or database internals. */
2
+ export declare function dbToolError(error: {
3
+ code?: string;
4
+ message?: string;
5
+ }, message?: string): {
6
+ error: string;
7
+ code: string;
8
+ };
9
+ /** Mark coded failures as MCP errors so the hosted api_usage classifier sees them. */
10
+ export declare function toolError(body: unknown): {
11
+ isError: true;
12
+ content: {
13
+ type: 'text';
14
+ text: string;
15
+ }[];
16
+ };
@@ -0,0 +1,17 @@
1
+ /** Public tool errors must not expose SQL, constraint names or database internals. */
2
+ export function dbToolError(error, message = 'Unable to complete database operation') {
3
+ if (error.code === '23505')
4
+ return { error: 'A record with these values already exists', code: 'conflict' };
5
+ if (error.code === '23503')
6
+ return { error: 'Referenced record not found', code: 'not_found' };
7
+ if (error.code === '42501')
8
+ return { error: 'Operation forbidden', code: 'forbidden' };
9
+ if (error.code?.startsWith('22') || error.code === '23514' || error.code === '23502') {
10
+ return { error: 'Invalid record values', code: 'validation_failed' };
11
+ }
12
+ return { error: message, code: 'internal_error' };
13
+ }
14
+ /** Mark coded failures as MCP errors so the hosted api_usage classifier sees them. */
15
+ export function toolError(body) {
16
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
17
+ }
@@ -0,0 +1,12 @@
1
+ /** Caller-safe database errors. Never serialize database messages, details or hints. */
2
+ export declare function mapDbError(error: unknown): {
3
+ error: string;
4
+ code: string;
5
+ };
6
+ export declare function dbErrorResult(error: unknown): {
7
+ isError: boolean;
8
+ content: {
9
+ type: 'text';
10
+ text: string;
11
+ }[];
12
+ };
@@ -0,0 +1,20 @@
1
+ /** Caller-safe database errors. Never serialize database messages, details or hints. */
2
+ export function mapDbError(error) {
3
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
4
+ if (code === '23505')
5
+ return { error: 'A record with these values already exists.', code: 'conflict' };
6
+ if (code === '23503' || code === 'PGRST116') {
7
+ return { error: 'The requested or referenced record was not found.', code: 'not_found' };
8
+ }
9
+ if (code.startsWith('23') || code.startsWith('22')) {
10
+ return { error: 'The supplied values are not valid. Check the input and try again.', code: 'validation_failed' };
11
+ }
12
+ return { error: 'The operation could not be completed. Please try again later.', code: 'internal_error' };
13
+ }
14
+ export function dbErrorResult(error) {
15
+ console.warn('[mcp] Database operation failed:', error);
16
+ return {
17
+ isError: true,
18
+ content: [{ type: 'text', text: JSON.stringify(mapDbError(error)) }],
19
+ };
20
+ }
@@ -1,8 +1,9 @@
1
- import { countEmailRows, groupMetricRows, percentRate, isDeliveredRow, isOpenedRow, countPeople, isComplainedRow, isBouncedRow, AI_MAX_TOKENS, outputConfig, anthropicCheckedText, readAllRows } from "@sendinel/domain";
1
+ import { countEmailRows, groupMetricRows, percentRate, isBouncedRow, AI_MAX_TOKENS, outputConfig, anthropicCheckedText, readAllRows } from "@sendinel/domain";
2
2
  import { z } from 'zod';
3
3
  import Anthropic from '@anthropic-ai/sdk';
4
4
  import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
+ import { toolError } from '../lib/db-error.js';
6
7
  import { buildVoicePrompt } from '../lib/voice.js';
7
8
  import { getAnthropicModelId } from '../anthropic-model.js';
8
9
  import { dkimRemediationText } from '../lib/email-provider.js';
@@ -103,19 +104,19 @@ export function registerAdvisorTools(server) {
103
104
  // 1. Current period email_log
104
105
  let currentQuery = db()
105
106
  .from('email_log')
106
- .select("status, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
107
+ .select("status, sent_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
107
108
  .eq('project_id', projectId)
108
- .gte('created_at', periodStart.toISOString());
109
+ .gte('sent_at', periodStart.toISOString());
109
110
  if (site_id)
110
111
  currentQuery = currentQuery.eq('site_id', site_id);
111
112
  const currentRows = await readAllRows(currentQuery);
112
113
  // 2. Prior period
113
114
  let priorQuery = db()
114
115
  .from('email_log')
115
- .select("status, created_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
116
+ .select("status, sent_at, bounced_at, opened_at, clicked_at, contact_id, to_email")
116
117
  .eq('project_id', projectId)
117
- .gte('created_at', priorStart.toISOString())
118
- .lt('created_at', periodStart.toISOString());
118
+ .gte('sent_at', priorStart.toISOString())
119
+ .lt('sent_at', periodStart.toISOString());
119
120
  if (site_id)
120
121
  priorQuery = priorQuery.eq('site_id', site_id);
121
122
  const priorRows = await readAllRows(priorQuery);
@@ -124,8 +125,8 @@ export function registerAdvisorTools(server) {
124
125
  // 3. Domain health (latest per site)
125
126
  let healthQuery = db()
126
127
  .from('domain_health')
127
- .select('site_id, bounce_rate, complaint_rate, spf_valid, dkim_valid, dmarc_valid, warmup_day')
128
- .eq('project_id', projectId)
128
+ .select('site_id, bounce_rate, complaint_rate, spf_valid, dkim_valid, dmarc_valid, warmup_day, sites!inner(project_id)')
129
+ .eq('sites.project_id', projectId)
129
130
  .order('check_date', { ascending: false })
130
131
  .limit(20);
131
132
  if (site_id)
@@ -595,15 +596,19 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
595
596
  .eq('id', site_id)
596
597
  .eq('project_id', projectId)
597
598
  .maybeSingle();
598
- if (siteErr || !site)
599
- return txt(`Error: Site not found — ${siteErr?.message ?? 'not found'}`);
599
+ if (siteErr)
600
+ return toolError({ error: 'Unable to load site', code: 'internal_error' });
601
+ if (!site)
602
+ return toolError({ error: 'Site not found', code: 'not_found' });
600
603
  // 2. Fetch latest domain_health
601
- const { data: healthRows } = await db()
604
+ const { data: healthRows, error: healthError } = await db()
602
605
  .from('domain_health')
603
606
  .select('spf_valid, dkim_valid, dmarc_valid, bounce_rate, complaint_rate, warmup_day, warmup_daily_limit, delivery_rate, total_sent')
604
607
  .eq('site_id', site_id)
605
608
  .order('check_date', { ascending: false })
606
609
  .limit(1);
610
+ if (healthError)
611
+ return toolError({ error: 'Unable to load domain health', code: 'internal_error' });
607
612
  const health = (healthRows ?? [])[0];
608
613
  const spfValid = health?.spf_valid ?? null; // null = not checked yet (#3904)
609
614
  const spfFailed = spfValid === false;
@@ -615,18 +620,16 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
615
620
  const warmupDailyLimit = health?.warmup_daily_limit ?? null;
616
621
  // 3. Fetch recent 7-day email_log stats
617
622
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
618
- const { data: recentLogs } = await db()
623
+ const { data: recentLogs, error: logsError } = await db()
619
624
  .from('email_log')
620
625
  .select("status, opened_at, clicked_at, bounced_at, contact_id, to_email")
621
626
  .eq('project_id', projectId)
622
627
  .eq('site_id', site_id)
623
628
  .gte('sent_at', sevenDaysAgo);
629
+ if (logsError)
630
+ return toolError({ error: 'Unable to load send history', code: 'internal_error' });
624
631
  const logs = recentLogs ?? [];
625
- const totalSent = logs.length;
626
- const bounced = logs.filter(isBouncedRow).length;
627
- const complained = logs.filter(isComplainedRow).length;
628
- const delivered = logs.filter(isDeliveredRow).length;
629
- const opened = countPeople(logs, isOpenedRow);
632
+ const { sent: totalSent, bounced, complained, delivered, opened } = countEmailRows(logs);
630
633
  const bounceRate = percentRate(bounced, totalSent, 2) ?? 0;
631
634
  const complaintRate = percentRate(complained, totalSent, 2) ?? 0;
632
635
  const openRate = percentRate(opened, delivered, 2) ?? 0;
@@ -745,7 +748,8 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
745
748
  estimatedRecovery = '3-5 days';
746
749
  return j({
747
750
  site: { id: site.id, slug: site.slug, domain: site.sending_domain },
748
- health_score: healthScore,
751
+ health_score: totalSent === 0 ? null : healthScore,
752
+ ...(totalSent === 0 ? { health_score_reason: 'no_send_data', reason: 'no_send_data' } : {}),
749
753
  recent_7d: {
750
754
  total_sent: totalSent,
751
755
  delivered,
@@ -765,7 +769,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
765
769
  short_term: shortTerm.length > 0 ? shortTerm : ['Continue current sending practices'],
766
770
  monitoring,
767
771
  },
768
- estimated_recovery: issues.length === 0 ? 'No recovery needed — site is healthy' : estimatedRecovery,
772
+ ...(issues.some(issue => issue.type !== 'no_recent_sends') ? { estimated_recovery: estimatedRecovery } : {}),
769
773
  });
770
774
  });
771
775
  }
@@ -28,10 +28,11 @@ export declare function computeAnalyticsMetrics(rows: EmailLogRow[], eventCounts
28
28
  clicked: number;
29
29
  }): AnalyticsMetrics;
30
30
  export declare function computeSenderHealthScore(metrics: {
31
+ sent: number;
31
32
  bounce_rate: number;
32
33
  complaint_rate: number;
33
34
  unsubscribe_rate: number;
34
35
  open_rate: number;
35
- }, trackingEnabled: boolean): number;
36
+ }, trackingEnabled: boolean): number | null;
36
37
  export declare function registerAnalyticsTools(server: McpServer): void;
37
38
  export {};
@@ -1,4 +1,4 @@
1
- import { readPeriodCampaigns, campaignPeriodFields } from "@sendinel/domain";
1
+ import { readPeriodCampaigns, campaignPeriodFields, filterPlanContacts } from "@sendinel/domain";
2
2
  import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
3
3
  import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
4
4
  import { z } from 'zod';
@@ -7,6 +7,7 @@ import { getProjectId } from '../project.js';
7
7
  import { resolveProjectClient } from '../byod-client.js';
8
8
  import { decrypt } from '../crypto.js';
9
9
  import { logAudit } from '../audit.js';
10
+ import { dbToolError, toolError } from '../lib/db-error.js';
10
11
  const txt = (text) => ({ content: [{ type: 'text', text }] });
11
12
  const j = (data) => txt(JSON.stringify(data, null, 2));
12
13
  const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
@@ -21,6 +22,8 @@ export function computeAnalyticsMetrics(rows, eventCounts) {
21
22
  complaint_rate: metrics.complaint_rate ?? 0, unsubscribe_rate: metrics.unsubscribe_rate ?? 0 };
22
23
  }
23
24
  export function computeSenderHealthScore(metrics, trackingEnabled) {
25
+ if (metrics.sent === 0)
26
+ return null;
24
27
  let score = 100;
25
28
  if (metrics.bounce_rate > 0.05)
26
29
  score -= 40;
@@ -462,6 +465,11 @@ export function registerAnalyticsTools(server) {
462
465
  }, async ({ site_id, start_date, end_date }) => {
463
466
  const projectId = getProjectId();
464
467
  const end = end_date ?? new Date().toISOString().slice(0, 10);
468
+ const validDate = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) &&
469
+ Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
470
+ if (!validDate(start_date) || !validDate(end) || start_date > end) {
471
+ return toolError({ error: 'Use valid YYYY-MM-DD dates with start_date on or before end_date', code: 'validation_failed' });
472
+ }
465
473
  const { data: site, error: siteError } = await db()
466
474
  .from('sites')
467
475
  .select('tracking_enabled')
@@ -469,7 +477,9 @@ export function registerAnalyticsTools(server) {
469
477
  .eq('id', site_id)
470
478
  .maybeSingle();
471
479
  if (siteError)
472
- return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
480
+ return toolError({ error: 'Unable to load site', code: 'internal_error' });
481
+ if (!site)
482
+ return toolError({ error: 'Site not found', code: 'not_found' });
473
483
  const trackingEnabled = site?.tracking_enabled ?? false;
474
484
  const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
475
485
  .from('email_log')
@@ -481,7 +491,7 @@ export function registerAnalyticsTools(server) {
481
491
  .order('id', { ascending: true })
482
492
  .range(offset, offset + limit - 1));
483
493
  if (error)
484
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
494
+ return toolError(dbToolError(error));
485
495
  const rows = (data ?? []);
486
496
  let eventCounts = { opened: 0, clicked: 0 };
487
497
  let eventRows = [];
@@ -498,7 +508,7 @@ export function registerAnalyticsTools(server) {
498
508
  .order('id', { ascending: true })
499
509
  .range(offset, offset + limit - 1));
500
510
  if (eventsError)
501
- return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
511
+ return toolError(dbToolError(eventsError));
502
512
  eventRows = events ?? [];
503
513
  eventPageCount = pageCount;
504
514
  const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
@@ -511,14 +521,14 @@ export function registerAnalyticsTools(server) {
511
521
  const periodDays = daysBetween(start_date, end);
512
522
  const priorStart = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -periodDays));
513
523
  const priorEnd = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -1));
514
- const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => db()
524
+ const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => filterPlanContacts(db()
515
525
  .from('subscribers')
516
526
  .select('created_at, last_engaged_at, last_opened_at, last_clicked_at')
517
- .eq('project_id', projectId)
527
+ .eq('project_id', projectId))
518
528
  .order('id', { ascending: true })
519
529
  .range(offset, offset + limit - 1));
520
530
  if (subscribersError)
521
- return { content: [{ type: 'text', text: `Error: ${subscribersError.message}` }] };
531
+ return toolError(dbToolError(subscribersError));
522
532
  const subscriberRows = (subscribers ?? []);
523
533
  const currentNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${start_date}T00:00:00Z` && s.created_at <= `${end}T23:59:59Z`).length;
524
534
  const priorNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${priorStart}T00:00:00Z` && s.created_at <= `${priorEnd}T23:59:59Z`).length;
@@ -540,6 +550,7 @@ export function registerAnalyticsTools(server) {
540
550
  list_growth_rate: subscriberRows.length > 0 ? pctNumber(currentNew / subscriberRows.length) : 0,
541
551
  inactive_pct: subscriberRows.length > 0 ? pctNumber(inactive / subscriberRows.length) : 0,
542
552
  sender_health_score: computeSenderHealthScore(metrics, trackingEnabled),
553
+ ...(metrics.sent === 0 ? { sender_health_score_reason: 'no_send_data', reason: 'no_send_data' } : {}),
543
554
  coverage: {
544
555
  log_rows_scanned: rows.length,
545
556
  log_page_count: logPageCount,
@@ -890,7 +901,8 @@ export function registerAnalyticsTools(server) {
890
901
  return txt(`Error: ${attributionError.message}`);
891
902
  const scopedAttributionRows = attributionRows ?? [];
892
903
  const computeMetrics = (set, counts) => {
893
- const total = counts?.sent ?? set.length;
904
+ const sharedCounts = counts ?? countEmailRows(set);
905
+ const total = sharedCounts.sent;
894
906
  const delivered = counts?.delivered ?? set.filter(isDeliveredRow).length;
895
907
  const opened = counts?.opened ?? countPeople(set, isOpenedRow);
896
908
  const clicked = counts?.clicked ?? countPeople(set, isClickedRow);
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
+ import { dbToolError, toolError } from '../lib/db-error.js';
4
5
  import { checkApproval } from '../lib/action-approvals.js';
5
6
  import { confirmApproval, confirmApprovalInput } from '../lib/approval-chat-confirm.js';
6
7
  const txt = (text) => ({ content: [{ type: 'text', text }] });
@@ -42,7 +43,7 @@ export function registerApprovalTools(server, options) {
42
43
  idempotentHint: true,
43
44
  openWorldHint: false,
44
45
  },
45
- description: 'List all pending approval requests for this project. Excludes expired requests without mutating them. Shows what actions are waiting for human approval.',
46
+ description: 'List unexpired pending approval requests for this project. Expired rows are excluded even before the expiry cron runs.',
46
47
  inputSchema: {},
47
48
  }, async () => {
48
49
  const { data, error } = await db()
@@ -54,7 +55,7 @@ export function registerApprovalTools(server, options) {
54
55
  .order('requested_at', { ascending: false })
55
56
  .limit(25);
56
57
  if (error)
57
- return j({ error: error.message, code: 'INTERNAL_ERROR' });
58
+ return toolError(dbToolError(error));
58
59
  return j({ pending: data ?? [], count: data?.length ?? 0 });
59
60
  });
60
61
  }
@@ -1,3 +1,4 @@
1
+ import { dbErrorResult } from '../lib/db-errors.js';
1
2
  import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
2
3
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
3
4
  import { z } from 'zod';
@@ -12,6 +13,7 @@ import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/d
12
13
  import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../lib/campaign-trigger-approval.js';
13
14
  import { adminDb, db, requestContext } from '../db.js';
14
15
  import { getProjectId } from '../project.js';
16
+ import { dbToolError, toolError } from '../lib/db-error.js';
15
17
  import { checkPlanLimit } from '../plan-limits.js';
16
18
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
17
19
  import { fireWebhookEvent } from '../lib/webhook-events.js';
@@ -382,10 +384,12 @@ async function campaignInProject(campaignId) {
382
384
  return Boolean(data);
383
385
  }
384
386
  /** Mirrors validateTarget() in the branches REST route. */
385
- async function validateBranchTarget(campaignId, action, targetStepId, targetCampaignId) {
387
+ async function validateBranchTarget(campaignId, fromStepId, action, targetStepId, targetCampaignId) {
386
388
  if (action === 'goto_step') {
387
389
  if (!targetStepId)
388
390
  return 'target_step_id is required for goto_step';
391
+ if (targetStepId === fromStepId)
392
+ return 'goto_step cannot target its own source step';
389
393
  const { data } = await db()
390
394
  .from('campaign_steps')
391
395
  .select('id')
@@ -398,6 +402,8 @@ async function validateBranchTarget(campaignId, action, targetStepId, targetCamp
398
402
  if (action === 'goto_campaign') {
399
403
  if (!targetCampaignId)
400
404
  return 'target_campaign_id is required for goto_campaign';
405
+ if (targetCampaignId === campaignId)
406
+ return 'goto_campaign cannot target its own campaign';
401
407
  if (!(await campaignInProject(targetCampaignId)))
402
408
  return 'target_campaign_id must be a campaign in this project';
403
409
  }
@@ -882,40 +888,56 @@ export function registerCampaignTools(server) {
882
888
  send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
883
889
  },
884
890
  }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
885
- const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
886
- if (!properties.ok)
887
- return j({ error: properties.error, code: "INVALID_INPUT" });
888
- const renderVars = {};
889
- if (survey_id) {
890
- const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
891
- if (!surveyLink)
892
- return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
893
- renderVars.survey_link = surveyLink;
891
+ try {
892
+ const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
893
+ if (!properties.ok)
894
+ return j({ error: properties.error, code: "INVALID_INPUT" });
895
+ const { data: campaign, error: campaignError } = await db()
896
+ .schema('email')
897
+ .from('campaigns')
898
+ .select('id')
899
+ .eq('id', campaign_id)
900
+ .eq('project_id', getProjectId())
901
+ .maybeSingle();
902
+ if (campaignError)
903
+ return dbErrorResult(campaignError);
904
+ if (!campaign)
905
+ return { ...j({ error: 'Campaign not found.', code: 'not_found' }), isError: true };
906
+ const renderVars = {};
907
+ if (survey_id) {
908
+ const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
909
+ if (!surveyLink)
910
+ return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
911
+ renderVars.survey_link = surveyLink;
912
+ }
913
+ const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
914
+ const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
915
+ const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
916
+ const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
917
+ const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
918
+ const { data, error } = await db()
919
+ .from('campaign_steps')
920
+ .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
921
+ .select()
922
+ .single();
923
+ if (error)
924
+ return dbErrorResult(error);
925
+ await recordBrandAssetUsage({
926
+ client: db(),
927
+ projectId: getProjectId(),
928
+ source: 'campaign_step',
929
+ resourceId: data.id,
930
+ html: renderedHtml,
931
+ assetIds: brand_asset_ids,
932
+ }).catch((err) => {
933
+ console.warn('Failed to record brand asset usage for MCP campaign step:', err);
934
+ });
935
+ await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
936
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
937
+ }
938
+ catch (error) {
939
+ return dbErrorResult(error);
894
940
  }
895
- const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
896
- const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
897
- const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
898
- const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
899
- const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
900
- const { data, error } = await db()
901
- .from('campaign_steps')
902
- .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
903
- .select()
904
- .single();
905
- if (error)
906
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
907
- await recordBrandAssetUsage({
908
- client: db(),
909
- projectId: getProjectId(),
910
- source: 'campaign_step',
911
- resourceId: data.id,
912
- html: renderedHtml,
913
- assetIds: brand_asset_ids,
914
- }).catch((err) => {
915
- console.warn('Failed to record brand asset usage for MCP campaign step:', err);
916
- });
917
- await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
918
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
919
941
  });
920
942
  server.registerTool('update_campaign_status', {
921
943
  title: 'Update Campaign Status',
@@ -1589,7 +1611,7 @@ export function registerCampaignTools(server) {
1589
1611
  idempotentHint: false,
1590
1612
  openWorldHint: false,
1591
1613
  },
1592
- description: 'Clone an existing campaign (with all steps) into a new draft campaign',
1614
+ description: 'Clone campaign steps into a draft. Trigger, audience, send class, re-entry, goals and branches are NOT copied; fields_not_copied and warnings explicitly identify these omissions. Review and configure before activation.',
1593
1615
  inputSchema: {
1594
1616
  campaign_id: z.string().uuid().describe('Source campaign to clone'),
1595
1617
  new_name: z.string().min(1).describe('Name for the cloned campaign'),
@@ -1603,11 +1625,13 @@ export function registerCampaignTools(server) {
1603
1625
  .eq('id', campaign_id)
1604
1626
  .eq('project_id', projectId)
1605
1627
  .maybeSingle();
1606
- if (fetchErr || !original)
1607
- return txt(`Error: Campaign not found — ${fetchErr?.message ?? 'not found'}`);
1628
+ if (fetchErr)
1629
+ return toolError(dbToolError(fetchErr));
1630
+ if (!original)
1631
+ return toolError({ error: 'Campaign not found', code: 'not_found' });
1608
1632
  const limit = await checkPlanLimit('active_campaigns');
1609
1633
  if (!limit.allowed)
1610
- return txt(`Active campaign limit reached (${limit.current}/${limit.limit}).`);
1634
+ return toolError({ error: `Active campaign limit reached (${limit.current}/${limit.limit}).`, code: 'plan_limit' });
1611
1635
  const { data: newCampaign, error: createErr } = await db()
1612
1636
  .from('campaigns')
1613
1637
  .insert({
@@ -1623,7 +1647,7 @@ export function registerCampaignTools(server) {
1623
1647
  .select()
1624
1648
  .single();
1625
1649
  if (createErr)
1626
- return txt(`Error creating campaign: ${createErr.message}`);
1650
+ return toolError(dbToolError(createErr));
1627
1651
  const steps = (original.campaign_steps ?? []).sort((a, b) => a.step_order - b.step_order);
1628
1652
  if (steps.length > 0) {
1629
1653
  const stepRows = steps.map((s) => ({
@@ -1638,9 +1662,18 @@ export function registerCampaignTools(server) {
1638
1662
  }));
1639
1663
  const { error: stepErr } = await db().from('campaign_steps').insert(stepRows);
1640
1664
  if (stepErr)
1641
- return txt(`Campaign created but steps failed: ${stepErr.message}`);
1642
- }
1643
- return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id });
1665
+ return toolError({ ...dbToolError(stepErr, 'Campaign created but copying steps failed'), campaign_id: newCampaign.id });
1666
+ }
1667
+ const copied = new Set(['project_id', 'site_id', 'name', 'type', 'description', 'sends_per_hour', 'requires_approval']);
1668
+ const identity = new Set(['id', 'status', 'created_at', 'updated_at', 'campaign_steps']);
1669
+ const fields_not_copied = [...new Set([
1670
+ ...Object.keys(original).filter(key => !copied.has(key) && !identity.has(key)),
1671
+ 'trigger_type', 'trigger_event_name', 'trigger_tag', 'trigger_config', 'segment_id',
1672
+ 'send_class', 'unsub_footer_template', 'period_key', 'reentry_mode', 'campaign_goals', 'campaign_step_branches',
1673
+ ])];
1674
+ await logAudit({ action: 'campaign.cloned', resourceType: 'campaign', resourceId: newCampaign.id, details: { cloned_from: campaign_id, fields_not_copied } });
1675
+ return j({ ...newCampaign, steps_copied: steps.length, cloned_from: campaign_id, fields_not_copied,
1676
+ warnings: fields_not_copied.map(field => `${field} was not copied; review and configure the draft before activation.`) });
1644
1677
  });
1645
1678
  server.registerTool('list_sequence_templates', {
1646
1679
  title: 'List Sequence Templates',
@@ -3831,14 +3864,20 @@ Return ONLY valid JSON with this exact shape:
3831
3864
  }, async ({ campaign_id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority }) => {
3832
3865
  const conditionValue = campaignBranchConditionValue(condition_type, condition_value);
3833
3866
  if (!conditionValue.ok)
3834
- return { ...j({ error: conditionValue.error, code: 'INVALID_CONDITION_VALUE' }), isError: true };
3867
+ return { ...j({ error: conditionValue.error, code: 'validation_failed' }), isError: true };
3835
3868
  if (!(await campaignInProject(campaign_id)))
3836
- return j({ error: 'Campaign not found in this project', code: 'CAMPAIGN_NOT_FOUND' });
3869
+ return toolError({ error: 'Campaign not found in this project', code: 'not_found' });
3870
+ const { data: sourceStep, error: sourceError } = await db().from('campaign_steps').select('id')
3871
+ .eq('id', from_step_id).eq('campaign_id', campaign_id).maybeSingle();
3872
+ if (sourceError)
3873
+ return toolError(dbToolError(sourceError));
3874
+ if (!sourceStep)
3875
+ return toolError({ error: 'from_step_id must be a step in this campaign', code: 'validation_failed' });
3837
3876
  // Same requirement the REST route enforces: an action naming a target must have one,
3838
3877
  // and that target must belong to this project.
3839
- const targetError = await validateBranchTarget(campaign_id, action, target_step_id ?? null, target_campaign_id ?? null);
3878
+ const targetError = await validateBranchTarget(campaign_id, from_step_id, action, target_step_id ?? null, target_campaign_id ?? null);
3840
3879
  if (targetError)
3841
- return j({ error: targetError, code: 'INVALID_TARGET' });
3880
+ return toolError({ error: targetError, code: 'validation_failed' });
3842
3881
  const { data, error } = await db()
3843
3882
  .from('campaign_step_branches')
3844
3883
  .insert({
@@ -3854,9 +3893,14 @@ Return ONLY valid JSON with this exact shape:
3854
3893
  .select('id, from_step_id, condition_type, condition_value, action, target_step_id, target_campaign_id, priority')
3855
3894
  .single();
3856
3895
  if (error)
3857
- return j({ error: error.message });
3896
+ return toolError(dbToolError(error));
3858
3897
  await logAudit({ action: 'add_campaign_branch', resourceType: 'campaign', resourceId: campaign_id, details: { branch_id: data?.id, condition_type, branch_action: action } }).catch(() => { });
3859
- return j({ success: true, branch: data });
3898
+ // Read after insertion so a tie already committed by a concurrent request is visible.
3899
+ const { data: peers, error: peerError } = await db().from('campaign_step_branches').select('id')
3900
+ .eq('campaign_id', campaign_id).eq('from_step_id', from_step_id).eq('priority', priority ?? 0).neq('id', data.id);
3901
+ const warnings = peerError ? ['Branch saved, but priority ties could not be checked.']
3902
+ : peers?.length ? ['Another branch on this step has the same priority; evaluation order may be ambiguous.'] : [];
3903
+ return j({ success: true, branch: data, warnings });
3860
3904
  });
3861
3905
  server.registerTool('remove_campaign_branch', {
3862
3906
  title: 'Remove Campaign Branch',