@sendinel/mcp-server 1.0.138 → 1.0.140

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.
@@ -1,3 +1,4 @@
1
+ import { dbErrorResult } from '../lib/db-errors.js';
1
2
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
3
4
  import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
@@ -24,6 +25,40 @@ const CUSTOM_FIELD_TYPES = [
24
25
  'multi_select', 'contact_reference',
25
26
  ];
26
27
  const CUSTOM_FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,49}$/;
28
+ // Core contact columns from 001_core_schema.sql and subsequent subscriber migrations.
29
+ const RESERVED_CUSTOM_FIELD_KEYS = new Set([
30
+ 'email', 'first_name', 'last_name', 'id', 'phone', 'status', 'tags', 'project_id', 'org_id', 'user_id',
31
+ 'created_at', 'updated_at', 'clerk_user_id', 'global_unsubscribed', 'global_unsubscribed_at',
32
+ 'source', 'source_site', 'ip_address', 'opted_in_at', 'last_emailed_at', 'last_opened_at',
33
+ 'last_clicked_at', 'total_emails_sent', 'total_opens', 'total_clicks', 'engagement_score',
34
+ 'lifecycle_stage', 'score_updated_at', 'custom_properties', 'timezone', 'last_engaged_at',
35
+ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'best_send_hour',
36
+ 'best_send_hour_confidence', 'best_send_hour_updated_at', 'notes', 'birthday', 'anniversary',
37
+ 'external_id', 'imported_from', 'phone_e164', 'sms_opt_in_status', 'sms_opt_in_at',
38
+ 'sms_opt_out_at', 'is_test', 'company_id', 'source_deleted_at', 'cooled_at', 'cooled_reason',
39
+ 'introduced_by_subscriber_id', 'ccpa_opted_out', 'last_transactional_at',
40
+ 'total_transactional_sent', 'last_activity_at',
41
+ ]);
42
+ const CUSTOM_FIELD_COLUMNS = 'id, key, label, type, required, options, display_order';
43
+ async function registeredCustomFields(projectId) {
44
+ return readAllRows(db().from('custom_field_definitions').select(CUSTOM_FIELD_COLUMNS).eq('project_id', projectId));
45
+ }
46
+ function keyDistance(left, right) {
47
+ let row = Array.from({ length: right.length + 1 }, (_, index) => index);
48
+ for (let i = 0; i < left.length; i++) {
49
+ const next = [i + 1];
50
+ for (let j = 0; j < right.length; j++)
51
+ next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (left[i] === right[j] ? 0 : 1)));
52
+ row = next;
53
+ }
54
+ return row[right.length];
55
+ }
56
+ function unknownCustomPropertyWarnings(patch, fields) {
57
+ return Object.keys(patch).filter(key => !Object.hasOwn(fields, key)).map(key => {
58
+ const near = Object.keys(fields).filter(known => keyDistance(key, known) <= 2);
59
+ return `Unknown custom property "${key}"${near.length ? `; did you mean ${near.map(value => `"${value}"`).join(' or ')}?` : '; no registered definition'}`;
60
+ });
61
+ }
27
62
  const SUBSCRIBER_VIEW_MAX_NAME_LENGTH = 100;
28
63
  const SUBSCRIBER_VIEW_MAX_COLUMNS = 150;
29
64
  const SUBSCRIBER_VIEW_KEY_RE = /^[a-zA-Z0-9_.:-]{1,160}$/;
@@ -1219,133 +1254,138 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1219
1254
  utm_term: z.string().optional(),
1220
1255
  },
1221
1256
  }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
