@sendinel/mcp-server 1.0.0

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.
Files changed (90) hide show
  1. package/README.md +113 -0
  2. package/build/anthropic-model.d.ts +5 -0
  3. package/build/anthropic-model.js +6 -0
  4. package/build/audit.d.ts +21 -0
  5. package/build/audit.js +38 -0
  6. package/build/auth.d.ts +10 -0
  7. package/build/auth.js +57 -0
  8. package/build/byod-client.d.ts +30 -0
  9. package/build/byod-client.js +95 -0
  10. package/build/crypto.d.ts +2 -0
  11. package/build/crypto.js +27 -0
  12. package/build/db.d.ts +38 -0
  13. package/build/db.js +70 -0
  14. package/build/index.d.ts +1 -0
  15. package/build/index.js +97 -0
  16. package/build/lib/action-approvals.d.ts +30 -0
  17. package/build/lib/action-approvals.js +154 -0
  18. package/build/lib/schedule.d.ts +36 -0
  19. package/build/lib/schedule.js +63 -0
  20. package/build/lib/social-engine/archetypes.d.ts +27 -0
  21. package/build/lib/social-engine/archetypes.js +160 -0
  22. package/build/lib/social-engine/index.d.ts +5 -0
  23. package/build/lib/social-engine/index.js +6 -0
  24. package/build/lib/social-engine/parse.d.ts +7 -0
  25. package/build/lib/social-engine/parse.js +83 -0
  26. package/build/lib/social-engine/platform-constraints.d.ts +27 -0
  27. package/build/lib/social-engine/platform-constraints.js +70 -0
  28. package/build/lib/social-engine/prompt-builder.d.ts +13 -0
  29. package/build/lib/social-engine/prompt-builder.js +80 -0
  30. package/build/lib/social-engine/types.d.ts +60 -0
  31. package/build/lib/social-engine/types.js +19 -0
  32. package/build/lib/url-validation.d.ts +3 -0
  33. package/build/lib/url-validation.js +51 -0
  34. package/build/lib/voice.d.ts +32 -0
  35. package/build/lib/voice.js +140 -0
  36. package/build/lib/webhook-events.d.ts +15 -0
  37. package/build/lib/webhook-events.js +120 -0
  38. package/build/plan-limits.d.ts +8 -0
  39. package/build/plan-limits.js +9 -0
  40. package/build/project.d.ts +1 -0
  41. package/build/project.js +9 -0
  42. package/build/server.d.ts +18 -0
  43. package/build/server.js +235 -0
  44. package/build/tools/ab-testing.d.ts +2 -0
  45. package/build/tools/ab-testing.js +204 -0
  46. package/build/tools/advisor.d.ts +23 -0
  47. package/build/tools/advisor.js +762 -0
  48. package/build/tools/analytics.d.ts +33 -0
  49. package/build/tools/analytics.js +1105 -0
  50. package/build/tools/approvals.d.ts +2 -0
  51. package/build/tools/approvals.js +32 -0
  52. package/build/tools/automations.d.ts +2 -0
  53. package/build/tools/automations.js +344 -0
  54. package/build/tools/campaigns.d.ts +2 -0
  55. package/build/tools/campaigns.js +1335 -0
  56. package/build/tools/compound.d.ts +2 -0
  57. package/build/tools/compound.js +312 -0
  58. package/build/tools/contacts.d.ts +2 -0
  59. package/build/tools/contacts.js +1483 -0
  60. package/build/tools/content.d.ts +2 -0
  61. package/build/tools/content.js +68 -0
  62. package/build/tools/data-proposals.d.ts +2 -0
  63. package/build/tools/data-proposals.js +155 -0
  64. package/build/tools/data.d.ts +2 -0
  65. package/build/tools/data.js +707 -0
  66. package/build/tools/delivery-ops.d.ts +2 -0
  67. package/build/tools/delivery-ops.js +387 -0
  68. package/build/tools/drafts.d.ts +2 -0
  69. package/build/tools/drafts.js +204 -0
  70. package/build/tools/forms.d.ts +2 -0
  71. package/build/tools/forms.js +46 -0
  72. package/build/tools/gdpr.d.ts +2 -0
  73. package/build/tools/gdpr.js +61 -0
  74. package/build/tools/org.d.ts +2 -0
  75. package/build/tools/org.js +71 -0
  76. package/build/tools/segments.d.ts +2 -0
  77. package/build/tools/segments.js +384 -0
  78. package/build/tools/sites.d.ts +2 -0
  79. package/build/tools/sites.js +182 -0
  80. package/build/tools/sms.d.ts +2 -0
  81. package/build/tools/sms.js +489 -0
  82. package/build/tools/social-posts.d.ts +2 -0
  83. package/build/tools/social-posts.js +380 -0
  84. package/build/tools/templates.d.ts +2 -0
  85. package/build/tools/templates.js +282 -0
  86. package/build/tools/warmup.d.ts +2 -0
  87. package/build/tools/warmup.js +57 -0
  88. package/build/tools/webhooks.d.ts +2 -0
  89. package/build/tools/webhooks.js +127 -0
  90. package/package.json +63 -0
