@sendinel/mcp-server 1.0.20 → 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.
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.20",
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",