@sendinel/mcp-server 1.0.35 → 1.0.37

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.
@@ -1,3 +1,4 @@
1
+ import { decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
1
2
  import { db, requestContext } from '../db.js';
2
3
  import { getProjectId } from '../project.js';
3
4
  import { fireWebhookEvent } from './webhook-events.js';
@@ -131,12 +132,14 @@ export function currentChannelContext() {
131
132
  }
132
133
  /** Tag-stripped, whitespace-collapsed, length-capped text. HTML is untrusted input. */
133
134
  export function approvalExcerpt(html, max = 200) {
134
- const text = String(html ?? '')
135
+ // Outlook-only comment content ("96") is dropped and character codes decoded, so an approver
136
+ // reads the email the way a recipient does.
137
+ const text = decodeHtmlEntities(stripHtmlComments(String(html ?? ''))
135
138
  .replace(/<style[\s\S]*?<\/style>/gi, ' ')
136
139
  .replace(/<script[\s\S]*?<\/script>/gi, ' ')
137
- .replace(/<[^>]+>/g, ' ')
138
- .replace(/&nbsp;/g, ' ')
139
- .replace(/\s+/g, ' ')
140
+ .replace(/<title[\s\S]*?<\/title>/gi, ' ')
141
+ .replace(/<[^>]+>/g, ' '))
142
+ .replace(/[\s\u00A0\u2002\u2003\u2009\u200A]+/g, ' ')
140
143
  .trim();
141
144
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
142
145
  }
@@ -149,10 +152,26 @@ function broadcastSummary(d) {
149
152
  const excerpt = d.body_excerpt ? ` — "${d.body_excerpt}"` : '';
150
153
  return `Send "${d.subject}" to ${audience}${count ? ` (${count})` : ''}${when}${excerpt}`;
151
154
  }
155
+ /**
156
+ * launch_campaign approvals carry the written emails in params.generated (#3669), so the summary
157
+ * can name what goes out. Null for an older approval that has only the briefs.
158
+ */
159
+ function launchSummary(p, d) {
160
+ const generated = (p.generated ?? {});
161
+ const steps = Array.isArray(generated.steps) ? generated.steps : [];
162
+ if (!steps.length)
163
+ return null;
164
+ const subjects = steps.map((step, i) => `${i + 1}. "${approvalExcerpt(step?.subject, 120)}"`).join(' ');
165
+ const count = Array.isArray(generated.contact_ids) ? generated.contact_ids.length : null;
166
+ const people = count === null ? 'real contacts' : `${count.toLocaleString('en-US')} contact${count === 1 ? '' : 's'}`;
167
+ const audience = d.segment_name ? ` in segment "${d.segment_name}"` : (d.audience_label ? ` matching ${d.audience_label}` : '');
168
+ const excerpt = approvalExcerpt(steps[0]?.body_html);
169
+ return `Launch campaign "${p.name || 'unknown'}": ${steps.length} email${steps.length === 1 ? '' : 's'} to ${people}${audience} — ${subjects}${excerpt ? ` — first email: "${excerpt}"` : ''}`;
170
+ }
152
171
  /** Build a human-readable summary of the action for the approval screen */
153
172
  export function buildApprovalSummary(action, params, display = {}) {
154
173
  const summaries = {
155
- launch_campaign: (p) => `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
174
+ launch_campaign: (p) => launchSummary(p, display) ?? `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
156
175
  install_connector_schema: (p) => `Install connector schema for project ${p.project_id ?? 'current'}`,
157
176
  install_connector_view: (p) => `Reinstall the customer-side contact view for project ${p.project_id ?? 'current'}`,
158
177
  set_connector_mapping: () => `Rewrite the BYOD connector field mapping`,
@@ -1525,7 +1525,7 @@ Adapt your tone accordingly:
1525
1525
  idempotentHint: false,
1526
1526
  openWorldHint: true,
1527
1527
  },
1528
- description: 'Full campaign lifecycle in one call: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Requires human approval via the dashboard before confirmed execution.',
1528
+ description: 'Full campaign lifecycle: create segment (from NL or existing), create campaign, generate emails from briefs, enroll contacts, activate. Call with confirmed=true to write the emails and get an approval_id; a person approves those exact emails; call again with the same arguments plus approval_id to launch them. Nothing is created or sent before approval.',
1529
1529
  inputSchema: {
1530
1530
  name: z.string().min(1).describe('Campaign name'),
1531
1531
  site_id: z.string().uuid(),
@@ -1544,12 +1544,10 @@ Adapt your tone accordingly:
1544
1544
  }, async ({ name, site_id, type, segment_description, segment_id, emails, sends_per_hour, confirmed, approval_id }) => {
1545
1545
  if (!segment_description && !segment_id)
1546
1546
  return txt('Provide segment_description or segment_id.');
1547
- const apiKey = process.env.ANTHROPIC_API_KEY;
1548
- if (!apiKey)
1549
- return txt('Error: ANTHROPIC_API_KEY not configured');
1550
- const anthropic = new Anthropic({ apiKey });
1551
1547
  const projectId = getProjectId();
1552
- const approvalParams = {
1548
+ // What the caller asked for. An approval is checked against these, so the approved call must
1549
+ // repeat them unchanged.
1550
+ const requestParams = {
1553
1551
  name,
1554
1552
  site_id,
1555
1553
  type: type ?? 'sequence',
@@ -1558,22 +1556,125 @@ Adapt your tone accordingly:
1558
1556
  emails,
1559
1557
  sends_per_hour,
1560
1558
  };
1559
+ function readLaunchPlan(value) {
1560
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1561
+ return null;
1562
+ const plan = value;
1563
+ if (!plan.rules || typeof plan.rules !== 'object' || Array.isArray(plan.rules))
1564
+ return null;
1565
+ if (!Array.isArray(plan.contact_ids) || !Array.isArray(plan.steps) || plan.steps.length === 0)
1566
+ return null;
1567
+ const steps = [];
1568
+ for (const raw of plan.steps) {
1569
+ if (!raw || typeof raw.subject !== 'string' || !raw.subject.trim() || typeof raw.body_html !== 'string' || !raw.body_html.trim())
1570
+ return null;
1571
+ steps.push({
1572
+ subject: raw.subject,
1573
+ body_html: raw.body_html,
1574
+ body_text: typeof raw.body_text === 'string' ? raw.body_text : null,
1575
+ blocks: Array.isArray(raw.blocks) ? raw.blocks : null,
1576
+ delay_days: typeof raw.delay_days === 'number' ? raw.delay_days : 0,
1577
+ delay_hours: typeof raw.delay_hours === 'number' ? raw.delay_hours : 0,
1578
+ });
1579
+ }
1580
+ return {
1581
+ rules: plan.rules,
1582
+ segment_name: typeof plan.segment_name === 'string' ? plan.segment_name : `${name} — segment`,
1583
+ contact_ids: plan.contact_ids.map(String),
1584
+ steps,
1585
+ };
1586
+ }
1587
+ // Creates the segment (when the audience was described in words), the campaign and its steps,
1588
+ // enrolls the given people and activates. Uses only the plan: no model call happens here.
1589
+ async function launchFromPlan(plan) {
1590
+ let segId = segment_id;
1591
+ if (segment_description) {
1592
+ const { data: seg, error: segErr } = await db()
1593
+ .from('segments')
1594
+ .insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
1595
+ .select('id')
1596
+ .single();
1597
+ if (segErr)
1598
+ return txt(`Error creating segment: ${segErr.message}`);
1599
+ segId = seg.id;
1600
+ }
1601
+ const campLimit = await checkPlanLimit('active_campaigns');
1602
+ if (!campLimit.allowed)
1603
+ return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
1604
+ const { data: campaign, error: campErr } = await db()
1605
+ .from('campaigns')
1606
+ .insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
1607
+ .select('id')
1608
+ .single();
1609
+ if (campErr)
1610
+ return txt(`Error creating campaign: ${campErr.message}`);
1611
+ const steps = [];
1612
+ for (let i = 0; i < plan.steps.length; i++) {
1613
+ const planned = plan.steps[i];
1614
+ const { data: step, error: stepErr } = await db()
1615
+ .from('campaign_steps')
1616
+ .insert({
1617
+ campaign_id: campaign.id, step_order: i,
1618
+ subject: planned.subject, body_html: planned.body_html, body_text: planned.body_text,
1619
+ blocks: planned.blocks,
1620
+ delay_days: planned.delay_days, delay_hours: planned.delay_hours,
1621
+ })
1622
+ .select('id, step_order, subject')
1623
+ .single();
1624
+ if (stepErr)
1625
+ return txt(`Error adding step ${i}: ${stepErr.message}`);
1626
+ steps.push(step);
1627
+ }
1628
+ const enrollRows = plan.contact_ids.map((id) => ({ campaign_id: campaign.id, contact_id: id, next_send_at: new Date().toISOString() }));
1629
+ if (enrollRows.length) {
1630
+ await db().from('campaign_enrollments').upsert(enrollRows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
1631
+ }
1632
+ await db().from('campaigns').update({ status: 'active', total_enrolled: enrollRows.length, updated_at: new Date().toISOString() }).eq('id', campaign.id);
1633
+ return j({
1634
+ campaign_id: campaign.id, campaign_name: name, status: 'active',
1635
+ segment: { id: segId, name: plan.segment_name, contacts: plan.contact_ids.length },
1636
+ enrolled: enrollRows.length,
1637
+ steps: steps.map(s => ({ step: s.step_order, subject: s.subject })),
1638
+ message: `Campaign "${name}" is live! ${enrollRows.length} contacts enrolled across ${steps.length} email(s).`,
1639
+ });
1640
+ }
1641
+ const channelContext = currentChannelContext();
1642
+ let needsApproval = false;
1561
1643
  if (confirmed) {
1562
- const channelContext = currentChannelContext();
1563
- const decision = assertChannelCanExecute('launch_campaign', approvalParams, channelContext);
1644
+ const decision = assertChannelCanExecute('launch_campaign', requestParams, channelContext);
1564
1645
  if (!decision.allowed)
1565
1646
  return txt(decision.reason ?? 'This channel cannot request this action.');
1566
- if (decision.tier !== 'autonomous') {
1567
- if (!approval_id) {
1568
- const approval = await requestApproval('launch_campaign', approvalParams, undefined, channelContext);
1569
- return j(approval);
1570
- }
1571
- const check = await checkApproval(approval_id, { consume: true, action: 'launch_campaign', params: approvalParams });
1572
- if (!check.approved) {
1573
- return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1574
- }
1647
+ needsApproval = decision.tier !== 'autonomous';
1648
+ }
1649
+ // Approved call: launch exactly what the person approved.
1650
+ if (confirmed && needsApproval && approval_id) {
1651
+ const { data: row, error: rowErr } = await db()
1652
+ .from('action_approvals')
1653
+ .select('params')
1654
+ .eq('id', approval_id)
1655
+ .eq('project_id', projectId)
1656
+ .eq('action', 'launch_campaign')
1657
+ .maybeSingle();
1658
+ if (rowErr)
1659
+ return txt(`Error reading approval ${approval_id}: ${rowErr.message}`);
1660
+ if (!row)
1661
+ return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
1662
+ const stored = (row.params ?? {});
1663
+ const plan = readLaunchPlan(stored.generated);
1664
+ // Checked before the approval is used up: an approval made before the emails were shown
1665
+ // for approval covers only the briefs, so it is refused and left as it is.
1666
+ if (!plan)
1667
+ return txt(`Approval ${approval_id} does not include the written emails, so it cannot launch anything. Request a new approval.`);
1668
+ const check = await checkApproval(approval_id, { consume: true, action: 'launch_campaign', params: { ...requestParams, generated: stored.generated } });
1669
+ if (!check.approved) {
1670
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
1575
1671
  }
1672
+ return launchFromPlan(plan);
1576
1673
  }
1674
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1675
+ if (!apiKey)
1676
+ return txt('Error: ANTHROPIC_API_KEY not configured');
1677
+ const anthropic = new Anthropic({ apiKey });
1577
1678
  function applyPaymentRules(query, payment) {
1578
1679
  if (!payment)
1579
1680
  return query;
@@ -1654,9 +1755,10 @@ Adapt your tone accordingly:
1654
1755
  }
1655
1756
  return ids;
1656
1757
  };
1657
- let segId = segment_id;
1758
+ const segId = segment_id;
1658
1759
  let segmentName;
1659
1760
  let contactCount = 0;
1761
+ let contactIds = [];
1660
1762
  let parsedRules;
1661
1763
  if (segment_description) {
1662
1764
  const parseResp = await anthropic.messages.create({
@@ -1673,8 +1775,8 @@ Adapt your tone accordingly:
1673
1775
  catch {
1674
1776
  return txt(`Error: Failed to parse segment from "${segment_description}"`);
1675
1777
  }
1676
- const ids = await getContactIds(parsedRules);
1677
- contactCount = ids.length;
1778
+ contactIds = await getContactIds(parsedRules);
1779
+ contactCount = contactIds.length;
1678
1780
  segmentName = `${name} — segment`;
1679
1781
  }
1680
1782
  else {
@@ -1688,8 +1790,8 @@ Adapt your tone accordingly:
1688
1790
  return txt('Error: segment not found');
1689
1791
  segmentName = seg.name;
1690
1792
  parsedRules = seg.rules;
1691
- const ids = await getContactIds(parsedRules);
1692
- contactCount = ids.length;
1793
+ contactIds = await getContactIds(parsedRules);
1794
+ contactCount = contactIds.length;
1693
1795
  }
1694
1796
  if (!confirmed) {
1695
1797
  const subjectPreviews = [];
@@ -1718,27 +1820,10 @@ Adapt your tone accordingly:
1718
1820
  }
1719
1821
  if (contactCount === 0)
1720
1822
  return txt('No contacts match the segment. Adjust targeting criteria.');
1721
- // Create segment if from NL description
1722
- if (segment_description) {
1723
- const { data: seg, error: segErr } = await db()
1724
- .from('segments')
1725
- .insert({ project_id: projectId, name: segmentName, description: segment_description, rules: parsedRules })
1726
- .select('id')
1727
- .single();
1728
- if (segErr)
1729
- return txt(`Error creating segment: ${segErr.message}`);
1730
- segId = seg.id;
1731
- }
1823
+ // Checked before anything is written or an approval is requested.
1732
1824
  const campLimit = await checkPlanLimit('active_campaigns');
1733
1825
  if (!campLimit.allowed)
1734
1826
  return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
1735
- const { data: campaign, error: campErr } = await db()
1736
- .from('campaigns')
1737
- .insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
1738
- .select('id')
1739
- .single();
1740
- if (campErr)
1741
- return txt(`Error creating campaign: ${campErr.message}`);
1742
1827
  const [{ data: site }, { data: project }] = await Promise.all([
1743
1828
  db()
1744
1829
  .from('sites')
@@ -1752,6 +1837,8 @@ Adapt your tone accordingly:
1752
1837
  .eq('id', projectId)
1753
1838
  .maybeSingle(),
1754
1839
  ]);
1840
+ if (!site)
1841
+ return txt('Error: site not found');
1755
1842
  const brandCtx = site ? `Brand: ${site.name}, Domain: ${site.sending_domain}, From: ${site.from_name}` : '';
1756
1843
  const voicePrompt = buildVoicePrompt(site?.brand_voice, project?.brand_settings ?? null);
1757
1844
  const systemPrompt = `${buildEmailSystemPrompt(brandCtx, voicePrompt, project?.brand_settings ?? null)}
@@ -1774,7 +1861,9 @@ ComposerBlock[] must conform to this JSON Schema:
1774
1861
  ${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
1775
1862
 
1776
1863
  Do not include body_html in your response; Sendinel renders body_html from blocks.`;
1777
- const steps = [];
1864
+ // Write every email first. If one cannot be written, stop: nothing is created and no
1865
+ // approval is requested, so a brief can never go out in place of an email.
1866
+ const plannedSteps = [];
1778
1867
  for (let i = 0; i < emails.length; i++) {
1779
1868
  const email = emails[i];
1780
1869
  const genResp = await anthropic.messages.create({
@@ -1785,52 +1874,61 @@ Do not include body_html in your response; Sendinel renders body_html from block
1785
1874
  });
1786
1875
  const gtb = genResp.content.find((b) => b.type === 'text');
1787
1876
  const rawEmail = gtb && 'text' in gtb ? gtb.text : '{}';
1788
- let emailContent;
1877
+ let written = null;
1789
1878
  try {
1790
1879
  const parsed = JSON.parse(rawEmail.replace(/```json?\n?|```/g, '').trim());
1791
1880
  const conclusion = parsed.conclusion ?? parsed;
1792
1881
  const blocks = normalizeComposerBlocks(conclusion.blocks);
1793
- emailContent = blocks.length
1882
+ written = blocks.length
1794
1883
  ? {
1795
1884
  subject: conclusion.subject,
1796
1885
  body_html: blocksToHtml(blocks, { preheader: typeof conclusion.preview_text === 'string' ? conclusion.preview_text : null }),
1797
1886
  body_text: typeof conclusion.body_text === 'string' && conclusion.body_text.trim() ? conclusion.body_text : blocksToText(blocks),
1798
1887
  blocks,
1799
1888
  }
1800
- : parsed;
1889
+ : { subject: conclusion.subject, body_html: conclusion.body_html, body_text: conclusion.body_text, blocks: null };
1801
1890
  }
1802
1891
  catch {
1803
- emailContent = { subject: `Step ${i + 1}`, body_html: '<p>' + email.brief + '</p>', body_text: email.brief };
1892
+ written = null;
1804
1893
  }
1805
- const { data: step, error: stepErr } = await db()
1806
- .from('campaign_steps')
1807
- .insert({
1808
- campaign_id: campaign.id, step_order: i,
1809
- subject: String(emailContent.subject ?? `Step ${i + 1}`), body_html: emailContent.body_html, body_text: emailContent.body_text,
1810
- blocks: Array.isArray(emailContent.blocks) ? emailContent.blocks : null,
1811
- delay_days: email.delay_days ?? 0, delay_hours: email.delay_hours ?? 0,
1812
- })
1813
- .select('id, step_order, subject')
1814
- .single();
1815
- if (stepErr)
1816
- return txt(`Error adding step ${i}: ${stepErr.message}`);
1817
- steps.push(step);
1894
+ if (!written || typeof written.subject !== 'string' || !written.subject.trim() || typeof written.body_html !== 'string' || !written.body_html.trim()) {
1895
+ return txt(`Error: email ${i + 1} of ${emails.length} could not be written from its brief. Nothing was created and no approval was requested. Try again, or reword that brief.`);
1896
+ }
1897
+ plannedSteps.push({
1898
+ subject: written.subject.trim(),
1899
+ body_html: written.body_html,
1900
+ body_text: typeof written.body_text === 'string' && written.body_text.trim() ? written.body_text : null,
1901
+ blocks: written.blocks,
1902
+ delay_days: email.delay_days ?? 0,
1903
+ delay_hours: email.delay_hours ?? 0,
1904
+ });
1818
1905
  }
1819
- // Enroll contacts
1820
- const enrollIds = await getContactIds(parsedRules);
1821
- const enrollRows = enrollIds.map((id) => ({ campaign_id: campaign.id, contact_id: id, next_send_at: new Date().toISOString() }));
1822
- if (enrollRows.length) {
1823
- await db().from('campaign_enrollments').upsert(enrollRows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true });
1906
+ const plan = {
1907
+ rules: parsedRules,
1908
+ segment_name: segmentName ?? `${name} — segment`,
1909
+ // Frozen here: the people a person approves are the people enrolled.
1910
+ contact_ids: contactIds,
1911
+ steps: plannedSteps,
1912
+ };
1913
+ if (needsApproval) {
1914
+ const approval = await requestApproval('launch_campaign', { ...requestParams, generated: plan }, undefined, channelContext, {
1915
+ segment_name: segment_id ? plan.segment_name : null,
1916
+ audience_label: segment_description ? `"${segment_description}"` : null,
1917
+ });
1918
+ return j({
1919
+ ...approval,
1920
+ campaign_name: name,
1921
+ recipient_count: plan.contact_ids.length,
1922
+ segment: segment_description
1923
+ ? { description: segment_description, parsed_rules: plan.rules, matching_contacts: plan.contact_ids.length }
1924
+ : { id: segId, name: plan.segment_name, matching_contacts: plan.contact_ids.length },
1925
+ emails: plan.steps.map((step, i) => ({
1926
+ step: i, subject: step.subject, body_html: step.body_html, body_text: step.body_text, delay_days: step.delay_days, delay_hours: step.delay_hours,
1927
+ })),
1928
+ next: 'Show the user each subject, the recipient count and approve_url. After a person approves, call launch_campaign again with the same arguments plus approval_id. These exact emails are sent; nothing is rewritten. Do not say it launched before that call succeeds.',
1929
+ });
1824
1930
  }
1825
- // Activate + update enrolled count
1826
- await db().from('campaigns').update({ status: 'active', total_enrolled: enrollIds.length, updated_at: new Date().toISOString() }).eq('id', campaign.id);
1827
- return j({
1828
- campaign_id: campaign.id, campaign_name: name, status: 'active',
1829
- segment: { id: segId, name: segmentName, contacts: contactCount },
1830
- enrolled: enrollIds.length,
1831
- steps: steps.map(s => ({ step: s.step_order, subject: s.subject })),
1832
- message: `Campaign "${name}" is live! ${enrollIds.length} contacts enrolled across ${steps.length} email(s).`,
1833
- });
1931
+ return launchFromPlan(plan);
1834
1932
  });
1835
1933
  server.registerTool('preview_email', {
1836
1934
  title: 'Preview Email',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.35",
3
+ "version": "1.0.37",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",