@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.
- package/build/tools/ab-testing.js +45 -38
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
179
|
+
confirmed: z.boolean().optional().describe('Set true to request approval after previewing'),
|
|
177
180
|
},
|
|
178
|
-
},
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
219
|
-
|
|
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('
|
|
222
|
-
.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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",
|