@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,61 @@
1
+ import { z } from 'zod';
2
+ import { db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { logAudit } from '../audit.js';
5
+ import { requestApproval, checkApproval } from '../lib/action-approvals.js';
6
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
7
+ const j = (data) => txt(JSON.stringify(data, null, 2));
8
+ export function registerGdprTools(server) {
9
+ server.registerTool('delete_subscriber_data', {
10
+ description: 'GDPR Article 17: Permanently delete a subscriber and all their data. Anonymizes email_log, hashes suppressions, cascades to subscriptions/enrollments/tokens. This action is IRREVERSIBLE. Requires human approval via dashboard.',
11
+ inputSchema: {
12
+ contact_id: z.string().uuid().describe('UUID of the subscriber to delete'),
13
+ reason: z.enum(['gdpr_request', 'user_request', 'admin']).optional().default('gdpr_request'),
14
+ approval_id: z.string().uuid().optional().describe('Required — get this by calling once without it, then have a human approve via dashboard.'),
15
+ },
16
+ }, async ({ contact_id, reason, approval_id }) => {
17
+ // Verify contact belongs to this project (defense in depth)
18
+ const { data: contact } = await db()
19
+ .from('subscribers')
20
+ .select('id, email')
21
+ .eq('id', contact_id)
22
+ .eq('project_id', getProjectId())
23
+ .maybeSingle();
24
+ if (!contact)
25
+ return txt('Contact not found or does not belong to this project.');
26
+ // Human approval required (always, even in autonomous mode — protected tier)
27
+ if (!approval_id) {
28
+ const approval = await requestApproval('delete_subscriber_data', { contact_id, email: contact.email, reason });
29
+ return j(approval);
30
+ }
31
+ const check = await checkApproval(approval_id, { consume: true });
32
+ if (!check.approved) {
33
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
34
+ }
35
+ const { data, error } = await db()
36
+ .rpc('delete_contact_gdpr', {
37
+ p_contact_id: contact_id,
38
+ p_reason: reason ?? 'gdpr_request',
39
+ p_deleted_by: 'mcp',
40
+ });
41
+ if (error)
42
+ return txt(`Error: ${error.message}`);
43
+ logAudit({ action: 'contact.gdpr_deleted', resourceType: 'contact', resourceId: contact_id, details: { reason, result: data } });
44
+ return j(data);
45
+ });
46
+ server.registerTool('list_deletion_log', {
47
+ description: 'List GDPR deletion audit log entries',
48
+ inputSchema: {
49
+ limit: z.number().int().min(1).max(200).optional().default(50),
50
+ },
51
+ }, async ({ limit }) => {
52
+ const { data, error } = await db()
53
+ .from('deletion_log')
54
+ .select('id, contact_email_hash, reason, deleted_by, tables_affected, created_at')
55
+ .order('created_at', { ascending: false })
56
+ .limit(limit ?? 50);
57
+ if (error)
58
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
59
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
60
+ });
61
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerOrgTools(server: McpServer): void;
@@ -0,0 +1,71 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { z } from 'zod';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
6
+ const PLAN_LIMITS = {
7
+ free: { contacts: 500, monthly_sends: 1000, sites: 1, ai_drafts: 10, log_retention_days: 30 },
8
+ byod: { contacts: 50000, monthly_sends: 250000, sites: 10, ai_drafts: 500, log_retention_days: 180 },
9
+ managed: { contacts: 250000, monthly_sends: 1000000, sites: 50, ai_drafts: 2000, log_retention_days: 365 },
10
+ };
11
+ export function registerOrgTools(server) {
12
+ server.registerTool('list_team_members', {
13
+ description: 'List team members for the project organization.',
14
+ inputSchema: { org_id: z.string().uuid().optional() },
15
+ }, async ({ org_id }) => {
16
+ let orgId = org_id;
17
+ if (!orgId) {
18
+ const { data: project } = await db().from('projects').select('org_id').eq('id', getProjectId()).maybeSingle();
19
+ orgId = project?.org_id;
20
+ }
21
+ if (!orgId)
22
+ return j({ members: [] });
23
+ const { data, error } = await db().from('org_members').select('user_id, email, role, created_at').eq('org_id', orgId);
24
+ if (error)
25
+ return j({ error: error.message });
26
+ return j({ members: (data ?? []).map((member) => ({ user_id: member.user_id, email: member.email ?? null, role: member.role, joined_at: member.created_at })) });
27
+ });
28
+ server.registerTool('invite_team_member', {
29
+ description: 'Create a team invitation for the current project organization.',
30
+ inputSchema: { email: z.string().email(), role: z.enum(['owner', 'admin', 'member']).default('member') },
31
+ }, async ({ email, role }) => {
32
+ const { data: project } = await db().from('projects').select('org_id').eq('id', getProjectId()).maybeSingle();
33
+ if (!project?.org_id)
34
+ return j({ error: 'Project has no organization', code: 'NO_ORG' });
35
+ const token = randomBytes(16).toString('hex');
36
+ const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString();
37
+ const { data, error } = await db().from('org_invitations')
38
+ .insert({ org_id: project.org_id, email, role, token, expires_at: expiresAt })
39
+ .select('id, expires_at')
40
+ .single();
41
+ if (error)
42
+ return j({ error: error.message });
43
+ return j({ invitation_id: data.id, expires_at: data.expires_at });
44
+ });
45
+ server.registerTool('get_plan_usage', {
46
+ description: 'Get current plan usage for the project.',
47
+ inputSchema: {},
48
+ }, async () => {
49
+ const projectId = getProjectId();
50
+ const { data: project } = await db().from('projects').select('plan').eq('id', projectId).maybeSingle();
51
+ const plan = project?.plan ?? 'free';
52
+ const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.free ?? {};
53
+ const [{ count: contacts }, { count: sends }, { count: sites }] = await Promise.all([
54
+ db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
55
+ db().from('email_log').select('id', { count: 'exact', head: true }).eq('project_id', projectId).gte('created_at', new Date(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1).toISOString()),
56
+ db().from('sites').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
57
+ ]);
58
+ return j({
59
+ plan,
60
+ contacts: { used: contacts ?? 0, limit: limits.contacts ?? null },
61
+ monthly_sends: { used: sends ?? 0, limit: limits.monthly_sends ?? limits.emails_per_month ?? null },
62
+ sites: { used: sites ?? 0, limit: limits.sites ?? null },
63
+ ai_drafts: { used: 0, limit: limits.ai_drafts ?? null },
64
+ log_retention_days: limits.log_retention_days ?? null,
65
+ });
66
+ });
67
+ server.registerTool('get_plan_limits', {
68
+ description: 'Return plan limits for all plan tiers.',
69
+ inputSchema: {},
70
+ }, async () => j(PLAN_LIMITS));
71
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSegmentTools(server: McpServer): void;
@@ -0,0 +1,384 @@
1
+ import { z } from 'zod';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { getAnthropicModelId } from '../anthropic-model.js';
6
+ function isSegmentRuleGroup(value) {
7
+ const node = value;
8
+ return !!node && (node.op === 'AND' || node.op === 'OR') && Array.isArray(node.conditions);
9
+ }
10
+ function normalizeSegmentRulesToTree(rules) {
11
+ const source = (rules ?? {});
12
+ if (isSegmentRuleGroup(source))
13
+ return source;
14
+ const conditions = [];
15
+ if (source.lifecycle_stage)
16
+ conditions.push({ field: 'lifecycle_stage', operator: 'eq', value: source.lifecycle_stage });
17
+ if (source.min_score !== undefined)
18
+ conditions.push({ field: 'engagement_score', operator: 'gte', value: source.min_score });
19
+ if (source.max_score !== undefined)
20
+ conditions.push({ field: 'engagement_score', operator: 'lte', value: source.max_score });
21
+ if (source.source)
22
+ conditions.push({ field: 'source', operator: 'eq', value: source.source });
23
+ if (source.subscribed_after)
24
+ conditions.push({ field: 'created_at', operator: 'on_or_after', value: source.subscribed_after });
25
+ if (source.subscribed_before)
26
+ conditions.push({ field: 'created_at', operator: 'on_or_before', value: source.subscribed_before });
27
+ if (source.has_opened === true)
28
+ conditions.push({ field: 'last_opened_at', operator: 'exists' });
29
+ if (source.has_opened === false)
30
+ conditions.push({ field: 'last_opened_at', operator: 'not_exists' });
31
+ if (source.has_clicked === true)
32
+ conditions.push({ field: 'last_clicked_at', operator: 'exists' });
33
+ if (source.has_clicked === false)
34
+ conditions.push({ field: 'last_clicked_at', operator: 'not_exists' });
35
+ if (source.tag)
36
+ conditions.push({ field: 'tags', operator: 'contains', value: source.tag });
37
+ if (source.custom_property) {
38
+ const idx = source.custom_property.indexOf('=');
39
+ if (idx > 0)
40
+ conditions.push({ field: `custom_properties.${source.custom_property.slice(0, idx)}`, operator: 'eq', value: source.custom_property.slice(idx + 1) });
41
+ }
42
+ if (source.payment) {
43
+ for (const [field, value] of Object.entries(source.payment)) {
44
+ if (value == null || value === '')
45
+ continue;
46
+ if (typeof value === 'object' && !Array.isArray(value)) {
47
+ const rule = value;
48
+ if (typeof rule.within_days === 'number') {
49
+ conditions.push({ field: `payment.${field}`, operator: 'on_or_after', value: new Date().toISOString() });
50
+ conditions.push({ field: `payment.${field}`, operator: 'on_or_before', value: addDaysIso(rule.within_days) });
51
+ }
52
+ if (rule.after)
53
+ conditions.push({ field: `payment.${field}`, operator: 'on_or_after', value: rule.after });
54
+ if (rule.before)
55
+ conditions.push({ field: `payment.${field}`, operator: 'on_or_before', value: rule.before });
56
+ }
57
+ else {
58
+ conditions.push({ field: `payment.${field}`, operator: 'eq', value });
59
+ }
60
+ }
61
+ }
62
+ return { op: 'AND', conditions, exclude_suppressed: source.exclude_suppressed, site_id: source.site_id };
63
+ }
64
+ function addDaysIso(days) {
65
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
66
+ }
67
+ function getComparableValue(contact, field) {
68
+ if (field.startsWith('custom_properties.')) {
69
+ return field.slice('custom_properties.'.length).split('.').reduce((acc, part) => acc == null ? undefined : acc[part], contact.custom_properties);
70
+ }
71
+ if (field.startsWith('payment.')) {
72
+ return field.slice('payment.'.length).split('.').reduce((acc, part) => acc == null ? undefined : acc[part], contact.custom_properties?.payment);
73
+ }
74
+ return contact[field];
75
+ }
76
+ function compareValues(actual, operator, expected) {
77
+ if (operator === 'exists')
78
+ return actual !== null && actual !== undefined && actual !== '';
79
+ if (operator === 'not_exists')
80
+ return actual === null || actual === undefined || actual === '';
81
+ if (operator === 'contains')
82
+ return Array.isArray(actual) ? actual.map(String).includes(String(expected)) : typeof actual === 'string' && actual.includes(String(expected));
83
+ if (operator === 'eq')
84
+ return String(actual ?? '') === String(expected ?? '');
85
+ if (operator === 'neq')
86
+ return String(actual ?? '') !== String(expected ?? '');
87
+ const actualNumber = Number(actual);
88
+ const expectedNumber = Number(expected);
89
+ const numeric = Number.isFinite(actualNumber) && Number.isFinite(expectedNumber);
90
+ const left = numeric ? actualNumber : Date.parse(String(actual ?? ''));
91
+ const right = numeric ? expectedNumber : Date.parse(String(expected ?? ''));
92
+ if (!Number.isFinite(left) || !Number.isFinite(right))
93
+ return false;
94
+ if (operator === 'gt' || operator === 'after')
95
+ return left > right;
96
+ if (operator === 'gte' || operator === 'on_or_after')
97
+ return left >= right;
98
+ if (operator === 'lt' || operator === 'before')
99
+ return left < right;
100
+ if (operator === 'lte' || operator === 'on_or_before')
101
+ return left <= right;
102
+ return false;
103
+ }
104
+ function evaluateNode(contact, node) {
105
+ if (isSegmentRuleGroup(node)) {
106
+ if (!node.conditions.length)
107
+ return true;
108
+ return node.op === 'OR' ? node.conditions.some((child) => evaluateNode(contact, child)) : node.conditions.every((child) => evaluateNode(contact, child));
109
+ }
110
+ return compareValues(getComparableValue(contact, node.field), node.operator ?? 'eq', node.value);
111
+ }
112
+ async function getSegmentContactIds(rules) {
113
+ const projectId = getProjectId();
114
+ const tree = normalizeSegmentRulesToTree(rules);
115
+ let q = db()
116
+ .from('subscribers')
117
+ .select('id, email, lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, tags, custom_properties')
118
+ .eq('project_id', projectId)
119
+ .eq('global_unsubscribed', false);
120
+ if (!isSegmentRuleGroup(rules)) {
121
+ if (rules.lifecycle_stage)
122
+ q = q.eq('lifecycle_stage', rules.lifecycle_stage);
123
+ q = applyPaymentRules(q, rules.payment);
124
+ if (rules.min_score !== undefined)
125
+ q = q.gte('engagement_score', rules.min_score);
126
+ if (rules.max_score !== undefined)
127
+ q = q.lte('engagement_score', rules.max_score);
128
+ if (rules.source)
129
+ q = q.eq('source', rules.source);
130
+ if (rules.subscribed_after)
131
+ q = q.gte('created_at', `${rules.subscribed_after}T00:00:00Z`);
132
+ if (rules.subscribed_before)
133
+ q = q.lte('created_at', `${rules.subscribed_before}T23:59:59Z`);
134
+ if (rules.has_opened === true)
135
+ q = q.not('last_opened_at', 'is', null);
136
+ if (rules.has_opened === false)
137
+ q = q.is('last_opened_at', null);
138
+ if (rules.has_clicked === true)
139
+ q = q.not('last_clicked_at', 'is', null);
140
+ if (rules.has_clicked === false)
141
+ q = q.is('last_clicked_at', null);
142
+ if (rules.tag)
143
+ q = q.contains('tags', [rules.tag]);
144
+ }
145
+ if (tree.site_id) {
146
+ const { data: subs } = await db()
147
+ .from('site_subscriptions')
148
+ .select('contact_id')
149
+ .eq('site_id', tree.site_id)
150
+ .eq('subscribed', true);
151
+ const siteIds = (subs ?? []).map((s) => s.contact_id);
152
+ if (!siteIds.length)
153
+ return [];
154
+ q = q.in('id', siteIds);
155
+ }
156
+ const { data, error } = await q;
157
+ if (error || !data)
158
+ return [];
159
+ let contacts = data.filter((contact) => evaluateNode(contact, tree));
160
+ let ids = contacts.map((c) => String(c.id));
161
+ if (tree.exclude_suppressed !== false && ids.length > 0) {
162
+ const { data: suppressed } = await db()
163
+ .from('suppressions')
164
+ .select('email')
165
+ .eq('project_id', projectId);
166
+ if (suppressed?.length) {
167
+ const suppressedEmails = new Set(suppressed.map((s) => s.email));
168
+ ids = contacts
169
+ .filter((c) => !suppressedEmails.has(String(c.email)))
170
+ .map((c) => String(c.id));
171
+ }
172
+ }
173
+ return ids;
174
+ }
175
+ function applyPaymentRules(query, payment) {
176
+ if (!payment)
177
+ return query;
178
+ let q = query;
179
+ if (payment.plan)
180
+ q = q.contains('custom_properties', { payment: { plan: payment.plan } });
181
+ if (payment.mrr_cents !== undefined)
182
+ q = q.contains('custom_properties', { payment: { mrr_cents: payment.mrr_cents } });
183
+ if (payment.ltv_cents !== undefined)
184
+ q = q.contains('custom_properties', { payment: { ltv_cents: payment.ltv_cents } });
185
+ if (payment.subscription_status)
186
+ q = q.contains('custom_properties', { payment: { subscription_status: payment.subscription_status } });
187
+ q = applyPaymentDateRule(q, 'trial_ends_at', payment.trial_ends_at);
188
+ q = applyPaymentDateRule(q, 'churned_at', payment.churned_at);
189
+ q = applyPaymentDateRule(q, 'paid_at', payment.paid_at);
190
+ return q;
191
+ }
192
+ function applyPaymentDateRule(query, field, rule) {
193
+ if (!rule)
194
+ return query;
195
+ if (typeof rule === 'string') {
196
+ return query.contains('custom_properties', { payment: { [field]: rule } });
197
+ }
198
+ let q = query;
199
+ const column = `custom_properties->payment->>${field}`;
200
+ if (typeof rule.within_days === 'number') {
201
+ const start = new Date();
202
+ const end = new Date(start.getTime() + rule.within_days * 24 * 60 * 60 * 1000);
203
+ q = q.gte(column, start.toISOString()).lte(column, end.toISOString());
204
+ }
205
+ if (rule.after)
206
+ q = q.gte(column, rule.after);
207
+ if (rule.before)
208
+ q = q.lte(column, rule.before);
209
+ return q;
210
+ }
211
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
212
+ const j = (data) => txt(JSON.stringify(data, null, 2));
213
+ export function registerSegmentTools(server) {
214
+ server.registerTool('create_segment', {
215
+ description: 'Create a saved segment with filter rules. Segments evaluate dynamically at send time.',
216
+ inputSchema: {
217
+ name: z.string().min(1).describe('Segment name (unique per project)'),
218
+ description: z.string().optional(),
219
+ rules: z.record(z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, tags, custom_properties.<key>, payment.<key>.'),
220
+ },
221
+ }, async ({ name, description, rules }) => {
222
+ const { data, error } = await db()
223
+ .from('segments')
224
+ .insert({ project_id: getProjectId(), name, description, rules: normalizeSegmentRulesToTree(rules) })
225
+ .select('id, name, description, rules, created_at')
226
+ .single();
227
+ if (error)
228
+ return txt(`Error: ${error.message}`);
229
+ const ids = await getSegmentContactIds(data.rules);
230
+ return j({ ...data, estimated_contacts: ids.length });
231
+ });
232
+ server.registerTool('list_segments', {
233
+ description: 'List all saved segments with their contact counts',
234
+ inputSchema: {},
235
+ }, async () => {
236
+ const { data, error } = await db()
237
+ .from('segments')
238
+ .select('id, name, description, rules, created_at, updated_at')
239
+ .eq('project_id', getProjectId())
240
+ .order('created_at', { ascending: false });
241
+ if (error)
242
+ return txt(`Error: ${error.message}`);
243
+ const segments = [];
244
+ for (const seg of data ?? []) {
245
+ const ids = await getSegmentContactIds(seg.rules);
246
+ segments.push({ ...seg, contact_count: ids.length });
247
+ }
248
+ return j(segments);
249
+ });
250
+ server.registerTool('update_segment', {
251
+ description: 'Update a segment name, description, or rules',
252
+ inputSchema: {
253
+ segment_id: z.string().uuid(),
254
+ name: z.string().optional(),
255
+ description: z.string().optional(),
256
+ rules: z.record(z.unknown()).optional(),
257
+ },
258
+ }, async ({ segment_id, name, description, rules }) => {
259
+ const updates = { updated_at: new Date().toISOString() };
260
+ if (name)
261
+ updates.name = name;
262
+ if (description !== undefined)
263
+ updates.description = description;
264
+ if (rules)
265
+ updates.rules = normalizeSegmentRulesToTree(rules);
266
+ const { data, error } = await db()
267
+ .from('segments')
268
+ .update(updates)
269
+ .eq('id', segment_id)
270
+ .eq('project_id', getProjectId())
271
+ .select('id, name, description, rules, updated_at')
272
+ .single();
273
+ if (error)
274
+ return txt(`Error: ${error.message}`);
275
+ return j(data);
276
+ });
277
+ server.registerTool('delete_segment', {
278
+ description: 'Delete a saved segment. Campaigns using this segment will lose targeting.',
279
+ inputSchema: {
280
+ segment_id: z.string().uuid(),
281
+ },
282
+ }, async ({ segment_id }) => {
283
+ const { error } = await db()
284
+ .from('segments')
285
+ .delete()
286
+ .eq('id', segment_id)
287
+ .eq('project_id', getProjectId());
288
+ if (error)
289
+ return txt(`Error: ${error.message}`);
290
+ return j({ deleted: true, segment_id });
291
+ });
292
+ server.registerTool('preview_segment', {
293
+ description: 'Preview which contacts match a saved segment or inline rules. Returns count and sample.',
294
+ inputSchema: {
295
+ segment_id: z.string().uuid().optional(),
296
+ rules: z.record(z.unknown()).optional(),
297
+ },
298
+ }, async ({ segment_id, rules }) => {
299
+ let segRules;
300
+ if (segment_id) {
301
+ const { data: seg, error } = await db()
302
+ .from('segments')
303
+ .select('rules')
304
+ .eq('id', segment_id)
305
+ .eq('project_id', getProjectId())
306
+ .maybeSingle();
307
+ if (error || !seg)
308
+ return txt('Error: segment not found');
309
+ segRules = seg.rules;
310
+ }
311
+ else if (rules) {
312
+ segRules = rules;
313
+ }
314
+ else {
315
+ return txt('Provide either segment_id or rules.');
316
+ }
317
+ const ids = await getSegmentContactIds(segRules);
318
+ const sample = ids.slice(0, 10);
319
+ let sampleContacts = [];
320
+ if (sample.length) {
321
+ const { data } = await db()
322
+ .from('subscribers')
323
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, tags')
324
+ .in('id', sample);
325
+ sampleContacts = data ?? [];
326
+ }
327
+ return j({ total_contacts: ids.length, sample: sampleContacts });
328
+ });
329
+ server.registerTool('create_segment_nl', {
330
+ description: 'Create a segment from a natural language description. Parses description into filter rules using AI, previews contacts, creates on confirmation.',
331
+ inputSchema: {
332
+ name: z.string().min(1),
333
+ description: z.string().describe('Natural language audience description'),
334
+ confirmed: z.boolean().optional().describe('Set true to create after previewing'),
335
+ },
336
+ }, async ({ name, description, confirmed }) => {
337
+ const apiKey = process.env.ANTHROPIC_API_KEY;
338
+ if (!apiKey)
339
+ return txt('Error: ANTHROPIC_API_KEY not configured');
340
+ const anthropic = new Anthropic({ apiKey });
341
+ const resp = await anthropic.messages.create({
342
+ model: getAnthropicModelId(),
343
+ max_tokens: 512,
344
+ system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
345
+ messages: [{ role: 'user', content: description }],
346
+ });
347
+ const tb = resp.content.find((b) => b.type === 'text');
348
+ const raw = tb && 'text' in tb ? tb.text : '{}';
349
+ let parsedRules;
350
+ try {
351
+ parsedRules = normalizeSegmentRulesToTree(JSON.parse(raw.replace(/```json?\n?|```/g, '').trim()));
352
+ }
353
+ catch {
354
+ return txt(`Error: Failed to parse rules from "${description}"`);
355
+ }
356
+ const ids = await getSegmentContactIds(parsedRules);
357
+ const sample = ids.slice(0, 5);
358
+ let sampleContacts = [];
359
+ if (sample.length) {
360
+ const { data } = await db()
361
+ .from('subscribers')
362
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, tags')
363
+ .in('id', sample);
364
+ sampleContacts = data ?? [];
365
+ }
366
+ if (!confirmed) {
367
+ return j({
368
+ confirmation_required: true,
369
+ parsed_rules: parsedRules,
370
+ total_contacts: ids.length,
371
+ sample: sampleContacts,
372
+ message: `Parsed "${description}" into rules. ${ids.length} contacts match. Call again with confirmed: true to create.`,
373
+ });
374
+ }
375
+ const { data, error } = await db()
376
+ .from('segments')
377
+ .insert({ project_id: getProjectId(), name, description, rules: parsedRules })
378
+ .select('id, name, description, rules, created_at')
379
+ .single();
380
+ if (error)
381
+ return txt(`Error: ${error.message}`);
382
+ return j({ ...data, estimated_contacts: ids.length });
383
+ });
384
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSiteTools(server: McpServer): void;