@sendinel/mcp-server 1.0.212 → 1.0.214

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.
@@ -112,12 +112,12 @@ export const HOW_TO_RECIPES = [
112
112
  requires: { scopes: ['write'], tester_ok: false },
113
113
  steps: [sender,
114
114
  { n: 2, tool: 'create_campaign', args_example: { site_id: from(1, '0.id'), name: 'Welcome new signups', type: 'triggered', trigger_event_name: 'signup', send_class: 'product' }, note: 'Confirm the actual event emitted by the integration; signup is an example. This creates a draft.' },
115
- { n: 3, tool: 'add_campaign_step', args_example: { campaign_id: from(2, 'id'), step_order: 0, subject: 'Welcome!', body_html: '<p>Welcome aboard.</p>', delay_days: 0 } },
115
+ { n: 3, tool: 'add_campaign_step', args_example: { campaign_id: from(2, 'id'), step_order: 0, subject: 'Welcome!', body_html: '<p>Welcome aboard.</p>', delay_days: 0 }, note: 'Optional: pass template_id (+ template_vars) from create_template/list_templates instead of body_html.' },
116
116
  { n: 4, tool: 'send_test_email', args_example: { to_email: 'person@example.com', site_id: from(1, '0.id'), step_id: from(3, 'id'), dry_run: true }, note: 'Use an authorized preview address. Keep dry_run:true: render only, without approval.' },
117
117
  { n: 5, tool: 'activate_campaigns', args_example: { campaign_ids: [from(2, 'id')] }, approval, note: 'Show the campaign content and approve_url to the person; stop until approved.' },
118
118
  { n: 6, tool: 'activate_campaigns', args_example: { approval_id: from(5, 'approval_id') }, approval, note: 'Only after approval. Activation turns on the saved campaign; launch_campaign instead generates a NEW campaign from briefs.' },
119
119
  ],
120
- gotchas: [projectRule, previewRule, 'Activation does not emit signup events or enroll historical signups. Verify the integration fires the configured event.'],
120
+ gotchas: [projectRule, previewRule, 'Activation does not emit signup events or enroll historical signups. Verify the integration fires the configured event.', 'A template_id on add_campaign_step is rendered once at step creation, so later template edits do not change existing steps.'],
121
121
  done_when: 'Activation succeeds and the integration’s signup event is configured; end-to-end delivery remains unverified until an authorized signup test completes.',
122
122
  },
123
123
  {
@@ -155,9 +155,9 @@ export const HOW_TO_RECIPES = [
155
155
  { n: 4, tool: 'preview_email', args_example: { site_id: from(1, '0.id'), subject: from(3, 'template.subject_hint'), body_html: from(3, 'template.body_html') }, note: 'Review the rendered content with sample contact data; this sends nothing.' },
156
156
  { n: 5, tool: 'list_templates', args_example: { search: 'Product update' }, note: 'Verify the reusable template is discoverable; paginate if truncated.' },
157
157
  { n: 6, tool: 'create_campaign', args_example: { site_id: from(1, '0.id'), name: 'Product update sequence', type: 'sequence' }, note: 'Optional use in a campaign: creates a new draft; use an existing draft instead if appropriate.' },
158
- { n: 7, tool: 'add_campaign_step', args_example: { campaign_id: from(6, 'id'), step_order: 0, subject: from(3, 'template.subject_hint'), body_html: from(3, 'template.body_html') }, note: 'Copy content from get_template. No template_id parameter exists here; template_id/template_vars in the response are reserved for this path. This draft is not activated.' },
158
+ { n: 7, tool: 'add_campaign_step', args_example: { campaign_id: from(6, 'id'), step_order: 0, subject: from(3, 'template.subject_hint'), body_html: from(3, 'template.body_html') }, note: 'Optional: pass template_id (+ template_vars) from create_template/list_templates instead of body_html. This draft is not activated.' },
159
159
  ],
160
- gotchas: [projectRule, previewRule, 'Later template edits do not update copied step content. Sequences need a separate enrollment/activation plan and approval. For a one-off segment broadcast use send_broadcast with copied html and subject, segment_id and optional send_at; approval is required.'],
160
+ gotchas: [projectRule, previewRule, 'A template_id on add_campaign_step is rendered once at step creation, so later template edits do not change existing steps. Sequences need a separate enrollment/activation plan and approval. For a one-off segment broadcast use send_broadcast with copied html and subject, segment_id and optional send_at; approval is required.'],
161
161
  done_when: 'The template is saved, previewed and listed; if requested, its content is copied into an unactivated campaign step.',
162
162
  },
