@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.
- package/README.md +113 -0
- package/build/anthropic-model.d.ts +5 -0
- package/build/anthropic-model.js +6 -0
- package/build/audit.d.ts +21 -0
- package/build/audit.js +38 -0
- package/build/auth.d.ts +10 -0
- package/build/auth.js +57 -0
- package/build/byod-client.d.ts +30 -0
- package/build/byod-client.js +95 -0
- package/build/crypto.d.ts +2 -0
- package/build/crypto.js +27 -0
- package/build/db.d.ts +38 -0
- package/build/db.js +70 -0
- package/build/index.d.ts +1 -0
- package/build/index.js +97 -0
- package/build/lib/action-approvals.d.ts +30 -0
- package/build/lib/action-approvals.js +154 -0
- package/build/lib/schedule.d.ts +36 -0
- package/build/lib/schedule.js +63 -0
- package/build/lib/social-engine/archetypes.d.ts +27 -0
- package/build/lib/social-engine/archetypes.js +160 -0
- package/build/lib/social-engine/index.d.ts +5 -0
- package/build/lib/social-engine/index.js +6 -0
- package/build/lib/social-engine/parse.d.ts +7 -0
- package/build/lib/social-engine/parse.js +83 -0
- package/build/lib/social-engine/platform-constraints.d.ts +27 -0
- package/build/lib/social-engine/platform-constraints.js +70 -0
- package/build/lib/social-engine/prompt-builder.d.ts +13 -0
- package/build/lib/social-engine/prompt-builder.js +80 -0
- package/build/lib/social-engine/types.d.ts +60 -0
- package/build/lib/social-engine/types.js +19 -0
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +51 -0
- package/build/lib/voice.d.ts +32 -0
- package/build/lib/voice.js +140 -0
- package/build/lib/webhook-events.d.ts +15 -0
- package/build/lib/webhook-events.js +120 -0
- package/build/plan-limits.d.ts +8 -0
- package/build/plan-limits.js +9 -0
- package/build/project.d.ts +1 -0
- package/build/project.js +9 -0
- package/build/server.d.ts +18 -0
- package/build/server.js +235 -0
- package/build/tools/ab-testing.d.ts +2 -0
- package/build/tools/ab-testing.js +204 -0
- package/build/tools/advisor.d.ts +23 -0
- package/build/tools/advisor.js +762 -0
- package/build/tools/analytics.d.ts +33 -0
- package/build/tools/analytics.js +1105 -0
- package/build/tools/approvals.d.ts +2 -0
- package/build/tools/approvals.js +32 -0
- package/build/tools/automations.d.ts +2 -0
- package/build/tools/automations.js +344 -0
- package/build/tools/campaigns.d.ts +2 -0
- package/build/tools/campaigns.js +1335 -0
- package/build/tools/compound.d.ts +2 -0
- package/build/tools/compound.js +312 -0
- package/build/tools/contacts.d.ts +2 -0
- package/build/tools/contacts.js +1483 -0
- package/build/tools/content.d.ts +2 -0
- package/build/tools/content.js +68 -0
- package/build/tools/data-proposals.d.ts +2 -0
- package/build/tools/data-proposals.js +155 -0
- package/build/tools/data.d.ts +2 -0
- package/build/tools/data.js +707 -0
- package/build/tools/delivery-ops.d.ts +2 -0
- package/build/tools/delivery-ops.js +387 -0
- package/build/tools/drafts.d.ts +2 -0
- package/build/tools/drafts.js +204 -0
- package/build/tools/forms.d.ts +2 -0
- package/build/tools/forms.js +46 -0
- package/build/tools/gdpr.d.ts +2 -0
- package/build/tools/gdpr.js +61 -0
- package/build/tools/org.d.ts +2 -0
- package/build/tools/org.js +71 -0
- package/build/tools/segments.d.ts +2 -0
- package/build/tools/segments.js +384 -0
- package/build/tools/sites.d.ts +2 -0
- package/build/tools/sites.js +182 -0
- package/build/tools/sms.d.ts +2 -0
- package/build/tools/sms.js +489 -0
- package/build/tools/social-posts.d.ts +2 -0
- package/build/tools/social-posts.js +380 -0
- package/build/tools/templates.d.ts +2 -0
- package/build/tools/templates.js +282 -0
- package/build/tools/warmup.d.ts +2 -0
- package/build/tools/warmup.js +57 -0
- package/build/tools/webhooks.d.ts +2 -0
- package/build/tools/webhooks.js +127 -0
- package/package.json +63 -0
package/build/server.js
ADDED
|
@@ -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,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 {};
|