1222
- email = email.trim().toLowerCase();
1223
- const projectId = getProjectId();
1224
- // Block distribution list / system addresses
1225
- const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
1226
- const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
1227
- const local = email.trim().toLowerCase().split('@')[0] ?? '';
1228
- if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
1229
- return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
1230
- }
1231
- // Validate project ownership before the cap, and reuse the double opt-in lookup.
1232
- const { data: siteInfo, error: siteError } = await db().from('sites')
1233
- .select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
1234
- if (siteError)
1235
- throw new Error(`Site lookup failed: ${siteError.message}`);
1236
- if (!siteInfo)
1237
- return contactInputError('not_found', 'Site not found for this project.');
1238
- const needsConfirm = siteInfo.double_opt_in === true;
1239
- const limit = await checkPlanLimit('contacts');
1240
- if (!limit.allowed) {
1241
- return contactInputError('plan_limit', contactLimitMessage(limit));
1242
- }
1243
- const embedOk = await hasCompaniesEmbed(projectId);
1244
- const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
1245
- let { data: existingContact, error: lookupError } = await db()
1246
- .from('subscribers')
1247
- .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1248
- .eq('project_id', projectId)
1249
- .eq('email', email)
1250
- .maybeSingle();
1251
- if (lookupError)
1252
- return txt(`Error looking up contact: ${lookupError.message}`);
1253
- let customProperties;
1254
- if (company_name || company_domain) {
1255
- const orgId = await getProjectOrgId(projectId);
1256
- customProperties = await normalizeMcpContactPropertyPatch({
1257
- ...(company_name ? { company_name } : {}),
1258
- ...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
1259
- }, { orgId, projectId });
1260
- }
1261
- const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
1262
- let contact;
1263
- if (!existingContact) {
1264
- const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1265
- if (customProperties)
1266
- row.custom_properties = customProperties;
1267
- if (!needsConfirm)
1268
- row.opted_in_at = new Date().toISOString();
1269
- if (utm_source)
1270
- row.utm_source = utm_source;
1271
- if (utm_medium)
1272
- row.utm_medium = utm_medium;
1273
- if (utm_campaign)
1274
- row.utm_campaign = utm_campaign;
1275
- if (utm_content)
1276
- row.utm_content = utm_content;
1277
- if (utm_term)
1278
- row.utm_term = utm_term;
1279
- const { data, error } = await db().from('subscribers')
1280
- .upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
1281
- .select(selection).maybeSingle();
1282
- if (error)
1283
- return txt(`Error creating contact: ${error.message}`);
1284
- contact = data;
1285
- if (!contact) {
1286
- // A concurrent add won the unique key; its provenance belongs to that insert.
1287
- const result = await db().from('subscribers')
1288
- .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1289
- .eq('project_id', projectId).eq('email', email).single();
1290
- if (result.error)
1291
- return txt(`Error looking up contact: ${result.error.message}`);
1292
- existingContact = result.data;
1257
+ try {
1258
+ email = email.trim().toLowerCase();
1259
+ const projectId = getProjectId();
1260
+ // Block distribution list / system addresses
1261
+ const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
1262
+ const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
1263
+ const local = email.trim().toLowerCase().split('@')[0] ?? '';
1264
+ if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
1265
+ return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
1293
1266
  }
1294
- }
1295
- const existing = !!existingContact;
1296
- if (existingContact) {
1297
- const empty = (value) => value == null || value === '';
1298
- const row = {};
1299
- for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
1300
- if (empty(existingContact[key]) && !empty(value))
1301
- row[key] = value;
1267
+ // Validate project ownership before the cap, and reuse the double opt-in lookup.
1268
+ const { data: siteInfo, error: siteError } = await db().from('sites')
1269
+ .select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
1270
+ if (siteError)
1271
+ return dbErrorResult(siteError);
1272
+ if (!siteInfo)
1273
+ return contactInputError('not_found', 'Site not found for this project.');
1274
+ const needsConfirm = siteInfo.double_opt_in === true;
1275
+ const limit = await checkPlanLimit('contacts');
1276
+ if (!limit.allowed) {
1277
+ return contactInputError('plan_limit', contactLimitMessage(limit));
1302
1278
  }
1303
- if (!needsConfirm && existingContact.opted_in_at == null)
1304
- row.opted_in_at = new Date().toISOString();
1305
- const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
1306
- .filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
1307
- if (Object.keys(missingProperties).length) {
1308
- row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
1279
+ const embedOk = await hasCompaniesEmbed(projectId);
1280
+ const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
1281
+ let { data: existingContact, error: lookupError } = await db()
1282
+ .from('subscribers')
1283
+ .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1284
+ .eq('project_id', projectId)
1285
+ .eq('email', email)
1286
+ .maybeSingle();
1287
+ if (lookupError)
1288
+ return dbErrorResult(lookupError);
1289
+ let customProperties;
1290
+ if (company_name || company_domain) {
1291
+ const orgId = await getProjectOrgId(projectId);
1292
+ customProperties = await normalizeMcpContactPropertyPatch({
1293
+ ...(company_name ? { company_name } : {}),
1294
+ ...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
1295
+ }, { orgId, projectId });
1309
1296
  }
1310
- if (Object.keys(row).length) {
1311
- let update = db().from('subscribers').update(row)
1312
- .eq('id', existingContact.id).eq('project_id', projectId);
1313
- // The stored values must still match: a concurrent edit always wins over a re-add.
1314
- for (const key of Object.keys(row)) {
1315
- const previous = existingContact[key];
1316
- update = previous == null ? update.is(key, null)
1317
- : update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
1318
- }
1319
- const { error } = await update;
1297
+ const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
1298
+ let contact;
1299
+ if (!existingContact) {
1300
+ const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1301
+ if (customProperties)
1302
+ row.custom_properties = customProperties;
1303
+ if (!needsConfirm)
1304
+ row.opted_in_at = new Date().toISOString();
1305
+ if (utm_source)
1306
+ row.utm_source = utm_source;
1307
+ if (utm_medium)
1308
+ row.utm_medium = utm_medium;
1309
+ if (utm_campaign)
1310
+ row.utm_campaign = utm_campaign;
1311
+ if (utm_content)
1312
+ row.utm_content = utm_content;
1313
+ if (utm_term)
1314
+ row.utm_term = utm_term;
1315
+ const { data, error } = await db().from('subscribers')
1316
+ .upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
1317
+ .select(selection).maybeSingle();
1320
1318
  if (error)
1321
- return txt(`Error updating contact: ${error.message}`);
1319
+ return dbErrorResult(error);
1320
+ contact = data;
1321
+ if (!contact) {
1322
+ // A concurrent add won the unique key; its provenance belongs to that insert.
1323
+ const result = await db().from('subscribers')
1324
+ .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1325
+ .eq('project_id', projectId).eq('email', email).single();
1326
+ if (result.error)
1327
+ return dbErrorResult(result.error);
1328
+ existingContact = result.data;
1329
+ }
1330
+ }
1331
+ const existing = !!existingContact;
1332
+ if (existingContact) {
1333
+ const empty = (value) => value == null || value === '';
1334
+ const row = {};
1335
+ for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
1336
+ if (empty(existingContact[key]) && !empty(value))
1337
+ row[key] = value;
1338
+ }
1339
+ if (!needsConfirm && existingContact.opted_in_at == null)
1340
+ row.opted_in_at = new Date().toISOString();
1341
+ const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
1342
+ .filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
1343
+ if (Object.keys(missingProperties).length) {
1344
+ row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
1345
+ }
1346
+ if (Object.keys(row).length) {
1347
+ let update = db().from('subscribers').update(row)
1348
+ .eq('id', existingContact.id).eq('project_id', projectId);
1349
+ // The stored values must still match: a concurrent edit always wins over a re-add.
1350
+ for (const key of Object.keys(row)) {
1351
+ const previous = existingContact[key];
1352
+ update = previous == null ? update.is(key, null)
1353
+ : update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
1354
+ }
1355
+ const { error } = await update;
1356
+ if (error)
1357
+ return dbErrorResult(error);
1358
+ }
1359
+ const result = await db().from('subscribers').select(selection)
1360
+ .eq('id', existingContact.id).eq('project_id', projectId).single();
1361
+ if (result.error)
1362
+ return dbErrorResult(result.error);
1363
+ contact = result.data;
1364
+ }
1365
+ const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
1366
+ const { error: subErr } = await db()
1367
+ .from('site_subscriptions')
1368
+ .upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
1369
+ if (subErr)
1370
+ return dbErrorResult(subErr);
1371
+ if (existing)
1372
+ logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
1373
+ if (needsConfirm) {
1374
+ // Create opt-in token (confirmation email must be sent externally in MCP context)
1375
+ await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1376
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1377
+ logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1378
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1322
1379
  }
1323
- const result = await db().from('subscribers').select(selection)
1324
- .eq('id', existingContact.id).eq('project_id', projectId).single();
1325
- if (result.error)
1326
- return txt(`Error reading contact: ${result.error.message}`);
1327
- contact = result.data;
1328
- }
1329
- const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
1330
- const { error: subErr } = await db()
1331
- .from('site_subscriptions')
1332
- .upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
1333
- if (subErr)
1334
- return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
1335
- if (existing)
1336
- logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
1337
- if (needsConfirm) {
1338
- // Create opt-in token (confirmation email must be sent externally in MCP context)
1339
- await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1340
1380
  await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1341
- logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1342
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1343
- }
1344
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1345
- logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1346
- if (!existing)
1347
- logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1348
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
1381
+ logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1382
+ if (!existing)
1383
+ logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1384
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
1385
+ }
1386
+ catch (error) {
1387
+ return dbErrorResult(error);
1388
+ }
1349
1389
  });