163
163
  {
@@ -67,6 +67,54 @@ function stripHtml(value) {
67
67
  .replace(/\s+/g, ' ')
68
68
  .trim();
69
69
  }
70
+ // One render path for send_transactional, send_awaiting_reply, and add_campaign_step.
71
+ async function renderStoredCampaignTemplate(args) {
72
+ const { data: template, error: templateError } = await db()
73
+ .from('campaign_templates')
74
+ .select('id, subject_hint, body_html, body_text, format')
75
+ .eq('id', args.templateId)
76
+ .eq('project_id', args.projectId)
77
+ .maybeSingle();
78
+ if (templateError)
79
+ return { ok: false, reason: 'lookup_failed', dbError: templateError };
80
+ if (!template)
81
+ return { ok: false, reason: 'not_found' };
82
+ const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', args.projectId).maybeSingle();
83
+ const projectBrand = (project?.brand_settings ?? null);
84
+ const siteBrand = (args.site.brand_voice ?? null);
85
+ const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
86
+ const brandVars = resolveBrandTokens(mergedBrand, args.site.name || undefined, { address: args.site.physical_address ?? undefined });
87
+ const brandWarningCodes = brandWarnings(mergedBrand);
88
+ const vars = args.templateVars ?? {};
89
+ const renderVars = { ...brandVars, ...vars };
90
+ const subjectFallingBack = !args.subject?.trim();
91
+ const requiredVars = deriveTemplateVariables({
92
+ body_html: template.format === 'text' ? null : (template.body_html ?? ''),
93
+ body_text: template.format === 'text' ? (template.body_text ?? '') : null,
94
+ subject_hint: subjectFallingBack ? (template.subject_hint ?? '') : null,
95
+ });
96
+ const satisfied = new Set([
97
+ ...Object.keys(vars),
98
+ 'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
99
+ 'brand_logo_html', 'brand_font', 'brand_address',
100
+ ...Object.keys(NAME_FALLBACKS),
101
+ ]);
102
+ const missing = requiredVars.filter((name) => !satisfied.has(name));
103
+ if (missing.length)
104
+ return { ok: false, reason: 'missing_vars', missing, brandWarningCodes };
105
+ let html;
106
+ let text;
107
+ if (template.format === 'text') {
108
+ text = renderTemplateVars(template.body_text ?? '', renderVars, { brandTokens: brandVars });
109
+ html = undefined;
110
+ }
111
+ else {
112
+ html = renderTemplateVars(template.body_html ?? '', renderVars, { brandTokens: brandVars });
113
+ text = args.callerText ?? stripHtml(html);
114
+ }
115
+ const subject = args.subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
116
+ return { ok: true, subject, html, text, brandWarningCodes };
117
+ }
70
118
  function normalizeTriggerValue(value) {
71
119
  if (typeof value !== 'string')
72
120
  return null;
@@ -974,14 +1022,16 @@ export function registerCampaignTools(server) {
974
1022
  idempotentHint: false,
975
1023
  openWorldHint: false,
976
1024
  },
977
- description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content. To use a stored template, call get_template and copy subject_hint into subject and body_html into body_html (and body_text if available). This tool accepts no template_id or template_vars; those response fields are reserved here and are not set by this tool. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
1025
+ description: 'Add an email step to a campaign. Requires a non-blank subject and body_html, body_text, or blocks that render to content, unless template_id is set. Optionally render from a template via template_id. When template_id is set without body content, the template is rendered once into subject and body_html. For A/B testing, add multiple steps with the same step_order but different variant_label values.',
978
1026
  inputSchema: z.object({
979
1027
  campaign_id: z.string().uuid(),
980
1028
  step_order: z.number().int().min(0).describe('Position in sequence (0 = first)'),
981
- subject: z.string().min(1),
1029
+ subject: z.string().min(1).optional().describe('Email subject. Required unless template_id supplies a subject_hint.'),
982
1030
  body_html: z.string().optional(),
983
1031
  body_text: z.string().optional(),
984
1032
  blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Optional structured Composer blocks that produced body_html.'),
1033
+ template_id: z.string().uuid().optional().describe('campaign_templates row to render into this step. When set, body_html/blocks are not required; explicit body_html/body_text/blocks win over the template body.'),
1034
+ template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
985
1035
  delay_days: z.number().int().min(0).optional().default(0),
986
1036
  delay_hours: z.number().int().min(0).max(23).optional().default(0),
987
1037
  variant_label: z.string().optional().default('A').describe('Variant label for A/B testing (default: A). Use B, C, etc. for additional variants.'),
@@ -994,7 +1044,7 @@ export function registerCampaignTools(server) {
994
1044
  skip_if_event: z.string().trim().min(1).optional().describe('Skip this step at send time if the contact already has an event with this name; the journey continues.'),
995
1045
  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.'),
996
1046
  }).refine(absencePairValid, { message: absencePairError }),
997
- }, 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_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property }) => {
1047
+ }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, template_id, template_vars, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_events, send_only_if_events, send_only_if_events_mode, send_only_if_no_event, send_only_if_no_event_days, skip_if_property, send_only_if_property }) => {
998
1048
  try {
999
1049
  const absence = validateStepAbsenceRule({ send_only_if_no_event, send_only_if_no_event_days });
1000
1050
  if (!absence.ok)
@@ -1005,22 +1055,29 @@ export function registerCampaignTools(server) {
1005
1055
  const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
1006
1056
  if (!properties.ok)
1007
1057
  return j({ error: properties.error, code: "INVALID_INPUT" });
1058
+ if (template_id && body_html !== undefined) {
1059
+ return j({ error: "Provide either 'template_id' or 'body_html', not both", code: 'INVALID_INPUT' });
1060
+ }
1008
1061
  const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
1009
1062
  const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
1010
- if (!subject.trim())
1011
- return j({ error: 'A step needs a non-blank subject.', code: 'validation_failed' });
1012
- const hasBody = body_html?.trim() || body_text?.trim()
1063
+ const renderingTemplate = Boolean(template_id) && body_html === undefined && body_text === undefined && blocks === undefined;
1064
+ const explicitHasBody = () => Boolean(body_html?.trim() || body_text?.trim()
1013
1065
  || htmlFromBlocks?.replace(/<[^>]*>/g, '').trim()
1014
- || normalizedBlocks?.some((block) => block.type === 'image' && block.url);
1015
- if (!hasBody) {
1016
- return j({ error: 'A step needs body_html or body_text (or blocks that render to content).', code: 'validation_failed' });
1066
+ || normalizedBlocks?.some((block) => block.type === 'image' && block.url));
1067
+ if (!renderingTemplate) {
1068
+ if (!subject?.trim())
1069
+ return j({ error: 'A step needs a non-blank subject.', code: 'validation_failed' });
1070
+ if (!explicitHasBody()) {
1071
+ return j({ error: 'A step needs body_html or body_text (or blocks that render to content).', code: 'validation_failed' });
1072
+ }
1017
1073
  }
1074
+ const projectId = getProjectId();
1018
1075
  const { data: campaign, error: campaignError } = await db()
1019
1076
  .schema('email')
1020
1077
  .from('campaigns')
1021
- .select('id')
1078
+ .select('id, site_id')
1022
1079
  .eq('id', campaign_id)
1023
- .eq('project_id', getProjectId())
1080
+ .eq('project_id', projectId)
1024
1081
  .maybeSingle();
1025
1082
  if (campaignError)
1026
1083
  return dbErrorResult(campaignError);
@@ -1028,24 +1085,81 @@ export function registerCampaignTools(server) {
1028
1085
  return { ...j({ error: 'Campaign not found.', code: 'not_found' }), isError: true };
1029
1086
  const renderVars = {};
1030
1087
  if (survey_id) {
1031
- const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
1088
+ const surveyLink = await resolveSurveyShortLink(db(), projectId, survey_id);
1032
1089
  if (!surveyLink)
1033
1090
  return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
1034
1091
  renderVars.survey_link = surveyLink;
1035
1092
  }
1036
- const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
1037
- const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
1038
- const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
1093
+ let bakedSubject = subject;
1094
+ let bakedHtml = body_html;
1095
+ let bakedText = body_text;
1096
+ if (renderingTemplate && template_id) {
1097
+ let siteForTemplate = {};
1098
+ if (campaign.site_id) {
1099
+ const { data: site, error: siteError } = await db()
1100
+ .from('sites')
1101
+ .select('name, brand_voice, physical_address')
1102
+ .eq('id', campaign.site_id)
1103
+ .eq('project_id', projectId)
1104
+ .maybeSingle();
1105
+ if (siteError)
1106
+ return dbErrorResult(siteError);
1107
+ if (site)
1108
+ siteForTemplate = site;
1109
+ }
1110
+ const rendered = await renderStoredCampaignTemplate({
1111
+ templateId: template_id,
1112
+ templateVars: { ...(template_vars ?? {}), ...renderVars },
1113
+ subject,
1114
+ projectId,
1115
+ site: siteForTemplate,
1116
+ });
1117
+ if (!rendered.ok) {
1118
+ if (rendered.reason === 'lookup_failed')
1119
+ return dbErrorResult(rendered.dbError);
1120
+ if (rendered.reason === 'not_found')
1121
+ return { ...j({ error: 'Template not found.', code: 'not_found' }), isError: true };
1122
+ return j({ error: `Missing required template variable(s): ${rendered.missing.join(', ')}`, code: 'validation_failed' });
1123
+ }
1124
+ bakedSubject = rendered.subject;
1125
+ bakedHtml = rendered.html === undefined ? undefined : stripActiveEmailContent(rendered.html).html;
1126
+ bakedText = rendered.text;
1127
+ if (!bakedSubject?.trim())
1128
+ return j({ error: 'A step needs a non-blank subject.', code: 'validation_failed' });
1129
+ const bakedHasBody = bakedHtml?.trim() || bakedText?.trim();
1130
+ if (!bakedHasBody) {
1131
+ return j({ error: 'A step needs body_html or body_text (or blocks that render to content).', code: 'validation_failed' });
1132
+ }
1133
+ }
1134
+ else if (template_id) {
1135
+ const { data: template, error: templateError } = await db()
1136
+ .from('campaign_templates')
1137
+ .select('id')
1138
+ .eq('id', template_id)
1139
+ .eq('project_id', projectId)
1140
+ .maybeSingle();
1141
+ if (templateError)
1142
+ return dbErrorResult(templateError);
1143
+ if (!template)
1144
+ return { ...j({ error: 'Template not found.', code: 'not_found' }), isError: true };
1145
+ }
1146
+ const renderedSubject = survey_id && bakedSubject !== undefined ? renderTemplateVars(bakedSubject, renderVars) : bakedSubject;
1147
+ const renderedHtml = renderingTemplate
1148
+ ? (survey_id && bakedHtml !== undefined ? renderTemplateVars(bakedHtml, renderVars) : bakedHtml)
1149
+ : (body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks);
1150
+ const renderedText = renderingTemplate
1151
+ ? (survey_id && bakedText !== undefined ? renderTemplateVars(bakedText, renderVars) : bakedText)
1152
+ : (body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text);
1039
1153
  const { data, error } = await withCreatedItems('campaign_steps', db()
1040
1154
  .from('campaign_steps')
1041
- .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, ...events.rules, ...absence.rules, ...properties.rules })
1155
+ .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', template_id: template_id ?? null, template_vars: template_vars ?? null, skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...events.rules, ...absence.rules, ...properties.rules })
1042
1156
  .select()
1043
1157
  .single());
1044
1158
  if (error)
1045
1159
  return dbErrorResult(error);
1046
1160
  await recordBrandAssetUsage({
1047
1161
  client: db(),
1048
- projectId: getProjectId(),
1162
+ projectId,
1049
1163
  source: 'campaign_step',
1050
1164
  resourceId: data.id,
1051
1165
  html: renderedHtml,
@@ -1053,7 +1167,7 @@ export function registerCampaignTools(server) {
1053
1167
  }).catch((err) => {
1054
1168
  console.warn('Failed to record brand asset usage for MCP campaign step:', err);
1055
1169
  });
1056
- await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
1170
+ await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules, template_id: template_id ?? null } });
1057
1171
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1058
1172
  }
