@sendinel/mcp-server 1.0.20 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/tools/contacts.js +104 -74
- package/package.json +1 -1
package/build/tools/contacts.js
CHANGED
|
@@ -14,6 +14,7 @@ import { diffCustomProperties } from '../lib/custom-properties-diff.js';
|
|
|
14
14
|
import Anthropic from '@anthropic-ai/sdk';
|
|
15
15
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
16
16
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
17
|
+
const err = (text) => ({ ...txt(text), isError: true });
|
|
17
18
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
18
19
|
const CUSTOM_FIELD_TYPES = [
|
|
19
20
|
'email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date',
|
|
@@ -183,6 +184,19 @@ function normalizeCompanyDomain(value) {
|
|
|
183
184
|
function normalizeEmail(value) {
|
|
184
185
|
return String(value ?? '').trim().toLowerCase();
|
|
185
186
|
}
|
|
187
|
+
function normalizePhoneE164(value) {
|
|
188
|
+
const digits = String(value ?? '').replace(/[^\d+]/g, '');
|
|
189
|
+
if (!digits)
|
|
190
|
+
return null;
|
|
191
|
+
if (digits.startsWith('+1') && digits.length === 12)
|
|
192
|
+
return digits;
|
|
193
|
+
const numeric = digits.replace(/\D/g, '');
|
|
194
|
+
if (numeric.length === 10)
|
|
195
|
+
return `+1${numeric}`;
|
|
196
|
+
if (numeric.length === 11 && numeric.startsWith('1'))
|
|
197
|
+
return `+${numeric}`;
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
186
200
|
function isPlainObject(value) {
|
|
187
201
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
188
202
|
}
|
|
@@ -398,8 +412,8 @@ const SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, compan
|
|
|
398
412
|
const SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, total_opens, total_clicks, engagement_score, lifecycle_stage';
|
|
399
413
|
const SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
|
|
400
414
|
const SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
|
|
401
|
-
const UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), custom_properties, tags';
|
|
402
|
-
const UPDATE_SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, custom_properties, tags';
|
|
415
|
+
const UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), custom_properties, tags, phone_e164, timezone';
|
|
416
|
+
const UPDATE_SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, custom_properties, tags, phone_e164, timezone';
|
|
403
417
|
const GET_SUBSCRIBER_SELECT_WITH_COMPANY = '*, company:companies(id, name, domain), site_subscriptions(site_id, subscribed)';
|
|
404
418
|
const GET_SUBSCRIBER_SELECT_NO_COMPANY = '*, site_subscriptions(site_id, subscribed)';
|
|
405
419
|
const CONTACT_ENRICHMENT_ADAPTERS = createContactEnrichmentAdapters({
|
|
@@ -1464,7 +1478,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1464
1478
|
idempotentHint: true,
|
|
1465
1479
|
openWorldHint: false,
|
|
1466
1480
|
},
|
|
1467
|
-
description: 'Update a contact\'s email, name, tags, or custom properties',
|
|
1481
|
+
description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties',
|
|
1468
1482
|
inputSchema: {
|
|
1469
1483
|
contact_id: z.string().uuid(),
|
|
1470
1484
|
email: z.string().email().optional(),
|
|
@@ -1475,8 +1489,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1475
1489
|
company_domain: z.string().nullable().optional(),
|
|
1476
1490
|
tags: z.array(z.string()).optional(),
|
|
1477
1491
|
custom_properties: z.record(z.string(), z.unknown()).optional().describe('Merged into existing custom_properties'),
|
|
1492
|
+
phone_e164: z.string().nullable().optional().describe('E.164 phone number, e.g. +15551234567. SMS is US-only for now.'),
|
|
1493
|
+
timezone: z.string().nullable().optional().describe('IANA time zone identifier, e.g. America/Los_Angeles'),
|
|
1478
1494
|
},
|
|
1479
|
-
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties }) => {
|
|
1495
|
+
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
|
|
1480
1496
|
const projectId = getProjectId();
|
|
1481
1497
|
const embedOk = await hasCompaniesEmbed(projectId);
|
|
1482
1498
|
const updates = {};
|
|
@@ -1541,6 +1557,36 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1541
1557
|
...companyPatch,
|
|
1542
1558
|
};
|
|
1543
1559
|
}
|
|
1560
|
+
if (phone_e164 !== undefined) {
|
|
1561
|
+
if (phone_e164 === null) {
|
|
1562
|
+
updates.phone_e164 = null;
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
const normalized = normalizePhoneE164(phone_e164);
|
|
1566
|
+
if (!normalized)
|
|
1567
|
+
return err('Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
|
|
1568
|
+
updates.phone_e164 = normalized;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
if (timezone !== undefined) {
|
|
1572
|
+
if (timezone === null) {
|
|
1573
|
+
updates.timezone = null;
|
|
1574
|
+
}
|
|
1575
|
+
else {
|
|
1576
|
+
// Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
|
|
1577
|
+
// ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
|
|
1578
|
+
if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
|
|
1579
|
+
return err('Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
|
|
1580
|
+
}
|
|
1581
|
+
try {
|
|
1582
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
return err('Invalid timezone: must be a valid IANA time zone identifier.');
|
|
1586
|
+
}
|
|
1587
|
+
updates.timezone = timezone;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1544
1590
|
if (!Object.keys(updates).length)
|
|
1545
1591
|
return txt('No fields to update.');
|
|
1546
1592
|
updates.updated_at = new Date().toISOString();
|
|
@@ -1551,6 +1597,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1551
1597
|
.eq('project_id', projectId)
|
|
1552
1598
|
.select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
|
|
1553
1599
|
.single();
|
|
1600
|
+
if (error?.code === '23505' && updates.phone_e164 !== undefined) {
|
|
1601
|
+
return err('Another contact in this project already has this phone number.');
|
|
1602
|
+
}
|
|
1554
1603
|
if (error)
|
|
1555
1604
|
return txt(`Error: ${error.message}`);
|
|
1556
1605
|
if (customPropertyChanges.length > 0) {
|
|
@@ -2235,81 +2284,62 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2235
2284
|
}, async ({ site_id }) => {
|
|
2236
2285
|
const projectId = getProjectId();
|
|
2237
2286
|
// Sample recent contacts to discover keys (LIMIT 500 is enough for schema discovery)
|
|
2238
|
-
const
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
const
|
|
2257
|
-
if (
|
|
2258
|
-
return txt(`Error: ${
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
const
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
if (!keyMap.has(k))
|
|
2275
|
-
keyMap.set(k, { values: new Set(), count: 0 });
|
|
2276
|
-
const entry = keyMap.get(k);
|
|
2277
|
-
entry.count++;
|
|
2278
|
-
if (v !== null && v !== undefined && entry.values.size < 3) {
|
|
2279
|
-
entry.values.add(String(v));
|
|
2280
|
-
}
|
|
2287
|
+
const SCHEMA_SAMPLE_LIMIT = 500;
|
|
2288
|
+
async function sampleSubscriberProperties(scopeToSite) {
|
|
2289
|
+
let query = db()
|
|
2290
|
+
.from('subscribers')
|
|
2291
|
+
.select('custom_properties')
|
|
2292
|
+
.eq('project_id', projectId)
|
|
2293
|
+
.not('custom_properties', 'is', null)
|
|
2294
|
+
.order('created_at', { ascending: false });
|
|
2295
|
+
if (scopeToSite)
|
|
2296
|
+
query = query.eq('source_site', site_id);
|
|
2297
|
+
return query.limit(SCHEMA_SAMPLE_LIMIT);
|
|
2298
|
+
}
|
|
2299
|
+
const siteSample = await sampleSubscriberProperties(true);
|
|
2300
|
+
if (siteSample.error)
|
|
2301
|
+
return txt(`Error: ${siteSample.error.message}`);
|
|
2302
|
+
let rows = siteSample.data ?? [];
|
|
2303
|
+
let scope = 'site';
|
|
2304
|
+
if (rows.length === 0) {
|
|
2305
|
+
const projectSample = await sampleSubscriberProperties(false);
|
|
2306
|
+
if (projectSample.error)
|
|
2307
|
+
return txt(`Error: ${projectSample.error.message}`);
|
|
2308
|
+
rows = projectSample.data ?? [];
|
|
2309
|
+
scope = 'project';
|
|
2310
|
+
}
|
|
2311
|
+
const keyMap = new Map();
|
|
2312
|
+
for (const row of rows ?? []) {
|
|
2313
|
+
const props = row.custom_properties;
|
|
2314
|
+
if (!props)
|
|
2315
|
+
continue;
|
|
2316
|
+
for (const [k, v] of Object.entries(props)) {
|
|
2317
|
+
if (!keyMap.has(k))
|
|
2318
|
+
keyMap.set(k, { values: new Set(), count: 0 });
|
|
2319
|
+
const entry = keyMap.get(k);
|
|
2320
|
+
entry.count++;
|
|
2321
|
+
if (v !== null && v !== undefined && entry.values.size < 3) {
|
|
2322
|
+
entry.values.add(String(v));
|
|
2281
2323
|
}
|
|
2282
2324
|
}
|
|
2283
|
-
const schema = Array.from(keyMap.entries())
|
|
2284
|
-
.sort((a, b) => b[1].count - a[1].count)
|
|
2285
|
-
.map(([key, { values, count }]) => ({
|
|
2286
|
-
key,
|
|
2287
|
-
merge_tag: `{{custom.${key}}}`,
|
|
2288
|
-
merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
|
|
2289
|
-
example_values: Array.from(values),
|
|
2290
|
-
contacts_with_this_key: count,
|
|
2291
|
-
}));
|
|
2292
|
-
return j({
|
|
2293
|
-
site_id,
|
|
2294
|
-
scope,
|
|
2295
|
-
total_keys: schema.length,
|
|
2296
|
-
schema,
|
|
2297
|
-
note: scope === 'project'
|
|
2298
|
-
? `No contacts are tagged to site ${site_id} via source_site — sampled custom_properties across the whole project instead. Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).`
|
|
2299
|
-
: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
|
|
2300
|
-
});
|
|
2301
2325
|
}
|
|
2326
|
+
const schema = Array.from(keyMap.entries())
|
|
2327
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
2328
|
+
.map(([key, { values, count }]) => ({
|
|
2329
|
+
key,
|
|
2330
|
+
merge_tag: `{{custom.${key}}}`,
|
|
2331
|
+
merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
|
|
2332
|
+
example_values: Array.from(values),
|
|
2333
|
+
contacts_with_this_key: count,
|
|
2334
|
+
}));
|
|
2302
2335
|
return j({
|
|
2303
2336
|
site_id,
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
contacts_with_this_key: r.count,
|
|
2311
|
-
})),
|
|
2312
|
-
note: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
|
|
2337
|
+
scope,
|
|
2338
|
+
total_keys: schema.length,
|
|
2339
|
+
schema,
|
|
2340
|
+
note: scope === 'project'
|
|
2341
|
+
? `No contacts are tagged to site ${site_id} via source_site — sampled custom_properties across the whole project instead. Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).`
|
|
2342
|
+
: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
|
|
2313
2343
|
});
|
|
2314
2344
|
});
|
|
2315
2345
|
server.registerTool('create_custom_field', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.22",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|