@sendinel/mcp-server 1.0.179 → 1.0.181

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,8 +1,8 @@
1
- import { withApproval } from '../lib/action-approvals.js';
1
+ import { markNotExecuted, withApproval } from '../lib/action-approvals.js';
2
2
  import { z } from 'zod';
3
3
  import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
- import { dbToolError } from '../lib/db-error.js';
5
+ import { dbToolError, toolError } from '../lib/db-error.js';
6
6
  const txt = (text) => ({ content: [{ type: 'text', text }] });
7
7
  const j = (data) => txt(JSON.stringify(data, null, 2));
8
8
  export function registerAbTestingTools(server) {
@@ -35,7 +35,7 @@ export function registerAbTestingTools(server) {
35
35
  .eq('project_id', projectId)
36
36
  .maybeSingle();
37
37
  if (campErr || !campaign)
38
- return txt('Error: Campaign not found');
38
+ return toolError({ code: 'not_found', error: 'Error: Campaign not found' });
39
39
  // Check variant steps exist
40
40
  const { data: steps } = await db()
41
41
  .from('campaign_steps')
@@ -44,11 +44,11 @@ export function registerAbTestingTools(server) {
44
44
  .eq('step_order', step_order ?? 0);
45
45
  const variantLabels = (steps ?? []).map((s) => s.variant_label);
46
46
  if (variantLabels.length < 2) {
47
- return txt(`Error: Need at least 2 variant steps for step_order ${step_order ?? 0}. Found: [${variantLabels.join(', ')}]. Add more variants via add_campaign_step with variant_label parameter.`);
47
+ return toolError({ code: 'validation_failed', error: `Error: Need at least 2 variant steps for step_order ${step_order ?? 0}. Found: [${variantLabels.join(', ')}]. Add more variants via add_campaign_step with variant_label parameter.` });
48
48
  }
49
49
  const emptyVariants = (steps ?? []).filter((step) => !step.subject?.trim() || !(step.body_html?.trim() || step.body_text?.trim()));
50
50
  if (emptyVariants.length) {
51
- return j({
51
+ return toolError({
52
52
  error: `Variant(s) [${emptyVariants.map((step) => step.variant_label).join(', ')}] for step_order ${step_order ?? 0} need a non-blank subject and body. Add content with add_campaign_step before creating the test.`,
53
53
  code: 'validation_failed',
54
54
  });
@@ -61,7 +61,7 @@ export function registerAbTestingTools(server) {
61
61
  .eq('step_order', step_order ?? 0)
62
62
  .maybeSingle();
63
63
  if (existing)
64
- return txt(`Error: A/B test already exists for campaign step ${step_order ?? 0}. Test ID: ${existing.id}`);
64
+ return toolError({ code: 'conflict', error: `Error: A/B test already exists for campaign step ${step_order ?? 0}. Test ID: ${existing.id}` });
65
65
  const { data: test, error: createErr } = await db()
66
66
  .from('ab_tests')
67
67
  .insert({
@@ -125,7 +125,7 @@ export function registerAbTestingTools(server) {
125
125
  idempotentHint: true,
126
126
  openWorldHint: false,
127
127
  },
128
- description: 'Check statistical significance of an A/B test. Uses a two-proportion z-test. Returns per-variant rates, z-score, p-value, and whether the result is significant at the configured confidence level.',
128
+ description: 'Check statistical significance of an A/B test. Uses a two-proportion z-test. Returns per-variant rates (null when total sends is 0), z-score, p-value, and whether the result is significant at the configured confidence level.',
129
129
  inputSchema: {
130
130
  test_id: z.string().uuid(),
131
131
  },
@@ -138,7 +138,7 @@ export function registerAbTestingTools(server) {
138
138
  .eq('project_id', getProjectId())
139
139
  .maybeSingle();
140
140
  if (!test)
141
- return txt('Error: Test not found');
141
+ return toolError({ code: 'not_found', error: 'Error: Test not found' });
142
142
  if (test.status === 'draft') {
143
143
  // Auto-start if campaign is active
144
144
  const { data: camp } = await db()
@@ -158,7 +158,10 @@ export function registerAbTestingTools(server) {
158
158
  .rpc('check_ab_significance', { p_test_id: test_id });
159
159
  if (error)
160
160
  return txt(`Error: ${dbToolError(error).error}`);
161
- return j(data);
161
+ return j(data && Array.isArray(data.variants) ? {
162
+ ...data,
163
+ variants: data.variants.map((variant) => variant.total === 0 ? { ...variant, rate: null } : variant),
164
+ } : data);
162
165
  });
163
166
  server.registerTool('promote_ab_winner', {
164
167
  title: 'Promote A/B Winner',
@@ -173,9 +176,10 @@ export function registerAbTestingTools(server) {
173
176
  approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
174
177
  test_id: z.string().uuid(),
175
178
  winner: z.string().optional().describe('Override winner variant label (default: use statistically determined winner)'),
176
- confirmed: z.boolean().optional().describe('Set true to execute after previewing'),
179
+ confirmed: z.boolean().optional().describe('Set true to request approval after previewing'),
177
180
  },
178
- }, withApproval('promote_ab_winner', async ({ test_id, winner, confirmed }) => {
181
+ }, async (args) => {
182
+ const { test_id, winner, confirmed } = args;
179
183
  const projectId = getProjectId();
180
184
  const { data: test, error: testErr } = await db()
181
185
  .from('ab_tests')
@@ -184,13 +188,13 @@ export function registerAbTestingTools(server) {
184
188
  .eq('project_id', projectId)
185
189
  .maybeSingle();
186
190
  if (testErr || !test)
187
- return txt('Error: Test not found');
191
+ return markNotExecuted(toolError({ code: 'not_found', error: 'Error: Test not found' }));
188
192
  const winnerLabel = winner ?? test.winner;
189
193
  if (!winnerLabel) {
190
- return txt('Error: No winner determined yet. Run check_ab_significance first, or specify a winner manually.');
194
+ return markNotExecuted(toolError({ code: 'conflict', error: 'Error: No winner determined yet. Run check_ab_significance first, or specify a winner manually.' }));
191
195
  }
192
196
  if (!test.variants.includes(winnerLabel)) {
193
- return txt(`Error: "${winnerLabel}" is not a valid variant. Options: ${test.variants.join(', ')}`);
197
+ return markNotExecuted(toolError({ code: 'validation_failed', error: `Error: "${winnerLabel}" is not a valid variant. Options: ${test.variants.join(', ')}` }));
194
198
  }
195
199
  const losingVariants = test.variants.filter((v) => v !== winnerLabel);
196
200
  // Get step details for preview
@@ -212,31 +216,34 @@ export function registerAbTestingTools(server) {
212
216
  variant: s.variant_label,
213
217
  subject: s.subject,
214
218
  })),
215
- message: `Will promote variant "${winnerLabel}" and delete ${loserSteps.length} losing variant(s). Call again with confirmed: true to execute.`,
219
+ message: `Will promote variant "${winnerLabel}" and delete ${loserSteps.length} losing variant(s). Call promote_ab_winner again with confirmed: true to request approval. After a person approves it, call promote_ab_winner again with only approval_id.`,
216
220
  });
217
221
  }
218
- // Delete losing variant steps
219
- for (const loser of loserSteps) {
222
+ // Validate and preview before requesting or consuming an approval.
223
+ return withApproval('promote_ab_winner', async () => {
224
+ // Delete losing variant steps
225
+ for (const loser of loserSteps) {
226
+ await db()
227
+ .from('campaign_steps')
228
+ .delete()
229
+ .eq('id', loser.id);
230
+ }
231
+ // Mark test completed
220
232
  await db()
221
- .from('campaign_steps')
222
- .delete()
223
- .eq('id', loser.id);
224
- }
225
- // Mark test completed
226
- await db()
227
- .from('ab_tests')
228
- .update({
229
- status: 'completed',
230
- winner: winnerLabel,
231
- decided_at: new Date().toISOString(),
232
- })
233
- .eq('id', test_id);
234
- return j({
235
- test_id,
236
- status: 'completed',
237
- winner: winnerLabel,
238
- deleted_variants: losingVariants,
239
- message: `A/B test "${test.name}" completed. Variant "${winnerLabel}" promoted, ${loserSteps.length} losing step(s) removed.`,
240
- });
241
- }));
233
+ .from('ab_tests')
234
+ .update({
235
+ status: 'completed',
236
+ winner: winnerLabel,
237
+ decided_at: new Date().toISOString(),
238
+ })
239
+ .eq('id', test_id);
240
+ return j({
241
+ test_id,
242
+ status: 'completed',
243
+ winner: winnerLabel,
244
+ deleted_variants: losingVariants,
245
+ message: `A/B test "${test.name}" completed. Variant "${winnerLabel}" promoted, ${loserSteps.length} losing step(s) removed.`,
246
+ });
247
+ })(args);
248
+ });
242
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.179",
3
+ "version": "1.0.181",
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",