1059
1173
  catch (error) {
@@ -2708,53 +2822,31 @@ Do not include body_html in your response; Sendinel renders body_html from block
2708
2822
  let resolvedText = text;
2709
2823
  let brandWarningCodes = [];
2710
2824
  if (template_id) {
2711
- const { data: template, error: templateError } = await db()
2712
- .from('campaign_templates')
2713
- .select('id, subject_hint, body_html, body_text, format')
2714
- .eq('id', template_id)
2715
- .eq('project_id', projectId)
2716
- .maybeSingle();
2717
- if (templateError) {
2718
- console.error('MCP send template lookup failed:', templateError);
2719
- return { ...txt('Error: Internal server error'), ...sendError('internal_error') };
2825
+ const rendered = await renderStoredCampaignTemplate({
2826
+ templateId: template_id,
2827
+ templateVars: template_vars,
2828
+ subject,
2829
+ projectId,
2830
+ site,
2831
+ callerText: text,
2832
+ });
2833
+ if (!rendered.ok) {
2834
+ if (rendered.reason === 'lookup_failed') {
2835
+ console.error('MCP send template lookup failed:', rendered.dbError);
2836
+ return { ...txt('Error: Internal server error'), ...sendError('internal_error') };
2837
+ }
2838
+ if (rendered.reason === 'not_found')
2839
+ return txt('Error: Template not found');
2840
+ if (rendered.brandWarningCodes.length)
2841
+ console.warn('MCP send brand warnings', { projectId, site_id, warnings: rendered.brandWarningCodes });
2842
+ return txt(`Error: Missing required template variable(s): ${rendered.missing.join(', ')}`);
2720
2843
  }
2721
- if (!template)
2722
- return txt('Error: Template not found');
2723
- const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
2724
- const projectBrand = (project?.brand_settings ?? null);
2725
- const siteBrand = (site.brand_voice ?? null);
2726
- const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
2727
- const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined, { address: site.physical_address ?? undefined });
2728
- brandWarningCodes = brandWarnings(mergedBrand);
2844
+ brandWarningCodes = rendered.brandWarningCodes;
2729
2845
  if (brandWarningCodes.length)
2730
2846
  console.warn('MCP send brand warnings', { projectId, site_id, warnings: brandWarningCodes });
2731
- const vars = template_vars ?? {};
2732
- const renderVars = { ...brandVars, ...vars };
2733
- const subjectFallingBack = !subject?.trim();
2734
- const requiredVars = deriveTemplateVariables({
2735
- body_html: template.format === 'text' ? null : (template.body_html ?? ''),
2736
- body_text: template.format === 'text' ? (template.body_text ?? '') : null,
2737
- subject_hint: subjectFallingBack ? (template.subject_hint ?? '') : null,
2738
- });
2739
- const satisfied = new Set([
2740
- ...Object.keys(vars),
2741
- 'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
2742
- 'brand_logo_html', 'brand_font', 'brand_address',
2743
- ...Object.keys(NAME_FALLBACKS),
2744
- ]);
2745
- const missing = requiredVars.filter((name) => !satisfied.has(name));
2746
- if (missing.length) {
2747
- return txt(`Error: Missing required template variable(s): ${missing.join(', ')}`);
2748
- }
2749
- if (template.format === 'text') {
2750
- resolvedText = renderTemplateVars(template.body_text ?? '', renderVars, { brandTokens: brandVars });
2751
- resolvedHtml = undefined;
2752
- }
2753
- else {
2754
- resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars, { brandTokens: brandVars });
2755
- resolvedText = text ?? stripHtml(resolvedHtml);
2756
- }
2757
- resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
2847
+ resolvedSubject = rendered.subject;
2848
+ resolvedHtml = rendered.html;
2849
+ resolvedText = rendered.text;
2758
2850
  }