@@ -0,0 +1,235 @@
1
+ import { registerAnalyticsTools } from './tools/analytics.js';
2
+ import { registerCampaignTools } from './tools/campaigns.js';
3
+ import { registerContactTools } from './tools/contacts.js';
4
+ import { registerDraftTools } from './tools/drafts.js';
5
+ import { registerGdprTools } from './tools/gdpr.js';
6
+ import { registerSegmentTools } from './tools/segments.js';
7
+ import { registerSiteTools } from './tools/sites.js';
8
+ import { registerAbTestingTools } from './tools/ab-testing.js';
9
+ import { registerAdvisorTools } from './tools/advisor.js';
10
+ import { registerWebhookTools } from './tools/webhooks.js';
11
+ import { registerApprovalTools } from './tools/approvals.js';
12
+ import { registerAutomationTools } from './tools/automations.js';
13
+ import { registerTemplateTools } from './tools/templates.js';
14
+ import { registerDeliveryOpsTools } from './tools/delivery-ops.js';
15
+ import { registerCompoundTools } from './tools/compound.js';
16
+ import { registerDataTools } from './tools/data.js';
17
+ import { registerContentTools } from './tools/content.js';
18
+ import { registerWarmupTools } from './tools/warmup.js';
19
+ import { registerFormTools } from './tools/forms.js';
20
+ import { registerOrgTools } from './tools/org.js';
21
+ import { registerSmsTools } from './tools/sms.js';
22
+ import { registerSocialPostTools } from './tools/social-posts.js';
23
+ import { registerDataProposalTools } from './tools/data-proposals.js';
24
+ const GROUP_REGISTRY = {
25
+ analytics: registerAnalyticsTools,
26
+ campaigns: registerCampaignTools,
27
+ contacts: registerContactTools,
28
+ segments: registerSegmentTools,
29
+ drafts: registerDraftTools,
30
+ sites: registerSiteTools,
31
+ gdpr: registerGdprTools,
32
+ 'ab-testing': registerAbTestingTools,
33
+ advisor: registerAdvisorTools,
34
+ webhooks: registerWebhookTools,
35
+ approvals: registerApprovalTools,
36
+ automations: registerAutomationTools,
37
+ templates: registerTemplateTools,
38
+ 'delivery-ops': registerDeliveryOpsTools,
39
+ compound: registerCompoundTools,
40
+ data: registerDataTools,
41
+ content: registerContentTools,
42
+ warmup: registerWarmupTools,
43
+ forms: registerFormTools,
44
+ org: registerOrgTools,
45
+ sms: registerSmsTools,
46
+ 'social-posts': registerSocialPostTools,
47
+ 'data-proposals': registerDataProposalTools,
48
+ };
49
+ export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
50
+ // ── Scope enforcement ─────────────────────────────────────────────────────
51
+ // Maps tool names to required scope. Mirrors dashboard's WRITE_TOOLS/ADMIN_TOOLS.
52
+ const WRITE_TOOLS = new Set([
53
+ 'add_subscriber', 'import_subscribers', 'update_subscriber',
54
+ 'enrich_subscriber',
55
+ 'create_campaign', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
56
+ 'list_campaign_steps', 'update_campaign_step', 'delete_campaign_step',
57
+ 'enroll_contact', 'enroll_segment', 'get_enrollment_status', 'clone_campaign', 'generate_email',
58
+ 'create_social_campaign', 'schedule_social_post',
59
+ 'create_social_post', 'update_social_post', 'delete_social_post',
60
+ 'create_draft', 'approve_draft', 'reject_draft',
61
+ 'create_site', 'create_sender', 'update_site',
62
+ 'create_template', 'update_template', 'delete_template', 'translate_template',
63
+ 'delete_subscriber_data', 'identify_inactive', 'set_subscriber_tags',
64
+ 'update_hygiene_config',
65
+ 'create_segment', 'update_segment', 'delete_segment', 'create_segment_nl',
66
+ 'launch_campaign', 'merge_subscribers', 'create_campaign_with_content',
67
+ 'send_test_email', 'send_transactional', 'subscribe_webhook', 'update_webhook_subscription',
68
+ 'create_ab_test', 'promote_ab_winner', 'optimize_subject_lines',
69
+ 'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
70
+ 'trigger_automation', 'enroll_automation',
71
+ 'update_scoring_rules', 'register_domain',
72
+ 'update_warmup_schedule', 'pause_warmup', 'resume_warmup',
73
+ 'create_form', 'invite_team_member',
74
+ 'create_api_key', 'revoke_api_key', 'mark_notifications_read',
75
+ 'migrate_campaigns_from', 'start_platform_migration',
76
+ 'create_content_block', 'delete_content_block', 'trigger_content_pipeline',
77
+ 'run_list_hygiene', 'run_portfolio_analysis',
78
+ 'abuse_monitor_override',
79
+ 'send_sms', 'send_sms_campaign', 'set_contact_sms_consent',
80
+ 'setup_campaign_from_brief', 'create_template_from_brief', 'diagnose_delivery_issue', 'onboard_new_site',
81
+ 'connect_platform', 'disconnect_platform', 'configure_platform_trigger',
82
+ 'update_brand_voice', 'promote_draft_to_campaign',
83
+ 'propose_campaign_from_data', 'approve_data_proposal',
84
+ ]);
85
+ const ADMIN_TOOLS = new Set([
86
+ 'delete_subscriber_data', 'list_deletion_log',
87
+ 'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
88
+ 'create_site', 'update_site',
89
+ 'delete_template', 'register_domain', 'onboard_new_site',
90
+ 'create_api_key', 'revoke_api_key', 'invite_team_member',
91
+ 'connect_platform', 'disconnect_platform', 'configure_platform_trigger',
92
+ 'abuse_monitor_override',
93
+ 'tenant_export_full',
94
+ 'identify_inactive', 'delete_segment',
95
+ 'merge_subscribers', 'delete_webhook_subscription',
96
+ ]);
97
+ const SCOPE_HIERARCHY = { read: 1, write: 2, admin: 3 };
98
+ export function getRequiredScope(name) {
99
+ if (ADMIN_TOOLS.has(name))
100
+ return 'admin';
101
+ if (WRITE_TOOLS.has(name))
102
+ return 'write';
103
+ return 'read';
104
+ }
105
+ function scopeAllows(keyScopes, required) {
106
+ const requiredLevel = SCOPE_HIERARCHY[required] ?? 99;
107
+ return keyScopes.some((s) => (SCOPE_HIERARCHY[s] ?? 0) >= requiredLevel);
108
+ }
109
+ /**
110
+ * Collects tool-name → group metadata from the same group registry used by
111
+ * registerAllTools(). Scope filtering and definition scanning are applied
112
+ * through the same proxy path as real MCP registration.
113
+ */
114
+ export function getRegisteredToolGroups(options) {
115
+ const groups = options?.toolGroups === undefined
116
+ ? ALL_TOOL_GROUPS
117
+ : options.toolGroups;
118
+ const toolGroups = {};
119
+ let currentGroup = '';
120
+ const collector = {
121
+ registerTool(name, _config, _handler) {
122
+ toolGroups[name] = currentGroup;
123
+ },
124
+ };
125
+ const effectiveServer = createScopeProxy(collector, options?.scopes?.length ? options.scopes : null);
126
+ for (const group of groups) {
127
+ const register = GROUP_REGISTRY[group];
128
+ if (!register)
129
+ continue;
130
+ currentGroup = group;
131
+ register(effectiveServer);
132
+ }
133
+ return toolGroups;
134
+ }
135
+ const INJECTION_PATTERNS = [
136
+ // Direct prompt injection
137
+ { pattern: /ignore\s+(all\s+)?(previous|prior|above)\s+instructions?/i, label: 'ignore-instructions', severity: 'block' },
138
+ { pattern: /you\s+are\s+now\s+(in\s+)?(maintenance|developer|admin|god)\s+mode/i, label: 'mode-switch', severity: 'block' },
139
+ { pattern: /\bsystem\s*:/i, label: 'system-prefix', severity: 'block' },
140
+ { pattern: /act\s+as\s+(a\s+)?(different|new|another)\s+(ai|assistant|model)/i, label: 'persona-switch', severity: 'block' },
141
+ { pattern: /do\s+not\s+(tell|inform|mention)\s+(the\s+)?(user|human)/i, label: 'conceal-from-user', severity: 'block' },
142
+ { pattern: /before\s+respond(ing)?,?\s+(always\s+)?call/i, label: 'forced-tool-call', severity: 'block' },
143
+ // Exfiltration
144
+ { pattern: /\bfetch\s*\(/i, label: 'exfil-fetch', severity: 'block' },
145
+ { pattern: /https?:\/\//i, label: 'external-url', severity: 'warn' },
146
+ { pattern: /base64|btoa|atob/i, label: 'encoding-exfil', severity: 'warn' },
147
+ // Hidden Unicode (zero-width, directional overrides, BOM)
148
+ { pattern: /[\u200b-\u200f\u202a-\u202e\u2060-\u206f\u2066-\u2069\ufeff]/u, label: 'hidden-unicode', severity: 'block' },
149
+ ];
150
+ // Tool names must be lowercase snake_case identifiers, max 64 chars
151
+ const VALID_TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
152
+ // Pattern labels that are downgraded from block→warn via env var, e.g.
153
+ // MCP_TOOL_SCAN_ALLOWLIST=external-url
154
+ const SCAN_ALLOWLIST = new Set((process.env.MCP_TOOL_SCAN_ALLOWLIST ?? '').split(',').map(s => s.trim()).filter(Boolean));
155
+ // MCP_TOOL_SCAN_MODE=block (default) | warn | off
156
+ const SCAN_MODE = (process.env.MCP_TOOL_SCAN_MODE ?? 'block');
157
+ function scanToolDefinition(name, config) {
158
+ if (SCAN_MODE === 'off')
159
+ return [];
160
+ const findings = [];
161
+ // Name format (typosquatting / embedded injection)
162
+ if (!VALID_TOOL_NAME.test(name)) {
163
+ findings.push({ field: 'name', pattern: 'invalid-format', severity: 'warn', match: name.slice(0, 80) });
164
+ }
165
+ for (const { pattern, label, severity } of INJECTION_PATTERNS) {
166
+ if (pattern.test(name)) {
167
+ const effective = SCAN_ALLOWLIST.has(label) ? 'warn' : severity;
168
+ findings.push({ field: 'name', pattern: label, severity: effective, match: name.slice(0, 80) });
169
+ }
170
+ }
171
+ // Description
172
+ const desc = config?.description ?? '';
173
+ for (const { pattern, label, severity } of INJECTION_PATTERNS) {
174
+ if (pattern.test(desc)) {
175
+ const effective = SCAN_ALLOWLIST.has(label) ? 'warn' : severity;
176
+ findings.push({ field: 'description', pattern: label, severity: effective, match: desc.slice(0, 120) });
177
+ }
178
+ }
179
+ return findings;
180
+ }
181
+ /**
182
+ * Creates a Proxy around McpServer that intercepts registerTool calls
183
+ * and wraps handlers with scope enforcement.
184
+ */
185
+ // scopes=null means no scope restriction (all tools allowed) but scan still runs.
186
+ function createScopeProxy(server, scopes) {
187
+ return new Proxy(server, {
188
+ get(target, prop, receiver) {
189
+ if (prop === 'registerTool') {
190
+ return (name, config, handler) => {
191
+ // ── Tool definition scan (prompt injection / poisoning defense) ──
192
+ // Always runs, regardless of whether scope enforcement is active.
193
+ const findings = scanToolDefinition(name, config);
194
+ if (findings.length > 0) {
195
+ const wouldBlock = SCAN_MODE === 'block' && findings.some(f => f.severity === 'block');
196
+ const level = wouldBlock ? 'BLOCK' : 'WARN';
197
+ console.error(`[MCP SCAN] ${level} tool="${name}" findings=${JSON.stringify(findings)}`);
198
+ if (wouldBlock)
199
+ return; // omit from tool list entirely
200
+ }
201
+ // ── Scope enforcement (only when scopes are provided) ─────────────
202
+ if (scopes !== null) {
203
+ const required = getRequiredScope(name);
204
+ if (!scopeAllows(scopes, required)) {
205
+ // Don't register tools the key can't access — they won't appear in tool list
206
+ return;
207
+ }
208
+ // Wrap handler with runtime scope check (defense in depth)
209
+ const guardedHandler = (...args) => {
210
+ if (!scopeAllows(scopes, required)) {
211
+ return { content: [{ type: 'text', text: `Forbidden: this action requires "${required}" scope.` }] };
212
+ }
213
+ return handler(...args);
214
+ };
215
+ return target.registerTool(name, config, guardedHandler);
216
+ }
217
+ return target.registerTool(name, config, handler);
218
+ };
219
+ }
220
+ return Reflect.get(target, prop, receiver);
221
+ },
222
+ });
223
+ }
224
+ export function registerAllTools(server, options) {
225
+ const groups = options?.toolGroups === undefined
226
+ ? ALL_TOOL_GROUPS
227
+ : options.toolGroups;
228
+ // Always wrap — scanner runs for every caller, scope enforcement only when scopes provided
229
+ const effectiveServer = createScopeProxy(server, options?.scopes?.length ? options.scopes : null);
230
+ for (const group of groups) {
231
+ const register = GROUP_REGISTRY[group];
232
+ if (register)
233
+ register(effectiveServer);
234
+ }
235
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerAbTestingTools(server: McpServer): void;
@@ -0,0 +1,204 @@
1
+ import { z } from 'zod';
2
+ import { db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
5
+ const j = (data) => txt(JSON.stringify(data, null, 2));
6
+ export function registerAbTestingTools(server) {
7
+ server.registerTool('create_ab_test', {
8
+ description: 'Create an A/B test for a campaign step. Requires at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
9
+ inputSchema: {
10
+ campaign_id: z.string().uuid(),
11
+ step_order: z.number().int().min(0).optional().default(0).describe('Which step to test (default: 0, the first email)'),
12
+ name: z.string().min(1).describe('Test name, e.g. "Subject line urgency test"'),
13
+ metric: z.enum(['open_rate', 'click_rate']).optional().default('open_rate'),
14
+ split_ratio: z.number().min(0.1).max(0.9).optional().default(0.5).describe('Fraction of sends to variant A (default: 0.5)'),
15
+ min_sample_size: z.number().int().min(10).optional().default(100).describe('Minimum sends per variant before testing significance'),
16
+ confidence_level: z.number().min(0.8).max(0.99).optional().default(0.95),
17
+ auto_promote: z.boolean().optional().default(false).describe('Automatically promote winner when significant'),
18
+ },
19
+ }, async ({ campaign_id, step_order, name, metric, split_ratio, min_sample_size, confidence_level, auto_promote }) => {
20
+ const projectId = getProjectId();
21
+ // Validate campaign exists
22
+ const { data: campaign, error: campErr } = await db()
23
+ .from('campaigns')
24
+ .select('id, name, status')
25
+ .eq('id', campaign_id)
26
+ .eq('project_id', projectId)
27
+ .maybeSingle();
28
+ if (campErr || !campaign)
29
+ return txt('Error: Campaign not found');
30
+ // Check variant steps exist
31
+ const { data: steps } = await db()
32
+ .from('campaign_steps')
33
+ .select('id, variant_label, subject')
34
+ .eq('campaign_id', campaign_id)
35
+ .eq('step_order', step_order ?? 0);
36
+ const variantLabels = (steps ?? []).map((s) => s.variant_label);
37
+ if (variantLabels.length < 2) {
38
+ 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.`);
39
+ }
40
+ // Check no existing test for this step
41
+ const { data: existing } = await db()
42
+ .from('ab_tests')
43
+ .select('id')
44
+ .eq('campaign_id', campaign_id)
45
+ .eq('step_order', step_order ?? 0)
46
+ .maybeSingle();
47
+ if (existing)
48
+ return txt(`Error: A/B test already exists for campaign step ${step_order ?? 0}. Test ID: ${existing.id}`);
49
+ const { data: test, error: createErr } = await db()
50
+ .from('ab_tests')
51
+ .insert({
52
+ project_id: projectId,
53
+ campaign_id,
54
+ step_order: step_order ?? 0,
55
+ name,
56
+ metric: metric ?? 'open_rate',
57
+ variants: variantLabels.sort(),
58
+ split_ratio: split_ratio ?? 0.5,
59
+ min_sample_size: min_sample_size ?? 100,
60
+ confidence_level: confidence_level ?? 0.95,
61
+ auto_promote: auto_promote ?? false,
62
+ status: 'draft',
63
+ })
64
+ .select()
65
+ .single();
66
+ if (createErr)
67
+ return txt(`Error: ${createErr.message}`);
68
+ return j({
69
+ ...test,
70
+ variant_steps: steps,
71
+ message: `A/B test "${name}" created. Update status to 'running' when the campaign is activated, or it will start automatically when sends begin. Variants: ${variantLabels.join(' vs ')}.`,
72
+ });
73
+ });
74
+ server.registerTool('list_ab_tests', {
75
+ description: 'List A/B tests, optionally filtered by campaign or status',
76
+ inputSchema: {
77
+ campaign_id: z.string().uuid().optional(),
78
+ status: z.enum(['draft', 'running', 'significant', 'completed']).optional(),
79
+ limit: z.number().int().min(1).max(50).optional().default(25),
80
+ },
81
+ }, async ({ campaign_id, status, limit }) => {
82
+ let q = db()
83
+ .from('ab_tests')
84
+ .select('*')
85
+ .eq('project_id', getProjectId())
86
+ .order('created_at', { ascending: false })
87
+ .limit(limit ?? 25);
88
+ if (campaign_id)
89
+ q = q.eq('campaign_id', campaign_id);
90
+ if (status)
91
+ q = q.eq('status', status);
92
+ const { data, error } = await q;
93
+ if (error)
94
+ return txt(`Error: ${error.message}`);
95
+ return j(data);
96
+ });
97
+ server.registerTool('check_ab_significance', {
98
+ 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.',
99
+ inputSchema: {
100
+ test_id: z.string().uuid(),
101
+ },
102
+ }, async ({ test_id }) => {
103
+ // First, ensure the test is running (auto-start if campaign is active)
104
+ const { data: test } = await db()
105
+ .from('ab_tests')
106
+ .select('id, status, campaign_id')
107
+ .eq('id', test_id)
108
+ .eq('project_id', getProjectId())
109
+ .maybeSingle();
110
+ if (!test)
111
+ return txt('Error: Test not found');
112
+ if (test.status === 'draft') {
113
+ // Auto-start if campaign is active
114
+ const { data: camp } = await db()
115
+ .from('campaigns')
116
+ .select('status')
117
+ .eq('id', test.campaign_id)
118
+ .maybeSingle();
119
+ if (camp?.status === 'active') {
120
+ await db()
121
+ .from('ab_tests')
122
+ .update({ status: 'running' })
123
+ .eq('id', test_id);
124
+ }
125
+ }
126
+ const { data, error } = await db()
127
+ .schema('email')
128
+ .rpc('check_ab_significance', { p_test_id: test_id });
129
+ if (error)
130
+ return txt(`Error: ${error.message}`);
131
+ return j(data);
132
+ });
133
+ server.registerTool('promote_ab_winner', {
134
+ description: 'End an A/B test and keep only the winning variant. Requires confirmation (two-call pattern). Deletes the losing variant step(s) from campaign_steps.',
135
+ inputSchema: {
136
+ test_id: z.string().uuid(),
137
+ winner: z.string().optional().describe('Override winner variant label (default: use statistically determined winner)'),
138
+ confirmed: z.boolean().optional().describe('Set true to execute after previewing'),
139
+ },
140
+ }, async ({ test_id, winner, confirmed }) => {
141
+ const projectId = getProjectId();
142
+ const { data: test, error: testErr } = await db()
143
+ .from('ab_tests')
144
+ .select('*')
145
+ .eq('id', test_id)
146
+ .eq('project_id', projectId)
147
+ .maybeSingle();
148
+ if (testErr || !test)
149
+ return txt('Error: Test not found');
150
+ const winnerLabel = winner ?? test.winner;
151
+ if (!winnerLabel) {
152
+ return txt('Error: No winner determined yet. Run check_ab_significance first, or specify a winner manually.');
153
+ }
154
+ if (!test.variants.includes(winnerLabel)) {
155
+ return txt(`Error: "${winnerLabel}" is not a valid variant. Options: ${test.variants.join(', ')}`);
156
+ }
157
+ const losingVariants = test.variants.filter((v) => v !== winnerLabel);
158
+ // Get step details for preview
159
+ const { data: steps } = await db()
160
+ .from('campaign_steps')
161
+ .select('id, variant_label, subject')
162
+ .eq('campaign_id', test.campaign_id)
163
+ .eq('step_order', test.step_order);
164
+ const winnerStep = (steps ?? []).find((s) => s.variant_label === winnerLabel);
165
+ const loserSteps = (steps ?? []).filter((s) => losingVariants.includes(s.variant_label));
166
+ if (!confirmed) {
167
+ return j({
168
+ confirmation_required: true,
169
+ test_id,
170
+ test_name: test.name,
171
+ winner: winnerLabel,
172
+ winner_subject: winnerStep?.subject,
173
+ losing_variants: loserSteps.map((s) => ({
174
+ variant: s.variant_label,
175
+ subject: s.subject,
176
+ })),
177
+ message: `Will promote variant "${winnerLabel}" and delete ${loserSteps.length} losing variant(s). Call again with confirmed: true to execute.`,
178
+ });
179
+ }
180
+ // Delete losing variant steps
181
+ for (const loser of loserSteps) {
182
+ await db()
183
+ .from('campaign_steps')
184
+ .delete()
185
+ .eq('id', loser.id);
186
+ }
187
+ // Mark test completed
188
+ await db()
189
+ .from('ab_tests')
190
+ .update({
191
+ status: 'completed',
192
+ winner: winnerLabel,
193
+ decided_at: new Date().toISOString(),
194
+ })
195
+ .eq('id', test_id);
196
+ return j({
197
+ test_id,
198
+ status: 'completed',
199
+ winner: winnerLabel,
200
+ deleted_variants: losingVariants,
201
+ message: `A/B test "${test.name}" completed. Variant "${winnerLabel}" promoted, ${loserSteps.length} losing step(s) removed.`,
202
+ });
203
+ });
204
+ }
@@ -0,0 +1,23 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ interface PeriodMetrics {
3
+ sent: number;
4
+ delivered: number;
5
+ opened: number;
6
+ clicked: number;
7
+ bounced: number;
8
+ complained: number;
9
+ open_rate: number;
10
+ click_rate: number;
11
+ bounce_rate: number;
12
+ complaint_rate: number;
13
+ }
14
+ export declare function computeMetrics(rows: any[]): PeriodMetrics;
15
+ interface TimeBucket {
16
+ label: string;
17
+ sends: number;
18
+ engagements: number;
19
+ rate: number;
20
+ }
21
+ export declare function buildTimeBuckets(rows: any[], groupBy: 'hour' | 'day', metric: string): TimeBucket[];
22
+ export declare function registerAdvisorTools(server: McpServer): void;
23
+ export {};