@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,182 @@
1
+ import { z } from 'zod';
2
+ import { db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { checkPlanLimit } from '../plan-limits.js';
5
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
6
+ const j = (data) => txt(JSON.stringify(data, null, 2));
7
+ const LOCAL_PART_RE = /^[a-z0-9._-]+$/;
8
+ async function resolveSenderDomain(input) {
9
+ const projectId = getProjectId();
10
+ if (input.domain_id) {
11
+ const { data: domain, error } = await db()
12
+ .from('domains')
13
+ .select('id, name')
14
+ .eq('project_id', projectId)
15
+ .eq('id', input.domain_id)
16
+ .maybeSingle();
17
+ if (error)
18
+ return { error: error.message };
19
+ if (!domain?.id || !domain.name)
20
+ return { error: 'Domain not found.' };
21
+ const localPart = input.local_part?.trim().toLowerCase();
22
+ if (!localPart || !LOCAL_PART_RE.test(localPart)) {
23
+ return { error: 'local_part must contain only letters, numbers, dots, underscores, or hyphens.' };
24
+ }
25
+ const domainName = String(domain.name).toLowerCase();
26
+ return {
27
+ domain_id: String(domain.id),
28
+ sending_domain: domainName,
29
+ from_email: `${localPart}@${domainName}`,
30
+ is_shared_domain: false,
31
+ };
32
+ }
33
+ const sendingDomain = input.sending_domain?.trim().toLowerCase();
34
+ if (!sendingDomain)
35
+ return { error: 'domain_id or sending_domain is required.' };
36
+ if (!input.from_email?.trim())
37
+ return { error: 'from_email is required when using sending_domain.' };
38
+ const { data: existing, error: existingError } = await db()
39
+ .from('domains')
40
+ .select('id, name')
41
+ .eq('project_id', projectId)
42
+ .eq('name', sendingDomain)
43
+ .maybeSingle();
44
+ if (existingError)
45
+ return { error: existingError.message };
46
+ if (existing?.id) {
47
+ return {
48
+ domain_id: String(existing.id),
49
+ sending_domain: sendingDomain,
50
+ from_email: input.from_email.trim(),
51
+ is_shared_domain: false,
52
+ };
53
+ }
54
+ const { data: created, error: createError } = await db()
55
+ .from('domains')
56
+ .insert({ project_id: projectId, name: sendingDomain, provider: 'unknown', status: 'legacy_imported' })
57
+ .select('id, name')
58
+ .single();
59
+ if (createError)
60
+ return { error: createError.message };
61
+ return {
62
+ domain_id: String(created.id),
63
+ sending_domain: sendingDomain,
64
+ from_email: input.from_email.trim(),
65
+ is_shared_domain: false,
66
+ };
67
+ }
68
+ const createSenderSchema = {
69
+ name: z.string().min(1),
70
+ slug: z.string().min(1).describe('URL-safe identifier'),
71
+ domain_id: z.string().uuid().optional().describe('Preferred verified domain UUID'),
72
+ local_part: z.string().optional().describe('Local part before @ when using domain_id'),
73
+ sending_domain: z.string().min(1).optional().describe('Legacy domain text'),
74
+ from_email: z.string().email().optional().describe('Legacy full from address'),
75
+ from_name: z.string().min(1),
76
+ reply_to: z.string().email().optional(),
77
+ };
78
+ async function createSender({ name, slug, domain_id, local_part, sending_domain, from_email, from_name, reply_to, }) {
79
+ const limit = await checkPlanLimit('sites');
80
+ if (!limit.allowed)
81
+ return txt(`Site limit reached (${limit.current}/${limit.limit}).`);
82
+ const domain = await resolveSenderDomain({ domain_id, local_part, sending_domain, from_email });
83
+ if ('error' in domain)
84
+ return txt(`Error: ${domain.error}`);
85
+ const { data, error } = await db()
86
+ .from('sites')
87
+ .insert({
88
+ project_id: getProjectId(),
89
+ name,
90
+ slug,
91
+ sending_domain: domain.sending_domain,
92
+ domain_id: domain.domain_id,
93
+ is_shared_domain: domain.is_shared_domain,
94
+ from_email: domain.from_email,
95
+ from_name,
96
+ reply_to: reply_to ?? null,
97
+ })
98
+ .select()
99
+ .single();
100
+ if (error)
101
+ return txt(`Error: ${error.message}`);
102
+ return j(data);
103
+ }
104
+ export function registerSiteTools(server) {
105
+ server.registerTool('get_brand_voice', {
106
+ description: 'Read the brand voice configuration for a sender/site.',
107
+ inputSchema: {
108
+ site_id: z.string().uuid(),
109
+ },
110
+ }, async ({ site_id }) => {
111
+ const { data, error } = await db()
112
+ .from('sites')
113
+ .select('id, name, brand_voice')
114
+ .eq('project_id', getProjectId())
115
+ .eq('id', site_id)
116
+ .maybeSingle();
117
+ if (error)
118
+ return txt(`Error: ${error.message}`);
119
+ if (!data)
120
+ return j({ error: 'Site not found', code: 'NOT_FOUND' });
121
+ return j(data);
122
+ });
123
+ server.registerTool('update_brand_voice', {
124
+ description: 'Replace the brand voice configuration for a sender/site. Stores JSON in sites.brand_voice. Use the canonical Brand Brain keys so the dashboard and email generation can read the values: mission (string), audience (string), differentiators (string), tone_words (string[], max 8), forbidden_phrases (string[], max 20), default_signoff_name (string), ps_line (string). This REPLACES the whole object and overrides the org default — read the current value first if you only mean to change one field.',
125
+ inputSchema: {
126
+ site_id: z.string().uuid(),
127
+ brand_voice: z.record(z.string(), z.unknown()).describe('Brand voice JSON using the canonical keys: mission, audience, differentiators, tone_words, forbidden_phrases, default_signoff_name, ps_line.'),
128
+ },
129
+ }, async ({ site_id, brand_voice }) => {
130
+ const { data, error } = await db()
131
+ .from('sites')
132
+ .update({ brand_voice, updated_at: new Date().toISOString() })
133
+ .eq('project_id', getProjectId())
134
+ .eq('id', site_id)
135
+ .select('id, name, brand_voice, updated_at')
136
+ .single();
137
+ if (error)
138
+ return txt(`Error: ${error.message}`);
139
+ return j(data);
140
+ });
141
+ server.registerTool('create_site', {
142
+ description: 'Create a new sender. Accepts preferred domain_id + local_part or legacy sending_domain + from_email.',
143
+ inputSchema: createSenderSchema,
144
+ }, createSender);
145
+ server.registerTool('create_sender', {
146
+ description: 'Create a new sender under an existing verified domain.',
147
+ inputSchema: createSenderSchema,
148
+ }, createSender);
149
+ server.registerTool('update_site', {
150
+ description: 'Update a site name, from address, or other settings',
151
+ inputSchema: {
152
+ site_id: z.string().uuid(),
153
+ name: z.string().optional(),
154
+ from_email: z.string().email().optional(),
155
+ from_name: z.string().optional(),
156
+ active: z.boolean().optional(),
157
+ },
158
+ }, async ({ site_id, name, from_email, from_name, active }) => {
159
+ const updates = {};
160
+ if (name)
161
+ updates.name = name;
162
+ if (from_email)
163
+ updates.from_email = from_email;
164
+ if (from_name)
165
+ updates.from_name = from_name;
166
+ if (active !== undefined)
167
+ updates.active = active;
168
+ if (!Object.keys(updates).length)
169
+ return txt('No fields to update.');
170
+ updates.updated_at = new Date().toISOString();
171
+ const { data, error } = await db()
172
+ .from('sites')
173
+ .update(updates)
174
+ .eq('id', site_id)
175
+ .eq('project_id', getProjectId())
176
+ .select()
177
+ .single();
178
+ if (error)
179
+ return txt(`Error: ${error.message}`);
180
+ return j(data);
181
+ });
182
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSmsTools(server: McpServer): void;
@@ -0,0 +1,489 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { z } from 'zod';
3
+ import { decrypt } from '../crypto.js';
4
+ import { db } from '../db.js';
5
+ import { getProjectId } from '../project.js';
6
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
7
+ const j = (data) => txt(JSON.stringify(data, null, 2));
8
+ const GSM_BASIC = "@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
9
+ const GSM_EXTENDED = new Set(['^', '{', '}', '\\', '[', '~', ']', '|', '€']);
10
+ const QUIET_HOURS = {
11
+ default: { start: 8, end: 20 },
12
+ FL: { start: 8, end: 20 },
13
+ CT: { start: 8, end: 20 },
14
+ OK: { start: 8, end: 20 },
15
+ WA: { start: 8, end: 20 },
16
+ TX: { start: 9, end: 21, sundayStart: 12 },
17
+ };
18
+ const AREA_CODES = {
19
+ '201': { timeZone: 'America/New_York', state: 'NJ' },
20
+ '202': { timeZone: 'America/New_York', state: 'DC' },
21
+ '203': { timeZone: 'America/New_York', state: 'CT' },
22
+ '205': { timeZone: 'America/Chicago', state: 'AL' },
23
+ '206': { timeZone: 'America/Los_Angeles', state: 'WA' },
24
+ '212': { timeZone: 'America/New_York', state: 'NY' },
25
+ '213': { timeZone: 'America/Los_Angeles', state: 'CA' },
26
+ '214': { timeZone: 'America/Chicago', state: 'TX' },
27
+ '305': { timeZone: 'America/New_York', state: 'FL' },
28
+ '310': { timeZone: 'America/Los_Angeles', state: 'CA' },
29
+ '312': { timeZone: 'America/Chicago', state: 'IL' },
30
+ '404': { timeZone: 'America/New_York', state: 'GA' },
31
+ '405': { timeZone: 'America/Chicago', state: 'OK' },
32
+ '415': { timeZone: 'America/Los_Angeles', state: 'CA' },
33
+ '469': { timeZone: 'America/Chicago', state: 'TX' },
34
+ '512': { timeZone: 'America/Chicago', state: 'TX' },
35
+ '602': { timeZone: 'America/Phoenix', state: 'AZ' },
36
+ '617': { timeZone: 'America/New_York', state: 'MA' },
37
+ '702': { timeZone: 'America/Los_Angeles', state: 'NV' },
38
+ '713': { timeZone: 'America/Chicago', state: 'TX' },
39
+ '714': { timeZone: 'America/Los_Angeles', state: 'CA' },
40
+ '808': { timeZone: 'Pacific/Honolulu', state: 'HI' },
41
+ '817': { timeZone: 'America/Chicago', state: 'TX' },
42
+ '832': { timeZone: 'America/Chicago', state: 'TX' },
43
+ '917': { timeZone: 'America/New_York', state: 'NY' },
44
+ '972': { timeZone: 'America/Chicago', state: 'TX' },
45
+ };
46
+ function normalizePhoneE164(value) {
47
+ const digits = String(value ?? '').replace(/[^\d+]/g, '');
48
+ if (!digits)
49
+ return null;
50
+ if (digits.startsWith('+1') && digits.length === 12)
51
+ return digits;
52
+ const numeric = digits.replace(/\D/g, '');
53
+ if (numeric.length === 10)
54
+ return `+1${numeric}`;
55
+ if (numeric.length === 11 && numeric.startsWith('1'))
56
+ return `+${numeric}`;
57
+ return null;
58
+ }
59
+ function inferTimezoneFromPhone(phone) {
60
+ const normalized = normalizePhoneE164(phone);
61
+ const area = normalized?.slice(2, 5) ?? '';
62
+ return AREA_CODES[area] ?? { timeZone: 'America/New_York', state: 'default' };
63
+ }
64
+ function detectSmsEncoding(body) {
65
+ for (const char of body) {
66
+ if (GSM_BASIC.includes(char) || GSM_EXTENDED.has(char))
67
+ continue;
68
+ return 'UCS-2';
69
+ }
70
+ return 'GSM-7';
71
+ }
72
+ function countSmsSegments(body) {
73
+ const text = String(body ?? '');
74
+ const encoding = detectSmsEncoding(text);
75
+ if (!text.length)
76
+ return { encoding, segments: 1, length: 0 };
77
+ if (encoding === 'UCS-2') {
78
+ const chars = Array.from(text).length;
79
+ return { encoding, segments: chars <= 70 ? 1 : Math.ceil(chars / 67), length: chars };
80
+ }
81
+ let units = 0;
82
+ for (const char of text)
83
+ units += GSM_EXTENDED.has(char) ? 2 : 1;
84
+ return { encoding, segments: units <= 160 ? 1 : Math.ceil(units / 153), length: units };
85
+ }
86
+ function parseCredentials(value) {
87
+ return JSON.parse(decrypt(value));
88
+ }
89
+ async function readJsonSafe(res) {
90
+ return res.json().catch(() => ({}));
91
+ }
92
+ async function sendViaProvider(provider, credentialsEnc, fromNumber, toNumber, body, webhookUrl, trackingToken) {
93
+ const credentials = parseCredentials(credentialsEnc);
94
+ const meta = countSmsSegments(body);
95
+ if (provider === 'twilio') {
96
+ if (!credentials.account_sid || !credentials.auth_token)
97
+ throw new Error('Twilio credentials missing');
98
+ const auth = `Basic ${Buffer.from(`${credentials.account_sid}:${credentials.auth_token}`).toString('base64')}`;
99
+ const url = `https://api.twilio.com/2010-04-01/Accounts/${credentials.account_sid}/Messages.json`;
100
+ const payload = new URLSearchParams({
101
+ From: fromNumber,
102
+ To: toNumber,
103
+ Body: body,
104
+ StatusCallback: webhookUrl,
105
+ ProvideFeedback: 'true',
106
+ SmartEncoded: 'true',
107
+ });
108
+ const res = await fetch(url, {
109
+ method: 'POST',
110
+ headers: { Authorization: auth, 'Content-Type': 'application/x-www-form-urlencoded' },
111
+ body: payload.toString(),
112
+ });
113
+ const json = await readJsonSafe(res);
114
+ if (!res.ok)
115
+ throw new Error(json?.message ?? `Twilio send failed (${res.status})`);
116
+ return {
117
+ provider_message_id: String(json?.sid ?? trackingToken),
118
+ estimated_cost_cents: Number((0.83 * meta.segments).toFixed(4)),
119
+ segments: meta.segments,
120
+ encoding: meta.encoding,
121
+ };
122
+ }
123
+ if (!credentials.api_key)
124
+ throw new Error('Telnyx API key missing');
125
+ const res = await fetch('https://api.telnyx.com/v2/messages', {
126
+ method: 'POST',
127
+ headers: {
128
+ Authorization: `Bearer ${credentials.api_key}`,
129
+ 'Content-Type': 'application/json',
130
+ },
131
+ body: JSON.stringify({
132
+ from: fromNumber,
133
+ to: toNumber,
134
+ text: body,
135
+ webhook_url: webhookUrl,
136
+ client_state: trackingToken,
137
+ }),
138
+ });
139
+ const json = await readJsonSafe(res);
140
+ if (!res.ok)
141
+ throw new Error(json?.errors?.[0]?.detail ?? `Telnyx send failed (${res.status})`);
142
+ return {
143
+ provider_message_id: String(json?.data?.id ?? trackingToken),
144
+ estimated_cost_cents: Number((0.4 * meta.segments).toFixed(4)),
145
+ segments: meta.segments,
146
+ encoding: meta.encoding,
147
+ };
148
+ }
149
+ async function getDefaultProvider(projectId) {
150
+ const { data, error } = await db()
151
+ .from('sms_providers')
152
+ .select('id, provider, status, credentials_enc, default_from_number, brand_name')
153
+ .eq('project_id', projectId)
154
+ .eq('is_default', true)
155
+ .maybeSingle();
156
+ if (error)
157
+ throw new Error(error.message);
158
+ return data ?? null;
159
+ }
160
+ async function getProjectOrgId(projectId) {
161
+ const { data, error } = await db().from('projects').select('org_id').eq('id', projectId).maybeSingle();
162
+ if (error)
163
+ throw new Error(error.message);
164
+ return data?.org_id ?? null;
165
+ }
166
+ async function checkConsentGate(projectId, contactId, phone) {
167
+ const normalizedPhone = normalizePhoneE164(phone);
168
+ if (!normalizedPhone) {
169
+ return { ok: false, normalizedPhone: null, reasonCode: 'invalid_phone', reasonDetail: 'SMS requires a valid US E.164 number.' };
170
+ }
171
+ if (contactId) {
172
+ const { data: contact } = await db()
173
+ .from('subscribers')
174
+ .select('sms_opt_in_status')
175
+ .eq('project_id', projectId)
176
+ .eq('id', contactId)
177
+ .maybeSingle();
178
+ if (!contact || contact.sms_opt_in_status !== 'opted_in') {
179
+ return { ok: false, normalizedPhone, reasonCode: 'missing_sms_consent', reasonDetail: 'Contact does not have SMS opt-in on record.' };
180
+ }
181
+ }
182
+ const { data: suppression } = await db()
183
+ .from('suppressions')
184
+ .select('id')
185
+ .eq('project_id', projectId)
186
+ .eq('phone_e164', normalizedPhone)
187
+ .in('channel', ['sms', 'all'])
188
+ .maybeSingle();
189
+ if (suppression) {
190
+ return { ok: false, normalizedPhone, reasonCode: 'suppressed', reasonDetail: 'This number is suppressed for SMS.' };
191
+ }
192
+ const provider = await getDefaultProvider(projectId);
193
+ if (!provider || provider.status !== 'active') {
194
+ return { ok: false, normalizedPhone, reasonCode: 'provider_inactive', reasonDetail: 'No active default SMS provider is configured.' };
195
+ }
196
+ const { timeZone, state } = inferTimezoneFromPhone(normalizedPhone);
197
+ const quiet = QUIET_HOURS[state] ?? QUIET_HOURS.default;
198
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', weekday: 'short', hour12: false }).formatToParts(new Date());
199
+ const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? '12');
200
+ const weekday = parts.find((part) => part.type === 'weekday')?.value ?? 'Mon';
201
+ const start = weekday === 'Sun' && quiet.sundayStart != null ? quiet.sundayStart : quiet.start;
202
+ if (hour < start || hour >= quiet.end) {
203
+ return { ok: false, normalizedPhone, reasonCode: 'quiet_hours', reasonDetail: `Recipient local quiet hours block sending until ${start}:00 in ${timeZone}.` };
204
+ }
205
+ return { ok: true, normalizedPhone, provider };
206
+ }
207
+ async function logBlocked(projectId, orgId, siteId, contactId, phone, reasonCode, reasonDetail) {
208
+ await db().from('sms_send_blocked').insert({
209
+ org_id: orgId,
210
+ project_id: projectId,
211
+ site_id: siteId,
212
+ contact_id: contactId,
213
+ phone_e164: phone,
214
+ reason_code: reasonCode,
215
+ reason_detail: reasonDetail,
216
+ payload: {},
217
+ });
218
+ }
219
+ export function registerSmsTools(server) {
220
+ server.registerTool('list_sms_log', {
221
+ description: 'List recent SMS sends with delivery status and cost.',
222
+ inputSchema: {
223
+ status: z.enum(['queued', 'sending', 'sent', 'delivered', 'failed', 'undelivered']).optional(),
224
+ limit: z.number().int().min(1).max(100).optional().default(25),
225
+ },
226
+ }, async ({ status, limit }) => {
227
+ let query = db()
228
+ .from('sms_log')
229
+ .select('id, campaign_id, contact_id, provider, from_number, to_number, body, segments_count, encoding, status, cost_cents, classification, created_at, sent_at, delivered_at, failed_at')
230
+ .eq('project_id', getProjectId())
231
+ .order('created_at', { ascending: false })
232
+ .limit(limit ?? 25);
233
+ if (status)
234
+ query = query.eq('status', status);
235
+ const { data, error } = await query;
236
+ if (error)
237
+ return j({ error: error.message });
238
+ return j({ rows: data ?? [] });
239
+ });
240
+ server.registerTool('list_sms_inbound', {
241
+ description: 'List recent inbound SMS replies and their interpreted intent (stop/help/reply).',
242
+ inputSchema: {
243
+ interpreted_as: z.enum(['stop', 'help', 'start', 'reply', 'unknown']).optional(),
244
+ limit: z.number().int().min(1).max(100).optional().default(25),
245
+ },
246
+ }, async ({ interpreted_as, limit }) => {
247
+ let query = db()
248
+ .from('sms_inbound')
249
+ .select('id, provider, from_number, to_number, body, interpreted_as, contact_id, processed_at, created_at')
250
+ .eq('project_id', getProjectId())
251
+ .order('created_at', { ascending: false })
252
+ .limit(limit ?? 25);
253
+ if (interpreted_as)
254
+ query = query.eq('interpreted_as', interpreted_as);
255
+ const { data, error } = await query;
256
+ if (error)
257
+ return j({ error: error.message });
258
+ return j({ rows: data ?? [] });
259
+ });
260
+ server.registerTool('get_sms_provider_status', {
261
+ description: "Return the connection status, brand info, and recent error rate for the tenant's SMS provider.",
262
+ inputSchema: {},
263
+ }, async () => {
264
+ const projectId = getProjectId();
265
+ const [provider, recent] = await Promise.all([
266
+ getDefaultProvider(projectId),
267
+ db().from('sms_log').select('status, created_at').eq('project_id', projectId).order('created_at', { ascending: false }).limit(50),
268
+ ]);
269
+ if (recent.error)
270
+ return j({ error: recent.error.message });
271
+ const rows = recent.data ?? [];
272
+ const failures = rows.filter((row) => row.status === 'failed' || row.status === 'undelivered').length;
273
+ return j({
274
+ provider,
275
+ recent_message_count: rows.length,
276
+ recent_failure_count: failures,
277
+ recent_error_rate: rows.length ? Number((failures / rows.length).toFixed(3)) : 0,
278
+ });
279
+ });
280
+ server.registerTool('set_contact_sms_consent', {
281
+ description: 'Manually record an opt-in or opt-out for a contact (e.g., from a paper form or phone conversation).',
282
+ inputSchema: {
283
+ contact_id: z.string().uuid(),
284
+ status: z.enum(['opt_in', 'opt_out']),
285
+ source: z.string().optional(),
286
+ consent_text: z.string().optional(),
287
+ reason: z.string().optional(),
288
+ },
289
+ }, async ({ contact_id, status, source, consent_text, reason }) => {
290
+ const projectId = getProjectId();
291
+ const orgId = await getProjectOrgId(projectId);
292
+ if (!orgId)
293
+ return j({ error: 'Project org not found' });
294
+ const { data: contact, error: contactError } = await db()
295
+ .from('subscribers')
296
+ .select('id, phone_e164')
297
+ .eq('project_id', projectId)
298
+ .eq('id', contact_id)
299
+ .maybeSingle();
300
+ if (contactError)
301
+ return j({ error: contactError.message });
302
+ if (!contact?.phone_e164)
303
+ return j({ error: 'Contact not found or missing phone number', code: 'NOT_FOUND' });
304
+ const now = new Date().toISOString();
305
+ const patch = status === 'opt_out'
306
+ ? { sms_opt_in_status: 'opted_out', sms_opt_out_at: now }
307
+ : { sms_opt_in_status: 'opted_in', sms_opt_in_at: now, sms_opt_out_at: null };
308
+ const { error: updateError } = await db().from('subscribers').update(patch).eq('project_id', projectId).eq('id', contact_id);
309
+ if (updateError)
310
+ return j({ error: updateError.message });
311
+ const { error: consentError } = await db().from('consent_events').insert({
312
+ org_id: orgId,
313
+ project_id: projectId,
314
+ contact_id,
315
+ address: contact.phone_e164,
316
+ channel: 'sms',
317
+ event_type: status,
318
+ source: source ?? 'manual',
319
+ consent_text: consent_text ?? null,
320
+ metadata: {},
321
+ });
322
+ if (consentError)
323
+ return j({ error: consentError.message });
324
+ if (status === 'opt_out') {
325
+ const { error: suppressionError } = await db().from('suppressions').upsert({
326
+ org_id: orgId,
327
+ project_id: projectId,
328
+ channel: 'sms',
329
+ phone_e164: contact.phone_e164,
330
+ reason: reason ?? 'manual_opt_out',
331
+ }, { onConflict: 'project_id,channel,phone_e164' });
332
+ if (suppressionError)
333
+ return j({ error: suppressionError.message });
334
+ }
335
+ else {
336
+ const { error: suppressionError } = await db().from('suppressions').delete().eq('project_id', projectId).eq('channel', 'sms').eq('phone_e164', contact.phone_e164);
337
+ if (suppressionError)
338
+ return j({ error: suppressionError.message });
339
+ }
340
+ return j({ ok: true, contact_id, phone_e164: contact.phone_e164, status });
341
+ });
342
+ server.registerTool('preview_sms_cost', {
343
+ description: 'Given a draft SMS body and a segment, return the segment count, encoding, and estimated total cost.',
344
+ inputSchema: {
345
+ body: z.string().min(1),
346
+ recipient_count: z.number().int().min(0).optional().default(0),
347
+ },
348
+ }, async ({ body, recipient_count }) => {
349
+ const provider = await getDefaultProvider(getProjectId());
350
+ const meta = countSmsSegments(body);
351
+ const rate = provider?.provider === 'twilio' ? 0.83 : 0.4;
352
+ return j({
353
+ provider: provider?.provider ?? null,
354
+ body_length: meta.length,
355
+ encoding: meta.encoding,
356
+ segments_per_message: meta.segments,
357
+ recipient_count: recipient_count ?? 0,
358
+ estimated_total_cost_cents: Number((meta.segments * (recipient_count ?? 0) * rate).toFixed(4)),
359
+ });
360
+ });
361
+ server.registerTool('send_sms_campaign', {
362
+ description: "Launch an SMS campaign or broadcast to a segment. Same shape as email campaigns but with channel='sms'.",
363
+ inputSchema: {
364
+ site_id: z.string().uuid(),
365
+ name: z.string().min(1).max(200),
366
+ body: z.string().min(1),
367
+ type: z.enum(['broadcast', 'sequence', 'triggered']).optional().default('broadcast'),
368
+ segment_id: z.string().uuid().optional(),
369
+ scheduled_at: z.string().optional(),
370
+ activate: z.boolean().optional().default(false),
371
+ },
372
+ }, async ({ site_id, name, body, type, segment_id, scheduled_at, activate }) => {
373
+ const projectId = getProjectId();
374
+ const { data: campaign, error: campaignError } = await db()
375
+ .from('campaigns')
376
+ .insert({
377
+ project_id: projectId,
378
+ site_id,
379
+ name,
380
+ type,
381
+ status: activate ? 'active' : 'draft',
382
+ channel: 'sms',
383
+ send_at: scheduled_at ?? null,
384
+ })
385
+ .select('id, name, type, status, channel, site_id, send_at')
386
+ .single();
387
+ if (campaignError || !campaign)
388
+ return j({ error: campaignError?.message ?? 'Failed to create campaign' });
389
+ const { data: step, error: stepError } = await db()
390
+ .from('campaign_steps')
391
+ .insert({
392
+ project_id: projectId,
393
+ campaign_id: campaign.id,
394
+ site_id,
395
+ step_order: 0,
396
+ channel: 'sms',
397
+ subject: '',
398
+ body_sms: body,
399
+ body_text: body,
400
+ status: 'active',
401
+ })
402
+ .select('id, step_order, channel')
403
+ .single();
404
+ if (stepError)
405
+ return j({ error: stepError.message, campaign });
406
+ return j({
407
+ ok: true,
408
+ campaign,
409
+ first_step: step,
410
+ segment_id: segment_id ?? null,
411
+ note: segment_id ? 'Campaign created. Enroll the target segment through the campaign workflow to start delivery.' : 'Campaign created as SMS-ready.',
412
+ });
413
+ });
414
+ server.registerTool('send_sms', {
415
+ description: 'Send a single SMS through the active SMS provider to a contact or raw phone number. Validates consent and quiet hours before sending.',
416
+ inputSchema: {
417
+ body: z.string().min(1),
418
+ to_number: z.string().optional(),
419
+ contact_id: z.string().uuid().optional(),
420
+ site_id: z.string().uuid().optional(),
421
+ classification: z.enum(['marketing', 'transactional', 'conversational']).optional().default('marketing'),
422
+ },
423
+ }, async ({ body, to_number, contact_id, site_id, classification }) => {
424
+ const projectId = getProjectId();
425
+ const orgId = await getProjectOrgId(projectId);
426
+ if (!orgId)
427
+ return j({ error: 'Project org not found' });
428
+ let targetNumber = to_number ?? null;
429
+ if (contact_id && !targetNumber) {
430
+ const { data: contact, error: contactError } = await db().from('subscribers').select('phone_e164').eq('project_id', projectId).eq('id', contact_id).maybeSingle();
431
+ if (contactError)
432
+ return j({ error: contactError.message });
433
+ targetNumber = contact?.phone_e164 ?? null;
434
+ }
435
+ if (!targetNumber)
436
+ return j({ error: 'to_number or contact_id is required' });
437
+ const gate = await checkConsentGate(projectId, contact_id ?? null, targetNumber);
438
+ if (!gate.ok) {
439
+ await logBlocked(projectId, orgId, site_id ?? null, contact_id ?? null, gate.normalizedPhone ?? targetNumber, gate.reasonCode ?? 'blocked', gate.reasonDetail ?? 'SMS send blocked');
440
+ return j(gate);
441
+ }
442
+ const meta = countSmsSegments(body);
443
+ const trackingToken = randomUUID();
444
+ const { data: logRow, error: logError } = await db()
445
+ .from('sms_log')
446
+ .insert({
447
+ org_id: orgId,
448
+ project_id: projectId,
449
+ site_id: site_id ?? null,
450
+ contact_id: contact_id ?? null,
451
+ provider: gate.provider.provider,
452
+ from_number: gate.provider.default_from_number,
453
+ to_number: gate.normalizedPhone,
454
+ body,
455
+ segments_count: meta.segments,
456
+ encoding: meta.encoding,
457
+ tracking_token: trackingToken,
458
+ classification: classification ?? 'marketing',
459
+ status: 'sending',
460
+ })
461
+ .select('id')
462
+ .single();
463
+ if (logError || !logRow)
464
+ return j({ error: logError?.message ?? 'Failed to create sms_log row' });
465
+ const sendResult = await sendViaProvider(gate.provider.provider, gate.provider.credentials_enc, gate.provider.default_from_number, gate.normalizedPhone, body, `${process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'}/api/sms/inbound/${gate.provider.provider}`, trackingToken);
466
+ const { error: updateError } = await db()
467
+ .from('sms_log')
468
+ .update({
469
+ provider_message_id: sendResult.provider_message_id,
470
+ cost_cents: sendResult.estimated_cost_cents,
471
+ segments_count: sendResult.segments,
472
+ encoding: sendResult.encoding,
473
+ status: 'sent',
474
+ sent_at: new Date().toISOString(),
475
+ })
476
+ .eq('id', logRow.id);
477
+ if (updateError)
478
+ return j({ error: updateError.message });
479
+ return j({
480
+ ok: true,
481
+ sms_log_id: logRow.id,
482
+ provider: gate.provider.provider,
483
+ provider_message_id: sendResult.provider_message_id,
484
+ segments: sendResult.segments,
485
+ encoding: sendResult.encoding,
486
+ estimated_cost_cents: sendResult.estimated_cost_cents,
487
+ });
488
+ });
489
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSocialPostTools(server: McpServer): void;