@sendinel/mcp-server 1.0.19 → 1.0.21
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 +81 -15
- 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) {
|
|
@@ -2241,16 +2290,30 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2241
2290
|
});
|
|
2242
2291
|
if (error) {
|
|
2243
2292
|
// Fallback: inline query if RPC not yet available
|
|
2244
|
-
const
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2293
|
+
const SCHEMA_SAMPLE_LIMIT = 500;
|
|
2294
|
+
async function sampleSubscriberProperties(scopeToSite) {
|
|
2295
|
+
let query = db()
|
|
2296
|
+
.from('subscribers')
|
|
2297
|
+
.select('custom_properties')
|
|
2298
|
+
.eq('project_id', projectId)
|
|
2299
|
+
.not('custom_properties', 'is', null)
|
|
2300
|
+
.order('created_at', { ascending: false });
|
|
2301
|
+
if (scopeToSite)
|
|
2302
|
+
query = query.eq('source_site', site_id);
|
|
2303
|
+
return query.limit(SCHEMA_SAMPLE_LIMIT);
|
|
2304
|
+
}
|
|
2305
|
+
const siteSample = await sampleSubscriberProperties(true);
|
|
2306
|
+
if (siteSample.error)
|
|
2307
|
+
return txt(`Error: ${siteSample.error.message}`);
|
|
2308
|
+
let rows = siteSample.data ?? [];
|
|
2309
|
+
let scope = 'site';
|
|
2310
|
+
if (rows.length === 0) {
|
|
2311
|
+
const projectSample = await sampleSubscriberProperties(false);
|
|
2312
|
+
if (projectSample.error)
|
|
2313
|
+
return txt(`Error: ${projectSample.error.message}`);
|
|
2314
|
+
rows = projectSample.data ?? [];
|
|
2315
|
+
scope = 'project';
|
|
2316
|
+
}
|
|
2254
2317
|
const keyMap = new Map();
|
|
2255
2318
|
for (const row of rows ?? []) {
|
|
2256
2319
|
const props = row.custom_properties;
|
|
@@ -2277,9 +2340,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2277
2340
|
}));
|
|
2278
2341
|
return j({
|
|
2279
2342
|
site_id,
|
|
2343
|
+
scope,
|
|
2280
2344
|
total_keys: schema.length,
|
|
2281
2345
|
schema,
|
|
2282
|
-
note:
|
|
2346
|
+
note: scope === 'project'
|
|
2347
|
+
? `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).`
|
|
2348
|
+
: '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).',
|
|
2283
2349
|
});
|
|
2284
2350
|
}
|
|
2285
2351
|
return j({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
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",
|