2759
2851
  if (resolvedHtml !== undefined)
2760
2852
  resolvedHtml = stripActiveEmailContent(resolvedHtml).html;
@@ -2904,51 +2996,29 @@ Do not include body_html in your response; Sendinel renders body_html from block
2904
2996
  let resolvedText = text;
2905
2997
  let brandWarningCodes = [];
2906
2998
  if (template_id) {
2907
- const { data: template, error: templateError } = await db()
2908
- .from('campaign_templates')
2909
- .select('id, subject_hint, body_html, body_text, format')
2910
- .eq('id', template_id)
2911
- .eq('project_id', projectId)
2912
- .maybeSingle();
2913
- if (templateError)
2914
- return txt(`Error: ${dbToolError(templateError).error}`);
2915
- if (!template)
2916
- return txt('Error: Template not found');
2917
- const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
2918
- const projectBrand = (project?.brand_settings ?? null);
2919
- const siteBrand = (site.brand_voice ?? null);
2920
- const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
2921
- const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined, { address: site.physical_address ?? undefined });
2922
- brandWarningCodes = brandWarnings(mergedBrand);
2923
- if (brandWarningCodes.length)
2924
- console.warn('MCP send brand warnings', { projectId, site_id, warnings: brandWarningCodes });
2925
- const vars = template_vars ?? {};
2926
- const renderVars = { ...brandVars, ...vars };
2927
- const subjectFallingBack = !subject?.trim();
2928
- const requiredVars = deriveTemplateVariables({
2929
- body_html: template.format === 'text' ? null : (template.body_html ?? ''),
2930
- body_text: template.format === 'text' ? (template.body_text ?? '') : null,
2931
- subject_hint: subjectFallingBack ? (template.subject_hint ?? '') : null,
2999
+ const rendered = await renderStoredCampaignTemplate({
3000
+ templateId: template_id,
3001
+ templateVars: template_vars,
3002
+ subject,
3003
+ projectId,
3004
+ site,
3005
+ callerText: text,
2932
3006
  });
2933
- const satisfied = new Set([
2934
- ...Object.keys(vars),
2935
- 'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
2936
- 'brand_logo_html', 'brand_font', 'brand_address',
2937
- ...Object.keys(NAME_FALLBACKS),
2938
- ]);
2939
- const missing = requiredVars.filter((name) => !satisfied.has(name));
2940
- if (missing.length) {
2941
- return txt(`Error: Missing required template variable(s): ${missing.join(', ')}`);
3007
+ if (!rendered.ok) {
3008
+ if (rendered.reason === 'lookup_failed')
3009
+ return txt(`Error: ${dbToolError(rendered.dbError).error}`);
3010
+ if (rendered.reason === 'not_found')
3011
+ return txt('Error: Template not found');
3012
+ if (rendered.brandWarningCodes.length)
3013
+ console.warn('MCP send brand warnings', { projectId, site_id, warnings: rendered.brandWarningCodes });
3014
+ return txt(`Error: Missing required template variable(s): ${rendered.missing.join(', ')}`);
2942
3015
  }
2943
- if (template.format === 'text') {
2944
- resolvedText = renderTemplateVars(template.body_text ?? '', renderVars, { brandTokens: brandVars });
2945
- resolvedHtml = undefined;
2946
- }
2947
- else {
2948
- resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars, { brandTokens: brandVars });
2949
- resolvedText = text ?? stripHtml(resolvedHtml);
2950
- }
2951
- resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
3016
+ brandWarningCodes = rendered.brandWarningCodes;
3017
+ if (brandWarningCodes.length)
3018
+ console.warn('MCP send brand warnings', { projectId, site_id, warnings: brandWarningCodes });
3019
+ resolvedSubject = rendered.subject;
3020
+ resolvedHtml = rendered.html;
3021
+ resolvedText = rendered.text;
2952
3022
  }
2953
3023
  if (resolvedHtml !== undefined)
2954
3024
  resolvedHtml = stripActiveEmailContent(resolvedHtml).html;
@@ -178,6 +178,7 @@ export function registerConnectorTools(server) {
178
178
  return j({ error: tableError instanceof Error ? dbToolError(tableError).error : 'Could not list database tables' });
179
179
  }
180
180
  });