1350
1390
  server.registerTool('import_subscribers', {
1351
1391
  title: 'Import Subscribers',
@@ -1670,7 +1710,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1670
1710
  idempotentHint: true,
1671
1711
  openWorldHint: false,
1672
1712
  },
1673
- description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags.',
1713
+ description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags. Registered custom properties must match their type/options; unknown keys remain allowed with warnings and near-miss suggestions.',
1674
1714
  inputSchema: {
1675
1715
  contact_id: z.string().uuid(),
1676
1716
  email: z.string().email().optional(),
@@ -1685,161 +1725,199 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1685
1725
  timezone: z.string().nullable().optional().describe('IANA time zone identifier, e.g. America/Los_Angeles'),
1686
1726
  },
1687
1727
  }, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
1688
- const projectId = getProjectId();
1689
- const embedOk = await hasCompaniesEmbed(projectId);
1690
- const updates = {};
1691
- let customPropertyChanges = [];
1692
- if (email !== undefined)
1693
- updates.email = email;
1694
- if (first_name !== undefined)
1695
- updates.first_name = first_name;
1696
- if (last_name !== undefined)
1697
- updates.last_name = last_name;
1698
- if (company_id !== undefined) {
1699
- updates.company_id = company_id;
1700
- }
1701
- else if (company_name || company_domain) {
1702
- updates.company_id = await resolveCompanyId(projectId, {
1703
- company_name: company_name ?? undefined,
1704
- company_domain: company_domain ?? undefined,
1705
- });
1706
- }
1707
- if (tags !== undefined)
1708
- updates.tags = tags;
1709
- if (custom_properties !== undefined) {
1710
- const { data: existing } = await db()
1711
- .from('subscribers')
1712
- .select('custom_properties')
1713
- .eq('id', contact_id)
1714
- .eq('project_id', projectId)
1715
- .maybeSingle();
1716
- const orgId = await getProjectOrgId(projectId);
1717
- const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
1718
- orgId,
1719
- projectId,
1720
- subscriberId: contact_id,
1721
- preserveNullDeletions: true,
1722
- });
1723
- customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
1724
- updates.custom_properties = {
1725
- ...(existing?.custom_properties ?? {}),
1726
- ...normalizedPatch,
1727
- ...(company_name ? { company_name } : {}),
1728
- ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1729
- };
1730
- for (const [key, value] of Object.entries(normalizedPatch)) {
1731
- if (value === null)
1732
- delete updates.custom_properties[key];
1733
- }
1734
- }
1735
- else if (company_name || company_domain) {
1736
- const { data: existing } = await db()
1737
- .from('subscribers')
1738
- .select('custom_properties')
1739
- .eq('id', contact_id)
1740
- .eq('project_id', projectId)
1741
- .maybeSingle();
1742
- const orgId = await getProjectOrgId(projectId);
1743
- const companyPatch = await normalizeMcpContactPropertyPatch({
1744
- ...(company_name ? { company_name } : {}),
1745
- ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1746
- }, { orgId, projectId, subscriberId: contact_id });
1747
- updates.custom_properties = {
1748
- ...(existing?.custom_properties ?? {}),
1749
- ...companyPatch,
1750
- };
1751
- }
1752
- if (phone_e164 !== undefined) {
1753
- if (phone_e164 === null) {
1754
- updates.phone_e164 = null;
1755
- }
1756
- else {
1757
- const normalized = normalizePhoneE164(phone_e164);
1758
- if (!normalized)
1759
- return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
1760
- updates.phone_e164 = normalized;
1761
- }
1762
- }
1763
- if (timezone !== undefined) {
1764
- if (timezone === null) {
1765
- updates.timezone = null;
1766
- }
1767
- else {
1768
- // Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
1769
- // ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
1770
- if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
1771
- return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
1772
- }
1728
+ try {
1729
+ const projectId = getProjectId();
1730
+ let propertyWarnings = [];
1731
+ let fields;
1732
+ if (custom_properties !== undefined) {
1733
+ let definitions;
1773
1734
  try {
1774
- new Intl.DateTimeFormat('en-US', { timeZone: timezone });
1735
+ definitions = await registeredCustomFields(projectId);
1775
1736
  }
1776
1737
  catch {
1777
- return contactInputError('validation_failed', 'Invalid timezone: must be a valid IANA time zone identifier.');
1738
+ return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
1778
1739
  }
1779
- updates.timezone = timezone;
1740
+ fields = Object.fromEntries(definitions.map(field => [field.key, field]));
1741
+ const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
1742
+ const invalidShape = Object.entries(values).find(([key, value]) => {
1743
+ if (!Object.hasOwn(fields, key))
1744
+ return false;
1745
+ const type = fields[key].type;
1746
+ if (type === 'multi_select')
1747
+ return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
1748
+ return typeof value === 'object' && type !== 'contact_reference';
1749
+ });
1750
+ const { anomalies } = normalizeContactProperties(values, { fields });
1751
+ if (invalidShape || anomalies.length) {
1752
+ const key = invalidShape?.[0] ?? anomalies[0].key;
1753
+ return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
1754
+ }
1755
+ propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
1780
1756
  }
1781
- }
1782
- if (!Object.keys(updates).length)
1783
- return txt('No fields to update.');
1784
- const tagResult = {};
1785
- if (tags !== undefined) {
1786
- let previousTags;
1787
- try {
1788
- const { data: existing, error: readError } = await db()
1757
+ const embedOk = await hasCompaniesEmbed(projectId);
1758
+ const updates = {};
1759
+ let customPropertyChanges = [];
1760
+ if (email !== undefined)
1761
+ updates.email = email;
1762
+ if (first_name !== undefined)
1763
+ updates.first_name = first_name;
1764
+ if (last_name !== undefined)
1765
+ updates.last_name = last_name;
1766
+ if (company_id !== undefined) {
1767
+ updates.company_id = company_id;
1768
+ }
1769
+ else if (company_name || company_domain) {
1770
+ updates.company_id = await resolveCompanyId(projectId, {
1771
+ company_name: company_name ?? undefined,
1772
+ company_domain: company_domain ?? undefined,
1773
+ });
1774
+ }
1775
+ if (tags !== undefined)
1776
+ updates.tags = tags;
1777
+ if (custom_properties !== undefined) {
1778
+ const { data: existing, error: existingError } = await db()
1789
1779
  .from('subscribers')
1790
- .select('tags')
1780
+ .select('custom_properties')
1791
1781
  .eq('id', contact_id)
1792
1782
  .eq('project_id', projectId)
1793
1783
  .maybeSingle();
1794
- if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
1795
- previousTags = existing.tags;
1784
+ if (existingError)
1785
+ return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
1786
+ if (!existing)
1787
+ return contactInputError('not_found', 'Contact not found');
1788
+ const orgId = await getProjectOrgId(projectId);
1789
+ const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
1790
+ orgId,
1791
+ projectId,
1792
+ subscriberId: contact_id,
1793
+ preserveNullDeletions: true,
1794
+ fields,
1795
+ });
1796
+ customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
1797
+ updates.custom_properties = {
1798
+ ...(existing?.custom_properties ?? {}),
1799
+ ...normalizedPatch,
1800
+ ...(company_name ? { company_name } : {}),
1801
+ ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1802
+ };
1803
+ for (const [key, value] of Object.entries(normalizedPatch)) {
1804
+ if (value === null)
1805
+ delete updates.custom_properties[key];
1796
1806
  }
1797
1807
  }
1798
- catch {
1799
- // The replacement can still succeed, but the previous set is unknown.
1808
+ else if (company_name || company_domain) {
1809
+ const { data: existing } = await db()
1810
+ .from('subscribers')
1811
+ .select('custom_properties')
1812
+ .eq('id', contact_id)
1813
+ .eq('project_id', projectId)
1814
+ .maybeSingle();
1815
+ const orgId = await getProjectOrgId(projectId);
1816
+ const companyPatch = await normalizeMcpContactPropertyPatch({
1817
+ ...(company_name ? { company_name } : {}),
1818
+ ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1819
+ }, { orgId, projectId, subscriberId: contact_id });
1820
+ updates.custom_properties = {
1821
+ ...(existing?.custom_properties ?? {}),
1822
+ ...companyPatch,
1823
+ };
1800
1824
  }
1801
- if (previousTags === undefined) {
1802
- tagResult.warnings = ['Tags were REPLACED, but previous tags could not be read; removed and added tags are unknown. Use set_subscriber_tags to add tags without replacing the whole set.'];
1825
+ if (phone_e164 !== undefined) {
1826
+ if (phone_e164 === null) {
1827
+ updates.phone_e164 = null;
1828
+ }
1829
+ else {
1830
+ const normalized = normalizePhoneE164(phone_e164);
1831
+ if (!normalized)
1832
+ return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
1833
+ updates.phone_e164 = normalized;
1834
+ }
1803
1835
  }
1804
- else {
1805
- const previousSet = new Set(previousTags);
1806
- const nextSet = new Set(tags);
1807
- tagResult.tags_removed = [...previousSet].filter((tag) => !nextSet.has(tag));
1808
- tagResult.tags_added = [...nextSet].filter((tag) => !previousSet.has(tag));
1809
- if (tagResult.tags_removed.length > 0) {
1810
- tagResult.warnings = ['Tags were REPLACED. Use set_subscriber_tags to add tags without replacing the whole set.'];
1836
+ if (timezone !== undefined) {
1837
+ if (timezone === null) {
1838
+ updates.timezone = null;
1839
+ }
1840
+ else {
1841
+ // Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
1842
+ // ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
1843
+ if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
1844
+ return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
1845
+ }
1846
+ try {
1847
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
1848
+ }
1849
+ catch {
1850
+ return contactInputError('validation_failed', 'Invalid timezone: must be a valid IANA time zone identifier.');
1851
+ }
1852
+ updates.timezone = timezone;
1811
1853
  }
1812
1854
  }
1813
- }
1814
- updates.updated_at = new Date().toISOString();
1815
- const { data, error } = await db()
1816
- .from('subscribers')
1817
- .update(updates)
1818
- .eq('id', contact_id)
1819
- .eq('project_id', projectId)
1820
- .select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
1821
- .single();
1822
- if (error?.code === '23505' && updates.phone_e164 !== undefined) {
1823
- return contactInputError('validation_failed', 'Another contact in this project already has this phone number.');
1824
- }
1825
- if (error)
1826
- return txt(`Error: ${error.message}`);
1827
- if (customPropertyChanges.length > 0) {
1828
- logAudit({
1829
- action: 'contact.custom_properties_changed',
1830
- resourceType: 'contact',
1831
- resourceId: contact_id,
1832
- details: { changes: customPropertyChanges },
1855
+ if (!Object.keys(updates).length)
1856
+ return contactInputError('validation_failed', 'No fields to update.');
1857
+ const tagResult = {};
1858
+ if (tags !== undefined) {
1859
+ let previousTags;
1860
+ try {
1861
+ const { data: existing, error: readError } = await db()
1862
+ .from('subscribers')
1863
+ .select('tags')
1864
+ .eq('id', contact_id)
1865
+ .eq('project_id', projectId)
1866
+ .maybeSingle();
1867
+ if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
1868
+ previousTags = existing.tags;
1869
+ }
1870
+ }
1871
+ catch {
1872
+ // The replacement can still succeed, but the previous set is unknown.
1873
+ }
1874
+ if (previousTags === undefined) {
1875
+ tagResult.warnings = ['Tags were REPLACED, but previous tags could not be read; removed and added tags are unknown. Use set_subscriber_tags to add tags without replacing the whole set.'];
1876
+ }
1877
+ else {
1878
+ const previousSet = new Set(previousTags);
1879
+ const nextSet = new Set(tags);
1880
+ tagResult.tags_removed = [...previousSet].filter((tag) => !nextSet.has(tag));
1881
+ tagResult.tags_added = [...nextSet].filter((tag) => !previousSet.has(tag));
1882
+ if (tagResult.tags_removed.length > 0) {
1883
+ tagResult.warnings = ['Tags were REPLACED. Use set_subscriber_tags to add tags without replacing the whole set.'];
1884
+ }
1885
+ }
1886
+ }
1887
+ updates.updated_at = new Date().toISOString();
1888
+ const { data, error } = await db()
1889
+ .from('subscribers')
1890
+ .update(updates)
1891
+ .eq('id', contact_id)
1892
+ .eq('project_id', projectId)
1893
+ .select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
1894
+ .single();
1895
+ if (error?.code === '23505' && updates.phone_e164 !== undefined) {
1896
+ return contactInputError('conflict', 'Another contact in this project already has this phone number.');
1897
+ }
1898
+ if (error)
1899
+ return dbErrorResult(error);
1900
+ if (customPropertyChanges.length > 0) {
1901
+ logAudit({
1902
+ action: 'contact.custom_properties_changed',
1903
+ resourceType: 'contact',
1904
+ resourceId: contact_id,
1905
+ details: { changes: customPropertyChanges },
1906
+ });
1907
+ }
1908
+ await writeBackMcpContact(projectId, data, {
1909
+ ...(updates.email !== undefined ? { email: updates.email } : {}),
1910
+ ...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
1911
+ ...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
1912
+ ...(updates.tags !== undefined ? { tags: updates.tags } : {}),
1913
+ ...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
1833
1914
  });
1915
+ const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
1916
+ return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
1917
+ }
1918
+ catch (error) {
1919
+ return dbErrorResult(error);
1834
1920
  }
1835
- await writeBackMcpContact(projectId, data, {
1836
- ...(updates.email !== undefined ? { email: updates.email } : {}),
1837
- ...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
1838
- ...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
1839
- ...(updates.tags !== undefined ? { tags: updates.tags } : {}),
1840
- ...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
1841
- });
1842
- return j({ ...data, ...tagResult });
1843
1921
  });
1844
1922
  server.registerTool('get_subscriber', {
1845
1923
  title: 'Get Subscriber',
@@ -2515,7 +2593,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2515
2593
  idempotentHint: true,
2516
2594
  openWorldHint: false,
2517
2595
  },
2518
- description: 'Discover all custom_properties keys used across contacts for a site. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
2596
+ description: 'Discover registered custom field definitions and all custom_properties keys used across contacts for a site, including unused definitions. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
2519
2597
  inputSchema: {
2520
2598
  site_id: z.string().uuid().describe('Site to inspect — only contacts belonging to this site are sampled'),
2521
2599
  },
@@ -2539,20 +2617,34 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2539
2617
  // site-scoped; project scope is only for a site with no members at all.
2540
2618
  let loaded = await loadSchemaKeys(site_id);
2541
2619
  if ('error' in loaded)
2542
- return txt(loaded.error);
2620
+ return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
2543
2621
  let scope = 'site';
2544
2622
  if (!loaded.has_membership) {
2545
2623
  loaded = await loadSchemaKeys(null);
2546
2624
  if ('error' in loaded)
2547
- return txt(loaded.error);
2625
+ return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
2548
2626
  scope = 'project';
2549
2627
  }
2550
- const schema = loaded.keys.map(({ key, contacts_with_this_key, example_values }) => ({
2628
+ let definitions;
2629
+ try {
2630
+ definitions = await registeredCustomFields(projectId);
2631
+ }
2632
+ catch {
2633
+ return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
2634
+ }
2635
+ const byKey = new Map(definitions.map(field => [field.key, field]));
2636
+ const keys = [...loaded.keys];
2637
+ const observed = new Set(keys.map(row => row.key));
2638
+ for (const field of definitions)
2639
+ if (!observed.has(field.key))
2640
+ keys.push({ key: field.key, contacts_with_this_key: 0, example_values: [] });
2641
+ const schema = keys.map(({ key, contacts_with_this_key, example_values }) => ({
2551
2642
  key,
2552
2643
  merge_tag: `{{custom.${key}}}`,
2553
2644
  merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
2554
2645
  example_values,
2555
2646
  contacts_with_this_key,
2647
+ ...(byKey.has(key) ? { definition: byKey.get(key) } : {}),
2556
2648
  }));
2557
2649
  return j({
2558
2650
  site_id,
@@ -2564,6 +2656,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2564
2656
  : '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).',
2565
2657
  });
2566
2658
  });
2659
+ server.registerTool('list_custom_fields', {
2660
+ title: 'List Custom Fields',
2661
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2662
+ description: 'List all registered custom field definitions for the current project, including fields with no contact values yet.',
2663
+ inputSchema: {},
2664
+ }, async () => {
2665
+ try {
2666
+ const fields = await registeredCustomFields(getProjectId());
2667
+ return j({ fields, count: fields.length });
2668
+ }
2669
+ catch {
2670
+ return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
2671
+ }
2672
+ });
2567
2673
  server.registerTool('create_custom_field', {
2568
2674
  title: 'Create Custom Field',
2569
2675
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -2577,9 +2683,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2577
2683
  display_order: z.number().int().optional().default(0),
2578
2684
  },
2579
2685
  }, async ({ key, label, type, required, options, display_order }) => {
2686
+ if (RESERVED_CUSTOM_FIELD_KEYS.has(key))
2687
+ return contactInputError('validation_failed', `Custom field key "${key}" is reserved for a core contact column`);
2580
2688
  const optionsError = validateCustomFieldOptions(type, options);
2581
2689
  if (optionsError)
2582
- return txt(`Error: ${optionsError}`);
2690
+ return contactInputError('validation_failed', `Error: ${optionsError}`);
2583
2691
  const normalizedOptions = type === 'select' || type === 'multi_select'
2584
2692
  ? normalizeCustomFieldOptions(options)
2585
2693
  : null;
@@ -2601,8 +2709,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2601
2709
  .single();
2602
2710
  if (error) {
2603
2711
  if (error.code === '23505')
2604
- return txt(`Error: A custom field with key "${key}" already exists.`);
2605
- return txt(`Error: ${error.message}`);
2712
+ return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
2713
+ return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
2606
2714
  }
2607
2715
  await logAudit({
2608
2716
  action: 'custom_field.created',