181
+ // No approval preflight (#4550): this tool has no target row to reject.
181
182
  server.registerTool('install_connector_schema', {
182
183
  title: 'Install Connector Schema',
183
184
  annotations: {
@@ -195,6 +196,7 @@ export function registerConnectorTools(server) {
195
196
  const result = await callConnectorApi('/api/dashboard/connector/schema/install', { method: 'POST' });
196
197
  return j(result.ok ? result.data : { error: result.error });
197
198
  });
199
+ // No approval preflight (#4550): this tool has no target row to reject.
198
200
  server.registerTool('install_connector_view', {
199
201
  title: 'Install Connector Live View',
200
202
  annotations: {
@@ -223,6 +225,7 @@ export function registerConnectorTools(server) {
223
225
  });
224
226
  return j(result.ok ? result.data : { error: result.error });
225
227
  });
228
+ // No approval preflight (#4550): mapping is validated by the dashboard route only after this gate.
226
229
  server.registerTool('set_connector_mapping', {
227
230
  title: 'Set Connector Mapping',
228
231
  annotations: {
@@ -271,6 +274,7 @@ export function registerConnectorTools(server) {
271
274
  });
272
275
  return j(result.ok ? result.data : { error: result.error });
273
276
  });
277
+ // No approval preflight (#4550): this tool has no target row to reject.
274
278
  server.registerTool('resync_connector', {
275
279
  title: 'Resync Connector Contacts',
276
280
  annotations: {
@@ -2437,6 +2437,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2437
2437
  return dbErrorResult(countError);
2438
2438
  return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
2439
2439
  });
2440
+ // No approval preflight (#4550): nothing here rejects a missing target. An unknown site_id
2441
+ // returns an empty inactive list, and the approval binds that computed preview.
2440
2442
  server.registerTool('identify_inactive', {
2441
2443
  title: 'Identify Inactive',
2442
2444
  annotations: {
@@ -2679,6 +2681,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2679
2681
  // total_groups counts every group found; groups_truncated says when limit cut the list.
2680
2682
  return j({ duplicate_groups: result, total_groups: groups.length, groups_truncated: groups.length > result.length, coverage });
2681
2683
  });
2684
+ // No approval preflight (#4550): a missing target or source is not rejected. The preview uses
2685
+ // target?.email, and the post-approval path calls rpc merge_subscribers with no not-found refusal.
2682
2686
  server.registerTool('merge_subscribers', {
2683
2687
  title: 'Merge Subscribers',
2684
2688
  annotations: {
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { adminDb, db } from '../db.js';
5
5
  import { decrypt, encrypt } from '../crypto.js';
6
6
  import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
7
- import { approvalPageUrl, assertChannelCanExecute, checkApproval, currentChannelContext, currentExecutingApprovalId, requestApproval, withApproval } from '../lib/action-approvals.js';
7
+ import { approvalPageUrl, assertChannelCanExecute, checkApproval, currentChannelContext, currentExecutingApprovalId, registerApprovalPreflight, requestApproval, withApproval } from '../lib/action-approvals.js';
8
8
  import { getProjectId } from '../project.js';
9
9
  import { logAudit } from '../audit.js';
10
10
  import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
@@ -161,6 +161,82 @@ async function getPlatformProjectContext(client = adminDb(), orgId) {
161
161
  throw new Error('organization has no Supabase-auth user to own the connection');
162
162
  return { projectId, orgId: project.org_id, userId: userId };
163
163
  }
164
+ /** Shared by the handler and the request-only preflight so the refusal text cannot drift. */
165
+ async function lookupSavedCredential(credentialId, orgId) {
166
+ const parsed = parseSavedCredentialId(credentialId);
167
+ if (!parsed)
168
+ return { ok: false, result: j({ error: 'Invalid credential_id' }) };
169
+ const client = adminDb();
170
+ let context;
171
+ try {
172
+ context = await getPlatformProjectContext(client, orgId);
173
+ }
174
+ catch (error) {
175
+ // Same catch body the handler used. These are business refusals, not a preflight outage.
176
+ return {
177
+ ok: false,
178
+ result: j({ error: error instanceof Error ? dbToolError(error).error : 'Failed to delete provider credential' }),
179
+ };
180
+ }
181
+ const table = parsed.source === 'platform' ? 'platform_connections' : 'import_jobs';
182
+ const columns = parsed.source === 'platform'
183
+ ? 'id, platform, display_name, status, is_active, created_at'
184
+ : 'id, provider, status, created_at, completed_at';
185
+ const { data: credential, error: credentialErr } = await client
186
+ .schema('email')
187
+ .from(table)
188
+ .select(columns)
189
+ .eq('id', parsed.rowId)
190
+ .eq('org_id', context.orgId)
191
+ .eq('project_id', context.projectId)
192
+ .maybeSingle();
193
+ if (credentialErr)
194
+ return { ok: false, result: j(dbToolError(credentialErr)), dbError: credentialErr };
195
+ if (!credential)
196
+ return { ok: false, result: j({ error: 'Credential not found' }) };
197
+ return { ok: true, parsed, credential, context, client };
198
+ }
199
+ async function findActiveCustomWebhook(connectionId) {
200
+ const client = adminDb();
201
+ const projectId = getProjectId();
202
+ const { data: connection, error: lookupError } = await client
203
+ .schema('email')
204
+ .from('platform_connections')
205
+ .select('id, project_id, display_name, config')
206
+ .eq('id', connectionId)
207
+ .eq('project_id', projectId)
208
+ .eq('platform', 'custom')
209
+ .eq('is_active', true)
210
+ .maybeSingle();
211
+ return { client, projectId, connection, lookupError };
212
+ }
213
+ // Confirmed is not consulted. The outer request-only gate runs before the handler's
214
+ // preview, so the target must exist in this project on the first call as well as on replay.
215
+ async function deleteProviderCredentialPreflight(params) {
216
+ const credentialId = typeof params.credential_id === 'string' ? params.credential_id : '';
217
+ const orgId = typeof params.org_id === 'string' ? params.org_id : undefined;
218
+ const found = await lookupSavedCredential(credentialId, orgId);
219
+ if (!found.ok) {
220
+ if (found.dbError)
221
+ throw found.dbError;
222
+ return found.result;
223
+ }
224
+ return undefined;
225
+ }
226
+ async function rotateCustomWebhookSecretPreflight(params) {
227
+ const connectionId = typeof params.connection_id === 'string' ? params.connection_id : '';
228
+ const { connection, lookupError } = await findActiveCustomWebhook(connectionId);
229
+ if (lookupError)
230
+ throw lookupError;
231
+ if (!connection)
232
+ return j({ error: 'Custom webhook not found' });
233
+ return undefined;
234
+ }
235
+ // Partial test doubles of action-approvals omit this export. Production always provides it.
236
+ if (typeof registerApprovalPreflight === 'function') {
237
+ registerApprovalPreflight('delete_provider_credential', deleteProviderCredentialPreflight);
238
+ registerApprovalPreflight('rotate_custom_webhook_secret', rotateCustomWebhookSecretPreflight);
239
+ }
164
240
  function createCampaignMigrationTotals(options) {
165
241
  return {
166
242
  contacts: { total: options.includeContacts ? 1 : 0, processed: 0 },
@@ -401,27 +477,10 @@ export function registerDataTools(server) {
401
477
  },
402
478
  }, async ({ org_id, credential_id, confirmed, approval_id }) => {
403
479
  try {
404
- const parsed = parseSavedCredentialId(credential_id);
405
- if (!parsed)
406
- return j({ error: 'Invalid credential_id' });
407
- const client = adminDb();
408
- const context = await getPlatformProjectContext(client, org_id);
409
- const table = parsed.source === 'platform' ? 'platform_connections' : 'import_jobs';
410
- const columns = parsed.source === 'platform'
411
- ? 'id, platform, display_name, status, is_active, created_at'
412
- : 'id, provider, status, created_at, completed_at';
413
- const { data: credential, error: credentialErr } = await client
414
- .schema('email')
415
- .from(table)
416
- .select(columns)
417
- .eq('id', parsed.rowId)
418
- .eq('org_id', context.orgId)
419
- .eq('project_id', context.projectId)
420
- .maybeSingle();
421
- if (credentialErr)
422
- return j(dbToolError(credentialErr));
423
- if (!credential)
424
- return j({ error: 'Credential not found' });
480
+ const found = await lookupSavedCredential(credential_id, org_id);
481
+ if (!found.ok)
482
+ return found.result;
483
+ const { parsed, credential, context, client } = found;
425
484
  const provider = parsed.source === 'platform' ? credential.platform : credential.provider;
426
485
  const approvalParams = {
427
486
  credential_id,
@@ -870,21 +929,12 @@ export function registerDataTools(server) {
870
929
  },
871
930
  }, async ({ connection_id, confirmed, approval_id }) => {
872
931
  try {
873
- const client = adminDb();
874
- const projectId = getProjectId();
875
- const { data: connection, error: lookupError } = await client
876
- .schema('email')
877
- .from('platform_connections')
878
- .select('id, project_id, display_name, config')
879
- .eq('id', connection_id)
880
- .eq('project_id', projectId)
881
- .eq('platform', 'custom')
882
- .eq('is_active', true)
883
- .maybeSingle();
884
- if (lookupError)
885
- return j(dbToolError(lookupError));
886
- if (!connection)
932
+ const lookedUp = await findActiveCustomWebhook(connection_id);
933
+ if (lookedUp.lookupError)
934
+ return j(dbToolError(lookedUp.lookupError));
935
+ if (!lookedUp.connection)
887
936
  return j({ error: 'Custom webhook not found' });
937
+ const { client, projectId, connection } = lookedUp;
888
938
  if (!confirmed) {
889
939
  return j({
890
940
  confirmation_required: true,
@@ -3,11 +3,81 @@ import { z } from 'zod';
3
3
  import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { logAudit } from '../audit.js';
6
- import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
6
+ import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, registerApprovalPreflight } from '../lib/action-approvals.js';
7
7
  import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
8
8
  import { subscriberBlockers } from '../lib/delete-blockers.js';
9
9
  const txt = (text) => ({ content: [{ type: 'text', text }] });
10
10
  const j = (data) => txt(JSON.stringify(data, null, 2));
11
+ async function findSubscriberForDeletion(contactId) {
12
+ const { data, error } = await db()
13
+ .from('subscribers')
14
+ .select('id, email')
15
+ .eq('id', contactId)
16
+ .eq('project_id', getProjectId())
17
+ .maybeSingle();
18
+ return { contact: (data ?? null), error };
19
+ }
20
+ // A database error is not "missing". The handler still maps it to the old not-found text;
21
+ // the preflight throws so the registration boundary reports internal_error and writes no approval.
22
+ async function deleteSubscriberDataPreflight(params) {
23
+ const contactId = typeof params.contact_id === 'string' ? params.contact_id : '';
24
+ const { contact, error } = await findSubscriberForDeletion(contactId);
25
+ if (error)
26
+ throw error;
27
+ if (!contact)
28
+ return txt('Contact not found or does not belong to this project.');
29
+ return undefined;
30
+ }
31
+ async function loadCcpaRequest(requestId) {
32
+ const projectId = getProjectId();
33
+ const { data, error } = await db()
34
+ .from('compliance_requests')
35
+ .select('id, email, request_type, status, expires_at')
36
+ .eq('id', requestId)
37
+ .eq('project_id', projectId)
38
+ .eq('regulation', 'ccpa')
39
+ .maybeSingle();
40
+ return { projectId, request: (data ?? null), error };
41
+ }
42
+ function ccpaNotActionable(request) {
43
+ if (!request)
44
+ return 'CCPA request not found or does not belong to this project.';
45
+ if (request.status === 'completed' || request.status === 'expired') {
46
+ return `Request is already "${request.status}" and cannot be actioned further.`;
47
+ }
48
+ return undefined;
49
+ }
50
+ function ccpaNotReadyToComplete(request) {
51
+ if (request.status !== 'verified') {
52
+ return `Request must be "verified" before it can be completed (currently "${request.status}"). Call this tool with status: "verified" first.`;
53
+ }
54
+ return undefined;
55
+ }
56
+ // Only completed + delete requests the hand-written approval. status:'verified' updates the row
57
+ // with no approval, and a non-delete completion does too. approvalSatisfied can skip the gate
58
+ // only after those refusals, so this preflight refuses the same rows the handler refuses first.
59
+ async function actionCcpaRequestPreflight(params) {
60
+ if (params.status !== 'completed')
61
+ return undefined;
62
+ const requestId = typeof params.request_id === 'string' ? params.request_id : '';
63
+ const { request, error } = await loadCcpaRequest(requestId);
64
+ if (error)
65
+ throw error;
66
+ const closed = ccpaNotActionable(request);
67
+ if (closed)
68
+ return txt(closed);
69
+ const notReady = ccpaNotReadyToComplete(request);
70
+ if (notReady)
71
+ return txt(notReady);
72
+ if (request.request_type !== 'delete')
73
+ return undefined;
74
+ return undefined;
75
+ }
76
+ // Partial test doubles of action-approvals omit this export. Production always provides it.
77
+ if (typeof registerApprovalPreflight === 'function') {
78
+ registerApprovalPreflight('delete_subscriber_data', deleteSubscriberDataPreflight);
79
+ registerApprovalPreflight('action_ccpa_request', actionCcpaRequestPreflight);
80
+ }
11
81
  export function registerGdprTools(server) {
12
82
  server.registerTool('delete_subscriber_data', {
13
83
  title: 'Delete Subscriber Data',
@@ -24,13 +94,9 @@ export function registerGdprTools(server) {
24
94
  approval_id: z.string().uuid().optional().describe('Required — get this by calling once without it, then have a human approve via dashboard.'),
25
95
  },
26
96
  }, async ({ contact_id, reason, approval_id }) => {
27
- // Verify contact belongs to this project (defense in depth)
28
- const { data: contact } = await db()
29
- .from('subscribers')
30
- .select('id, email')
31
- .eq('id', contact_id)
32
- .eq('project_id', getProjectId())
33
- .maybeSingle();
97
+ // Verify contact belongs to this project (defense in depth). A lookup error stays
98
+ // the historical not-found text; the registered preflight reports it as internal_error.
99
+ const { contact } = await findSubscriberForDeletion(contact_id);
34
100
  if (!contact)
35
101
  return txt('Contact not found or does not belong to this project.');
36
102
  const approvalParams = { contact_id, email: contact.email, reason };
@@ -132,21 +198,14 @@ export function registerGdprTools(server) {
132
198
  approval_id: z.string().uuid().optional().describe('Required to complete a delete request after a human approves the summary in the dashboard.'),
133
199
  },
134
200
  }, async ({ request_id, status, approval_id }) => {
135
- const projectId = getProjectId();
136
- const { data: request, error: fetchErr } = await db()
137
- .from('compliance_requests')
138
- .select('id, email, request_type, status, expires_at')
139
- .eq('id', request_id)
140
- .eq('project_id', projectId)
141
- .eq('regulation', 'ccpa')
142
- .maybeSingle();
201
+ const { projectId, request, error: fetchErr } = await loadCcpaRequest(request_id);
143
202
  if (fetchErr)
144
203
  return txt(`Error: ${dbToolError(fetchErr).error}`);
204
+ const closed = ccpaNotActionable(request);
205
+ if (closed)
206
+ return txt(closed);
145
207
  if (!request)
146
208
  return txt('CCPA request not found or does not belong to this project.');
147
- if (request.status === 'completed' || request.status === 'expired') {
148
- return txt(`Request is already "${request.status}" and cannot be actioned further.`);
149
- }
150
209
  if (status === 'verified') {
151
210
  if (request.status !== 'pending') {
152
211
  return txt(`Request must be "pending" to verify (currently "${request.status}").`);
@@ -177,9 +236,9 @@ export function registerGdprTools(server) {
177
236
  });
178
237
  return j({ ok: true, request: data });
179
238
  }
180
- if (request.status !== 'verified') {
181
- return txt(`Request must be "verified" before it can be completed (currently "${request.status}"). Call this tool with status: "verified" first.`);
182
- }
239
+ const notReady = ccpaNotReadyToComplete(request);
240
+ if (notReady)
241
+ return txt(notReady);
183
242
  if (request.request_type === 'delete') {
184
243
  const approvalParams = {
185
244
  request_id,
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { logAudit } from '../audit.js';
4
4
  import { adminDb, db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
- import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
6
+ import { assertChannelCanExecute, checkApproval, currentChannelContext, registerApprovalPreflight, requestApproval, withApproval } from '../lib/action-approvals.js';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { dbToolError } from '../lib/db-error.js';
9
9
  import { checkPlanLimit } from '../plan-limits.js';
@@ -62,7 +62,7 @@ async function resolveCurrentOrgId(requestedOrgId) {
62
62
  .eq('id', getProjectId())
63
63
  .maybeSingle();
64
64
  if (error)
65
- return { ok: false, ...dbToolError(error) };
65
+ return { ok: false, ...dbToolError(error), cause: error };
66
66
  if (!project?.org_id)
67
67
  return { ok: false, error: 'Project has no organization', code: 'NO_ORG' };
68
68
  if (requestedOrgId && requestedOrgId !== project.org_id) {
@@ -70,6 +70,46 @@ async function resolveCurrentOrgId(requestedOrgId) {
70
70
  }
71
71
  return { ok: true, orgId: project.org_id };
72
72
  }
73
+ /** Shared by the handler and the request-only preflight so the refusal text cannot drift. */
74
+ async function lookupOrgMember(userId, orgId) {
75
+ const context = await resolveCurrentOrgId(orgId);
76
+ if (!context.ok) {
77
+ const dbError = 'cause' in context ? context.cause : undefined;
78
+ return { ok: false, result: j({ error: context.error, code: context.code }), dbError };
79
+ }
80
+ const { data: member, error: memberErr } = await db()
81
+ .from('org_members')
82
+ .select('id, user_id, role')
83
+ .eq('org_id', context.orgId)
84
+ .eq('user_id', userId)
85
+ .maybeSingle();
86
+ if (memberErr)
87
+ return { ok: false, result: j(dbToolError(memberErr)), dbError: memberErr };
88
+ if (!member)
89
+ return { ok: false, result: j({ error: 'Member not found', code: 'NOT_FOUND' }) };
90
+ return { ok: true, orgId: context.orgId, member: member };
91
+ }
92
+ function preflightOrgId(params) {
93
+ return typeof params.org_id === 'string' ? params.org_id : undefined;
94
+ }
95
+ // Existence and org ownership are checked whether or not confirmed is present (schema default
96
+ // false). The last-owner refusal runs only after confirmed=true in the handler. Applying it
97
+ // here would refuse an unconfirmed request-only call that currently receives an approval request.
98
+ async function teamMemberTargetPreflight(params) {
99
+ const userId = typeof params.user_id === 'string' ? params.user_id : '';
100
+ const found = await lookupOrgMember(userId, preflightOrgId(params));
101
+ if (!found.ok) {
102
+ if (found.dbError)
103
+ throw found.dbError;
104
+ return found.result;
105
+ }
106
+ return undefined;
107
+ }
108
+ // Partial test doubles of action-approvals omit this export. Production always provides it.
109
+ if (typeof registerApprovalPreflight === 'function') {
110
+ registerApprovalPreflight('remove_team_member', teamMemberTargetPreflight);
111
+ registerApprovalPreflight('set_team_member_role', teamMemberTargetPreflight);
112
+ }
73
113
  function normalizeHost(host) {
74
114
  if (!host)
75
115
  return null;
@@ -208,33 +248,24 @@ export function registerOrgTools(server) {
208
248
  approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
209
249
  },
210
250
  }, async ({ user_id, org_id, confirmed, approval_id }) => {
211
- const context = await resolveCurrentOrgId(org_id);
212
- if (!context.ok)
213
- return j({ error: context.error, code: context.code });
214
- const { data: member, error: memberErr } = await db()
215
- .from('org_members')
216
- .select('id, user_id, role')
217
- .eq('org_id', context.orgId)
218
- .eq('user_id', user_id)
219
- .maybeSingle();
220
- if (memberErr)
221
- return j(dbToolError(memberErr));
222
- if (!member)
223
- return j({ error: 'Member not found', code: 'NOT_FOUND' });
224
- const approvalParams = { user_id, org_id: context.orgId };
251
+ const found = await lookupOrgMember(user_id, org_id);
252
+ if (!found.ok)
253
+ return found.result;
254
+ const { orgId, member } = found;
255
+ const approvalParams = { user_id, org_id: orgId };
225
256
  if (!confirmed) {
226
257
  return j({
227
258
  confirmation_required: true,
228
259
  tool: 'remove_team_member',
229
260
  member: { user_id: member.user_id, role: member.role },
230
- message: `Removing this member (role: ${member.role}) revokes their access to org ${context.orgId}. Call again with confirmed=true to proceed.`,
261
+ message: `Removing this member (role: ${member.role}) revokes their access to org ${orgId}. Call again with confirmed=true to proceed.`,
231
262
  });
232
263
  }
233
264
  if (member.role === 'owner') {
234
265
  const { count } = await db()
235
266
  .from('org_members')
236
267
  .select('id', { count: 'exact', head: true })
237
- .eq('org_id', context.orgId)
268
+ .eq('org_id', orgId)
238
269
  .eq('role', 'owner');
239
270
  if ((count ?? 0) <= 1)
240
271
  return j({ error: 'Cannot remove the last owner', code: 'LAST_OWNER' });
@@ -257,7 +288,7 @@ export function registerOrgTools(server) {
257
288
  const { error } = await db()
258
289
  .from('org_members')
259
290
  .delete()
260
- .eq('org_id', context.orgId)
291
+ .eq('org_id', orgId)
261
292
  .eq('user_id', user_id);
262
293
  if (error)
263
294
  return j(dbToolError(error));
@@ -265,7 +296,7 @@ export function registerOrgTools(server) {
265
296
  action: 'remove_member',
266
297
  resourceType: 'org_member',
267
298
  resourceId: member.id,
268
- details: { org_id: context.orgId, removed_user: user_id, role: member.role },
299
+ details: { org_id: orgId, removed_user: user_id, role: member.role },
269
300
  }).catch(() => { });
270
301
  return j({ ok: true, removed_user_id: user_id });
271
302
  });
@@ -286,20 +317,11 @@ export function registerOrgTools(server) {
286
317
  approval_id: z.string().uuid().optional(),
287
318
  },
288
319
  }, async ({ user_id, role, org_id, confirmed, approval_id }) => {
289
- const context = await resolveCurrentOrgId(org_id);
290
- if (!context.ok)
291
- return j({ error: context.error, code: context.code });
292
- const { data: member, error: memberErr } = await db()
293
- .from('org_members')
294
- .select('id, user_id, role')
295
- .eq('org_id', context.orgId)
296
- .eq('user_id', user_id)
297
- .maybeSingle();
298
- if (memberErr)
299
- return j(dbToolError(memberErr));
300
- if (!member)
301
- return j({ error: 'Member not found', code: 'NOT_FOUND' });
302
- const approvalParams = { user_id, role, org_id: context.orgId };
320
+ const found = await lookupOrgMember(user_id, org_id);
321
+ if (!found.ok)
322
+ return found.result;
323
+ const { orgId, member } = found;
324
+ const approvalParams = { user_id, role, org_id: orgId };
303
325
  if (!confirmed) {
304
326
  return j({
305
327
  confirmation_required: true,
@@ -326,7 +348,7 @@ export function registerOrgTools(server) {
326
348
  const { data, error } = await db()
327
349
  .from('org_members')
328
350
  .update({ role })
329
- .eq('org_id', context.orgId)
351
+ .eq('org_id', orgId)
330
352
  .eq('user_id', user_id)
331
353
  .select('user_id, role')
332
354
  .maybeSingle();
@@ -336,7 +358,7 @@ export function registerOrgTools(server) {
336
358
  action: 'update_member_role',
337
359
  resourceType: 'org_member',
338
360
  resourceId: member.id,
339
- details: { org_id: context.orgId, user_id, new_role: role },
361
+ details: { org_id: orgId, user_id, new_role: role },
340
362
  }).catch(() => { });
341
363
  return j({ member: data });
342
364
  });
@@ -241,10 +241,10 @@ export function registerTemplateTools(server) {
241
241
  idempotentHint: false,
242
242
  openWorldHint: false,
243
243
  },
244
- description: 'Save an email template from supplied content. brief is a required description/label stored as brief_template, even with body_html; this tool performs no AI generation and consumes no AI credits. Use create_template_from_brief to AI-generate content using the site brand voice.',
244
+ description: 'Create a template record from content you supply. `brief` is a stored note and does NOT trigger AI generation or use credits; use create_template_from_brief to AI-generate.',
245
245
  inputSchema: {
246
246
  name: templateName,
247
- brief: z.string().min(1).describe('Required description/label; stored only, no AI generation or AI credits.'),
247
+ brief: z.string().min(1).describe('Stored as brief_template only. Does NOT trigger AI generation or use credits. Use create_template_from_brief to AI-generate.'),
248
248
  subject_hint: z.string().optional(),
249
249
  body_html: z.string().optional(),
250
250
  category: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.212",
3
+ "version": "1.0.214",
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",