@sendinel/mcp-server 1.0.139 → 1.0.141

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';
@@ -6,6 +7,8 @@ import { z } from 'zod';
6
7
  import { callSendinelV1 } from '../lib/v1-client.js';
7
8
  import { db, projectClientForWriteback } from '../db.js';
8
9
  import { getProjectId } from '../project.js';
10
+ import { dbToolError, toolError } from '../lib/db-error.js';
11
+ import { resolveSegmentContactIds } from '../lib/send-audience.js';
9
12
  import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
10
13
  import { logConsent, logAudit } from '../audit.js';
11
14
  import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
@@ -237,6 +240,49 @@ function normalizePhoneE164(value) {
237
240
  function isPlainObject(value) {
238
241
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
239
242
  }
243
+ const SUBSCRIBER_VIEW_COLUMNS = new Set([
244
+ 'contact', 'status_strip', 'quick_send', 'site', 'company', 'status', 'last_activity',
245
+ 'sms_opt_in', 'tags', 'score', 'stage', 'opens', 'clicks', 'joined', 'embed_actions',
246
+ 'email', 'first_name', 'last_name', 'introduced_by',
247
+ ]);
248
+ const SUBSCRIBER_VIEW_STAGES = new Set(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']);
249
+ function validateSubscriberViewInput(columns, filters) {
250
+ if (isPlainObject(filters)) {
251
+ for (const [key, value] of Object.entries(filters)) {
252
+ if (!SUBSCRIBER_VIEW_FILTER_KEYS.has(key))
253
+ return `Unknown filter "${key}". Valid keys: ${[...SUBSCRIBER_VIEW_FILTER_KEYS].join(', ')}`;
254
+ if (key === 'show_test' ? typeof value !== 'boolean' : typeof value !== 'string' || value.length > 500)
255
+ return `Invalid value for filter "${key}"`;
256
+ if (key === 'stage' && value !== '' && !SUBSCRIBER_VIEW_STAGES.has(value))
257
+ return `Invalid stage. Valid stages: ${[...SUBSCRIBER_VIEW_STAGES].join(', ')}`;
258
+ if (key === 'dir' && value !== 'asc' && value !== 'desc')
259
+ return 'dir must be asc or desc';
260
+ if (key === 'sort' && value !== '' && !SUBSCRIBER_VIEW_SORT_FIELDS.has(value))
261
+ return 'Invalid sort field';
262
+ if (key === 'status' && !['', 'all', 'active', 'unsubscribed', 'not_subscribed'].includes(value))
263
+ return 'Invalid subscription status';
264
+ if (key === 'cp_key' && value !== '' && !SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(value))
265
+ return 'Invalid custom property key';
266
+ }
267
+ }
268
+ if (isPlainObject(filters) && Boolean(filters.cp_key) !== Boolean(filters.cp_value))
269
+ return 'cp_key and cp_value must be provided together';
270
+ if (isPlainObject(columns)) {
271
+ for (const list of [columns.order, columns.hidden]) {
272
+ if (list === undefined)
273
+ continue;
274
+ if (!Array.isArray(list) || list.length > SUBSCRIBER_VIEW_MAX_COLUMNS)
275
+ return 'Invalid column list';
276
+ for (const key of list) {
277
+ if (typeof key !== 'string' || !SUBSCRIBER_VIEW_KEY_RE.test(key) ||
278
+ (!SUBSCRIBER_VIEW_COLUMNS.has(key) && !(key.startsWith('prop:') && SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(key.slice(5))))) {
279
+ return `Unknown column "${String(key)}". Use a subscriber column or prop:<custom property key>`;
280
+ }
281
+ }
282
+ }
283
+ }
284
+ return null;
285
+ }
240
286
  function normalizeSubscriberViewKeys(value) {
241
287
  if (!Array.isArray(value))
242
288
  return [];
@@ -1074,9 +1120,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1074
1120
  filters: z.record(z.string(), z.unknown()).optional(),
1075
1121
  },
1076
1122
  }, async ({ name, view_mode, columns, filters }) => {
1123
+ const inputError = validateSubscriberViewInput(columns, filters);
1124
+ if (inputError)
1125
+ return toolError({ error: inputError, code: 'validation_failed' });
1077
1126
  const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
1078
1127
  if (!normalizedName)
1079
- return txt('Error: name is required');
1128
+ return toolError({ error: 'name is required', code: 'validation_failed' });
1080
1129
  const { data, error } = await db()
1081
1130
  .from('subscriber_list_views')
1082
1131
  .insert({
@@ -1090,9 +1139,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1090
1139
  .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
1091
1140
  .maybeSingle();
1092
1141
  if (error?.code === '23505')
1093
- return txt(`Error: A view named "${normalizedName}" already exists`);
1142
+ return toolError({ error: `A view named "${normalizedName}" already exists`, code: 'conflict' });
1094
1143
  if (error)
1095
- return txt(`Error: ${error.message}`);
1144
+ return toolError(dbToolError(error));
1145
+ await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
1096
1146
  return j({ view: data });
1097
1147
  });
1098
1148
  server.registerTool('update_subscriber_view', {
@@ -1115,11 +1165,14 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1115
1165
  if (name === undefined && view_mode === undefined && columns === undefined && filters === undefined) {
1116
1166
  return txt('Nothing to update');
1117
1167
  }
1168
+ const inputError = validateSubscriberViewInput(columns, filters);
1169
+ if (inputError)
1170
+ return toolError({ error: inputError, code: 'validation_failed' });
1118
1171
  const updates = {};
1119
1172
  if (name !== undefined) {
1120
1173
  const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
1121
1174
  if (!normalizedName)
1122
- return txt('Error: name is required');
1175
+ return toolError({ error: 'name is required', code: 'validation_failed' });
1123
1176
  updates.name = normalizedName;
1124
1177
  }
1125
1178
  if (view_mode !== undefined)
@@ -1136,11 +1189,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1136
1189
  .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
1137
1190
  .maybeSingle();
1138
1191
  if (error?.code === '23505')
1139
- return txt('Error: A view with that name already exists');
1192
+ return toolError({ error: 'A view with that name already exists', code: 'conflict' });
1140
1193
  if (error)
1141
- return txt(`Error: ${error.message}`);
1194
+ return toolError(dbToolError(error));
1142
1195
  if (!data)
1143
- return txt('View not found');
1196
+ return toolError({ error: 'View not found', code: 'not_found' });
1197
+ await logAudit({ action: 'subscriber_view.saved', resourceType: 'subscriber_view', resourceId: data?.id });
1144
1198
  return j({ view: data });
1145
1199
  });
1146
1200
  server.registerTool('delete_subscriber_view', {
@@ -1253,133 +1307,138 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1253
1307
  utm_term: z.string().optional(),
1254
1308
  },
1255
1309
  }, 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 }) => {
1256
- email = email.trim().toLowerCase();
1257
- const projectId = getProjectId();
1258
- // Block distribution list / system addresses
1259
- const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
1260
- const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
1261
- const local = email.trim().toLowerCase().split('@')[0] ?? '';
1262
- if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
1263
- return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
1264
- }
1265
- // Validate project ownership before the cap, and reuse the double opt-in lookup.
1266
- const { data: siteInfo, error: siteError } = await db().from('sites')
1267
- .select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
1268
- if (siteError)
1269
- throw new Error(`Site lookup failed: ${siteError.message}`);
1270
- if (!siteInfo)
1271
- return contactInputError('not_found', 'Site not found for this project.');
1272
- const needsConfirm = siteInfo.double_opt_in === true;
1273
- const limit = await checkPlanLimit('contacts');
1274
- if (!limit.allowed) {
1275
- return contactInputError('plan_limit', contactLimitMessage(limit));
1276
- }
1277
- const embedOk = await hasCompaniesEmbed(projectId);
1278
- const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
1279
- let { data: existingContact, error: lookupError } = await db()
1280
- .from('subscribers')
1281
- .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1282
- .eq('project_id', projectId)
1283
- .eq('email', email)
1284
- .maybeSingle();
1285
- if (lookupError)
1286
- return txt(`Error looking up contact: ${lookupError.message}`);
1287
- let customProperties;
1288
- if (company_name || company_domain) {
1289
- const orgId = await getProjectOrgId(projectId);
1290
- customProperties = await normalizeMcpContactPropertyPatch({
1291
- ...(company_name ? { company_name } : {}),
1292
- ...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
1293
- }, { orgId, projectId });
1294
- }
1295
- const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
1296
- let contact;
1297
- if (!existingContact) {
1298
- const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1299
- if (customProperties)
1300
- row.custom_properties = customProperties;
1301
- if (!needsConfirm)
1302
- row.opted_in_at = new Date().toISOString();
1303
- if (utm_source)
1304
- row.utm_source = utm_source;
1305
- if (utm_medium)
1306
- row.utm_medium = utm_medium;
1307
- if (utm_campaign)
1308
- row.utm_campaign = utm_campaign;
1309
- if (utm_content)
1310
- row.utm_content = utm_content;
1311
- if (utm_term)
1312
- row.utm_term = utm_term;
1313
- const { data, error } = await db().from('subscribers')
1314
- .upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
1315
- .select(selection).maybeSingle();
1316
- if (error)
1317
- return txt(`Error creating contact: ${error.message}`);
1318
- contact = data;
1319
- if (!contact) {
1320
- // A concurrent add won the unique key; its provenance belongs to that insert.
1321
- const result = await db().from('subscribers')
1322
- .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1323
- .eq('project_id', projectId).eq('email', email).single();
1324
- if (result.error)
1325
- return txt(`Error looking up contact: ${result.error.message}`);
1326
- existingContact = result.data;
1310
+ try {
1311
+ email = email.trim().toLowerCase();
1312
+ const projectId = getProjectId();
1313
+ // Block distribution list / system addresses
1314
+ const DISTRO_LOCAL_PARTS = new Set(['all', 'everyone', 'distro', 'broadcast', 'announce', 'postmaster', 'abuse', 'mailer-daemon', 'bounce', 'bounces', 'noreply', 'no-reply', 'nobody', 'null', 'void']);
1315
+ const DISTRO_PATTERNS = [/^all[-+.]/, /[-+.]all$/, /^list[-+.]/, /[-+.]list$/, /[-+.]announce$/, /^announce[-+.]/, /[-+.]distro$/, /^distro[-+.]/, /[-+.]broadcast$/, /^broadcast[-+.]/];
1316
+ const local = email.trim().toLowerCase().split('@')[0] ?? '';
1317
+ if (DISTRO_LOCAL_PARTS.has(local) || DISTRO_PATTERNS.some(p => p.test(local))) {
1318
+ return contactInputError('validation_failed', `Blocked: "${email}" looks like a group or distribution list address. Sendinel sends to individual people only.`);
1327
1319
  }
1328
- }
1329
- const existing = !!existingContact;
1330
- if (existingContact) {
1331
- const empty = (value) => value == null || value === '';
1332
- const row = {};
1333
- for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
1334
- if (empty(existingContact[key]) && !empty(value))
1335
- row[key] = value;
1320
+ // Validate project ownership before the cap, and reuse the double opt-in lookup.
1321
+ const { data: siteInfo, error: siteError } = await db().from('sites')
1322
+ .select('double_opt_in').eq('id', site_id).eq('project_id', projectId).maybeSingle();
1323
+ if (siteError)
1324
+ return dbErrorResult(siteError);
1325
+ if (!siteInfo)
1326
+ return contactInputError('not_found', 'Site not found for this project.');
1327
+ const needsConfirm = siteInfo.double_opt_in === true;
1328
+ const limit = await checkPlanLimit('contacts');
1329
+ if (!limit.allowed) {
1330
+ return contactInputError('plan_limit', contactLimitMessage(limit));
1336
1331
  }
1337
- if (!needsConfirm && existingContact.opted_in_at == null)
1338
- row.opted_in_at = new Date().toISOString();
1339
- const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
1340
- .filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
1341
- if (Object.keys(missingProperties).length) {
1342
- row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
1332
+ const embedOk = await hasCompaniesEmbed(projectId);
1333
+ const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
1334
+ let { data: existingContact, error: lookupError } = await db()
1335
+ .from('subscribers')
1336
+ .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1337
+ .eq('project_id', projectId)
1338
+ .eq('email', email)
1339
+ .maybeSingle();
1340
+ if (lookupError)
1341
+ return dbErrorResult(lookupError);
1342
+ let customProperties;
1343
+ if (company_name || company_domain) {
1344
+ const orgId = await getProjectOrgId(projectId);
1345
+ customProperties = await normalizeMcpContactPropertyPatch({
1346
+ ...(company_name ? { company_name } : {}),
1347
+ ...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
1348
+ }, { orgId, projectId });
1343
1349
  }
1344
- if (Object.keys(row).length) {
1345
- let update = db().from('subscribers').update(row)
1346
- .eq('id', existingContact.id).eq('project_id', projectId);
1347
- // The stored values must still match: a concurrent edit always wins over a re-add.
1348
- for (const key of Object.keys(row)) {
1349
- const previous = existingContact[key];
1350
- update = previous == null ? update.is(key, null)
1351
- : update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
1352
- }
1353
- const { error } = await update;
1350
+ const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
1351
+ let contact;
1352
+ if (!existingContact) {
1353
+ const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1354
+ if (customProperties)
1355
+ row.custom_properties = customProperties;
1356
+ if (!needsConfirm)
1357
+ row.opted_in_at = new Date().toISOString();
1358
+ if (utm_source)
1359
+ row.utm_source = utm_source;
1360
+ if (utm_medium)
1361
+ row.utm_medium = utm_medium;
1362
+ if (utm_campaign)
1363
+ row.utm_campaign = utm_campaign;
1364
+ if (utm_content)
1365
+ row.utm_content = utm_content;
1366
+ if (utm_term)
1367
+ row.utm_term = utm_term;
1368
+ const { data, error } = await db().from('subscribers')
1369
+ .upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
1370
+ .select(selection).maybeSingle();
1354
1371
  if (error)
1355
- return txt(`Error updating contact: ${error.message}`);
1372
+ return dbErrorResult(error);
1373
+ contact = data;
1374
+ if (!contact) {
1375
+ // A concurrent add won the unique key; its provenance belongs to that insert.
1376
+ const result = await db().from('subscribers')
1377
+ .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1378
+ .eq('project_id', projectId).eq('email', email).single();
1379
+ if (result.error)
1380
+ return dbErrorResult(result.error);
1381
+ existingContact = result.data;
1382
+ }
1383
+ }
1384
+ const existing = !!existingContact;
1385
+ if (existingContact) {
1386
+ const empty = (value) => value == null || value === '';
1387
+ const row = {};
1388
+ for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
1389
+ if (empty(existingContact[key]) && !empty(value))
1390
+ row[key] = value;
1391
+ }
1392
+ if (!needsConfirm && existingContact.opted_in_at == null)
1393
+ row.opted_in_at = new Date().toISOString();
1394
+ const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
1395
+ .filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
1396
+ if (Object.keys(missingProperties).length) {
1397
+ row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
1398
+ }
1399
+ if (Object.keys(row).length) {
1400
+ let update = db().from('subscribers').update(row)
1401
+ .eq('id', existingContact.id).eq('project_id', projectId);
1402
+ // The stored values must still match: a concurrent edit always wins over a re-add.
1403
+ for (const key of Object.keys(row)) {
1404
+ const previous = existingContact[key];
1405
+ update = previous == null ? update.is(key, null)
1406
+ : update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
1407
+ }
1408
+ const { error } = await update;
1409
+ if (error)
1410
+ return dbErrorResult(error);
1411
+ }
1412
+ const result = await db().from('subscribers').select(selection)
1413
+ .eq('id', existingContact.id).eq('project_id', projectId).single();
1414
+ if (result.error)
1415
+ return dbErrorResult(result.error);
1416
+ contact = result.data;
1417
+ }
1418
+ const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
1419
+ const { error: subErr } = await db()
1420
+ .from('site_subscriptions')
1421
+ .upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
1422
+ if (subErr)
1423
+ return dbErrorResult(subErr);
1424
+ if (existing)
1425
+ logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
1426
+ if (needsConfirm) {
1427
+ // Create opt-in token (confirmation email must be sent externally in MCP context)
1428
+ await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1429
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1430
+ logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1431
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1356
1432
  }
1357
- const result = await db().from('subscribers').select(selection)
1358
- .eq('id', existingContact.id).eq('project_id', projectId).single();
1359
- if (result.error)
1360
- return txt(`Error reading contact: ${result.error.message}`);
1361
- contact = result.data;
1362
- }
1363
- const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
1364
- const { error: subErr } = await db()
1365
- .from('site_subscriptions')
1366
- .upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
1367
- if (subErr)
1368
- return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
1369
- if (existing)
1370
- logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
1371
- if (needsConfirm) {
1372
- // Create opt-in token (confirmation email must be sent externally in MCP context)
1373
- await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1374
1433
  await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1375
- logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1376
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1377
- }
1378
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1379
- logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1380
- if (!existing)
1381
- logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1382
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
1434
+ logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1435
+ if (!existing)
1436
+ logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1437
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
1438
+ }
1439
+ catch (error) {
1440
+ return dbErrorResult(error);
1441
+ }
1383
1442
  });
1384
1443
  server.registerTool('import_subscribers', {
1385
1444
  title: 'Import Subscribers',
@@ -1643,6 +1702,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1643
1702
  },
1644
1703
  description: 'List contacts filtered by engagement score range and/or lifecycle stage. Useful for building re-engagement campaigns or identifying high-value contacts.',
1645
1704
  inputSchema: {
1705
+ segment_id: z.string().uuid().optional().describe('Saved segment; mutually exclusive with inline audience filters'),
1646
1706
  lifecycle_stage: z.enum(['new', 'active', 'cooling', 'cold', 'churned', 'lead', 'trial', 'customer', 'win-back']).optional(),
1647
1707
  min_score: z.number().int().min(0).max(100).optional(),
1648
1708
  max_score: z.number().int().min(0).max(100).optional(),
@@ -1651,8 +1711,28 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1651
1711
  company_domain: z.string().optional(),
1652
1712
  limit: z.number().int().min(1).max(200).optional().default(50),
1653
1713
  },
1654
- }, async ({ lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
1714
+ }, async ({ segment_id, lifecycle_stage, min_score, max_score, site_id, company_id, company_domain, limit }) => {
1655
1715
  const projectId = getProjectId();
1716
+ let segmentIds;
1717
+ if (segment_id) {
1718
+ if ([lifecycle_stage, min_score, max_score, site_id, company_id, company_domain].some(value => value !== undefined)) {
1719
+ return toolError({ error: 'Provide segment_id or inline filters, not both', code: 'validation_failed' });
1720
+ }
1721
+ const { data: segment, error } = await db().from('segments').select('rules')
1722
+ .eq('project_id', projectId).eq('id', segment_id).maybeSingle();
1723
+ if (error)
1724
+ return toolError(dbToolError(error));
1725
+ if (!segment)
1726
+ return toolError({ error: 'Segment not found', code: 'not_found' });
1727
+ try {
1728
+ segmentIds = await resolveSegmentContactIds(projectId, segment.rules);
1729
+ }
1730
+ catch (error) {
1731
+ return toolError(isSegmentRulesError(error) ? { error: error.message, code: 'validation_failed' } : { error: 'Unable to evaluate segment', code: 'internal_error' });
1732
+ }
1733
+ if (!segmentIds.length)
1734
+ return j([]);
1735
+ }
1656
1736
  const embedOk = await hasCompaniesEmbed(projectId);
1657
1737
  let q = db()
1658
1738
  .from('subscribers')
@@ -1691,9 +1771,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1691
1771
  return { content: [{ type: 'text', text: '[]' }] };
1692
1772
  q = q.in('company_id', ids);
1693
1773
  }
1774
+ if (segmentIds) {
1775
+ const rows = [];
1776
+ for (const ids of chunkIds(segmentIds)) {
1777
+ // A Supabase builder is mutable; build a fresh query for each chunk.
1778
+ const { data, error } = await db().from('subscribers')
1779
+ .select(embedOk ? SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY : SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY)
1780
+ .eq('project_id', projectId).eq('global_unsubscribed', false).in('id', ids)
1781
+ .order('engagement_score', { ascending: false }).limit(limit ?? 50);
1782
+ if (error)
1783
+ return toolError(dbToolError(error));
1784
+ rows.push(...(data ?? []));
1785
+ }
1786
+ return j(rows.sort((a, b) => b.engagement_score - a.engagement_score).slice(0, limit ?? 50));
1787
+ }
1694
1788
  const { data, error } = await q;
1695
1789
  if (error)
1696
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
1790
+ return toolError(dbToolError(error));
1697
1791
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1698
1792
  });
1699
1793
  server.registerTool('update_subscriber', {
@@ -1719,194 +1813,199 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1719
1813
  timezone: z.string().nullable().optional().describe('IANA time zone identifier, e.g. America/Los_Angeles'),
1720
1814
  },
1721
1815
  }, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
1722
- const projectId = getProjectId();
1723
- let propertyWarnings = [];
1724
- let fields;
1725
- if (custom_properties !== undefined) {
1726
- let definitions;
1727
- try {
1728
- definitions = await registeredCustomFields(projectId);
1729
- }
1730
- catch {
1731
- return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
1732
- }
1733
- fields = Object.fromEntries(definitions.map(field => [field.key, field]));
1734
- const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
1735
- const invalidShape = Object.entries(values).find(([key, value]) => {
1736
- if (!Object.hasOwn(fields, key))
1737
- return false;
1738
- const type = fields[key].type;
1739
- if (type === 'multi_select')
1740
- return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
1741
- return typeof value === 'object' && type !== 'contact_reference';
1742
- });
1743
- const { anomalies } = normalizeContactProperties(values, { fields });
1744
- if (invalidShape || anomalies.length) {
1745
- const key = invalidShape?.[0] ?? anomalies[0].key;
1746
- return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
1747
- }
1748
- propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
1749
- }
1750
- const embedOk = await hasCompaniesEmbed(projectId);
1751
- const updates = {};
1752
- let customPropertyChanges = [];
1753
- if (email !== undefined)
1754
- updates.email = email;
1755
- if (first_name !== undefined)
1756
- updates.first_name = first_name;
1757
- if (last_name !== undefined)
1758
- updates.last_name = last_name;
1759
- if (company_id !== undefined) {
1760
- updates.company_id = company_id;
1761
- }
1762
- else if (company_name || company_domain) {
1763
- updates.company_id = await resolveCompanyId(projectId, {
1764
- company_name: company_name ?? undefined,
1765
- company_domain: company_domain ?? undefined,
1766
- });
1767
- }
1768
- if (tags !== undefined)
1769
- updates.tags = tags;
1770
- if (custom_properties !== undefined) {
1771
- const { data: existing, error: existingError } = await db()
1772
- .from('subscribers')
1773
- .select('custom_properties')
1774
- .eq('id', contact_id)
1775
- .eq('project_id', projectId)
1776
- .maybeSingle();
1777
- if (existingError)
1778
- return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
1779
- if (!existing)
1780
- return contactInputError('not_found', 'Contact not found');
1781
- const orgId = await getProjectOrgId(projectId);
1782
- const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
1783
- orgId,
1784
- projectId,
1785
- subscriberId: contact_id,
1786
- preserveNullDeletions: true,
1787
- fields,
1788
- });
1789
- customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
1790
- updates.custom_properties = {
1791
- ...(existing?.custom_properties ?? {}),
1792
- ...normalizedPatch,
1793
- ...(company_name ? { company_name } : {}),
1794
- ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1795
- };
1796
- for (const [key, value] of Object.entries(normalizedPatch)) {
1797
- if (value === null)
1798
- delete updates.custom_properties[key];
1799
- }
1800
- }
1801
- else if (company_name || company_domain) {
1802
- const { data: existing } = await db()
1803
- .from('subscribers')
1804
- .select('custom_properties')
1805
- .eq('id', contact_id)
1806
- .eq('project_id', projectId)
1807
- .maybeSingle();
1808
- const orgId = await getProjectOrgId(projectId);
1809
- const companyPatch = await normalizeMcpContactPropertyPatch({
1810
- ...(company_name ? { company_name } : {}),
1811
- ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1812
- }, { orgId, projectId, subscriberId: contact_id });
1813
- updates.custom_properties = {
1814
- ...(existing?.custom_properties ?? {}),
1815
- ...companyPatch,
1816
- };
1817
- }
1818
- if (phone_e164 !== undefined) {
1819
- if (phone_e164 === null) {
1820
- updates.phone_e164 = null;
1821
- }
1822
- else {
1823
- const normalized = normalizePhoneE164(phone_e164);
1824
- if (!normalized)
1825
- return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
1826
- updates.phone_e164 = normalized;
1827
- }
1828
- }
1829
- if (timezone !== undefined) {
1830
- if (timezone === null) {
1831
- updates.timezone = null;
1832
- }
1833
- else {
1834
- // Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
1835
- // ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
1836
- if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
1837
- return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
1838
- }
1816
+ try {
1817
+ const projectId = getProjectId();
1818
+ let propertyWarnings = [];
1819
+ let fields;
1820
+ if (custom_properties !== undefined) {
1821
+ let definitions;
1839
1822
  try {
1840
- new Intl.DateTimeFormat('en-US', { timeZone: timezone });
1823
+ definitions = await registeredCustomFields(projectId);
1841
1824
  }
1842
1825
  catch {
1843
- return contactInputError('validation_failed', 'Invalid timezone: must be a valid IANA time zone identifier.');
1826
+ return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
1844
1827
  }
1845
- updates.timezone = timezone;
1828
+ fields = Object.fromEntries(definitions.map(field => [field.key, field]));
1829
+ const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
1830
+ const invalidShape = Object.entries(values).find(([key, value]) => {
1831
+ if (!Object.hasOwn(fields, key))
1832
+ return false;
1833
+ const type = fields[key].type;
1834
+ if (type === 'multi_select')
1835
+ return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
1836
+ return typeof value === 'object' && type !== 'contact_reference';
1837
+ });
1838
+ const { anomalies } = normalizeContactProperties(values, { fields });
1839
+ if (invalidShape || anomalies.length) {
1840
+ const key = invalidShape?.[0] ?? anomalies[0].key;
1841
+ return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
1842
+ }
1843
+ propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
1846
1844
  }
1847
- }
1848
- if (!Object.keys(updates).length)
1849
- return contactInputError('validation_failed', 'No fields to update.');
1850
- const tagResult = {};
1851
- if (tags !== undefined) {
1852
- let previousTags;
1853
- try {
1854
- const { data: existing, error: readError } = await db()
1845
+ const embedOk = await hasCompaniesEmbed(projectId);
1846
+ const updates = {};
1847
+ let customPropertyChanges = [];
1848
+ if (email !== undefined)
1849
+ updates.email = email;
1850
+ if (first_name !== undefined)
1851
+ updates.first_name = first_name;
1852
+ if (last_name !== undefined)
1853
+ updates.last_name = last_name;
1854
+ if (company_id !== undefined) {
1855
+ updates.company_id = company_id;
1856
+ }
1857
+ else if (company_name || company_domain) {
1858
+ updates.company_id = await resolveCompanyId(projectId, {
1859
+ company_name: company_name ?? undefined,
1860
+ company_domain: company_domain ?? undefined,
1861
+ });
1862
+ }
1863
+ if (tags !== undefined)
1864
+ updates.tags = tags;
1865
+ if (custom_properties !== undefined) {
1866
+ const { data: existing, error: existingError } = await db()
1855
1867
  .from('subscribers')
1856
- .select('tags')
1868
+ .select('custom_properties')
1857
1869
  .eq('id', contact_id)
1858
1870
  .eq('project_id', projectId)
1859
1871
  .maybeSingle();
1860
- if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
1861
- previousTags = existing.tags;
1872
+ if (existingError)
1873
+ return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
1874
+ if (!existing)
1875
+ return contactInputError('not_found', 'Contact not found');
1876
+ const orgId = await getProjectOrgId(projectId);
1877
+ const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
1878
+ orgId,
1879
+ projectId,
1880
+ subscriberId: contact_id,
1881
+ preserveNullDeletions: true,
1882
+ fields,
1883
+ });
1884
+ customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
1885
+ updates.custom_properties = {
1886
+ ...(existing?.custom_properties ?? {}),
1887
+ ...normalizedPatch,
1888
+ ...(company_name ? { company_name } : {}),
1889
+ ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1890
+ };
1891
+ for (const [key, value] of Object.entries(normalizedPatch)) {
1892
+ if (value === null)
1893
+ delete updates.custom_properties[key];
1862
1894
  }
1863
1895
  }
1864
- catch {
1865
- // The replacement can still succeed, but the previous set is unknown.
1896
+ else if (company_name || company_domain) {
1897
+ const { data: existing } = await db()
1898
+ .from('subscribers')
1899
+ .select('custom_properties')
1900
+ .eq('id', contact_id)
1901
+ .eq('project_id', projectId)
1902
+ .maybeSingle();
1903
+ const orgId = await getProjectOrgId(projectId);
1904
+ const companyPatch = await normalizeMcpContactPropertyPatch({
1905
+ ...(company_name ? { company_name } : {}),
1906
+ ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1907
+ }, { orgId, projectId, subscriberId: contact_id });
1908
+ updates.custom_properties = {
1909
+ ...(existing?.custom_properties ?? {}),
1910
+ ...companyPatch,
1911
+ };
1866
1912
  }
1867
- if (previousTags === undefined) {
1868
- 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.'];
1913
+ if (phone_e164 !== undefined) {
1914
+ if (phone_e164 === null) {
1915
+ updates.phone_e164 = null;
1916
+ }
1917
+ else {
1918
+ const normalized = normalizePhoneE164(phone_e164);
1919
+ if (!normalized)
1920
+ return contactInputError('validation_failed', 'Invalid phone_e164: must be a valid E.164 phone number (SMS is US-only for now).');
1921
+ updates.phone_e164 = normalized;
1922
+ }
1869
1923
  }
1870
- else {
1871
- const previousSet = new Set(previousTags);
1872
- const nextSet = new Set(tags);
1873
- tagResult.tags_removed = [...previousSet].filter((tag) => !nextSet.has(tag));
1874
- tagResult.tags_added = [...nextSet].filter((tag) => !previousSet.has(tag));
1875
- if (tagResult.tags_removed.length > 0) {
1876
- tagResult.warnings = ['Tags were REPLACED. Use set_subscriber_tags to add tags without replacing the whole set.'];
1924
+ if (timezone !== undefined) {
1925
+ if (timezone === null) {
1926
+ updates.timezone = null;
1927
+ }
1928
+ else {
1929
+ // Region form only; mirrors normalizeTimezoneField in the dashboard. Intl accepts UTC offsets
1930
+ // ("+05:00") and abbreviations ("EST"), which Postgres AT TIME ZONE reads differently.
1931
+ if (timezone !== 'UTC' && !/^[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+$/.test(timezone)) {
1932
+ return contactInputError('validation_failed', 'Invalid timezone: must be an IANA region name such as America/Los_Angeles (not a UTC offset or abbreviation).');
1933
+ }
1934
+ try {
1935
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
1936
+ }
1937
+ catch {
1938
+ return contactInputError('validation_failed', 'Invalid timezone: must be a valid IANA time zone identifier.');
1939
+ }
1940
+ updates.timezone = timezone;
1877
1941
  }
1878
1942
  }
1879
- }
1880
- updates.updated_at = new Date().toISOString();
1881
- const { data, error } = await db()
1882
- .from('subscribers')
1883
- .update(updates)
1884
- .eq('id', contact_id)
1885
- .eq('project_id', projectId)
1886
- .select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
1887
- .single();
1888
- if (error?.code === '23505' && updates.phone_e164 !== undefined) {
1889
- return contactInputError('conflict', 'Another contact in this project already has this phone number.');
1890
- }
1891
- if (error)
1892
- return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
1893
- if (customPropertyChanges.length > 0) {
1894
- logAudit({
1895
- action: 'contact.custom_properties_changed',
1896
- resourceType: 'contact',
1897
- resourceId: contact_id,
1898
- details: { changes: customPropertyChanges },
1943
+ if (!Object.keys(updates).length)
1944
+ return contactInputError('validation_failed', 'No fields to update.');
1945
+ const tagResult = {};
1946
+ if (tags !== undefined) {
1947
+ let previousTags;
1948
+ try {
1949
+ const { data: existing, error: readError } = await db()
1950
+ .from('subscribers')
1951
+ .select('tags')
1952
+ .eq('id', contact_id)
1953
+ .eq('project_id', projectId)
1954
+ .maybeSingle();
1955
+ if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
1956
+ previousTags = existing.tags;
1957
+ }
1958
+ }
1959
+ catch {
1960
+ // The replacement can still succeed, but the previous set is unknown.
1961
+ }
1962
+ if (previousTags === undefined) {
1963
+ 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.'];
1964
+ }
1965
+ else {
1966
+ const previousSet = new Set(previousTags);
1967
+ const nextSet = new Set(tags);
1968
+ tagResult.tags_removed = [...previousSet].filter((tag) => !nextSet.has(tag));
1969
+ tagResult.tags_added = [...nextSet].filter((tag) => !previousSet.has(tag));
1970
+ if (tagResult.tags_removed.length > 0) {
1971
+ tagResult.warnings = ['Tags were REPLACED. Use set_subscriber_tags to add tags without replacing the whole set.'];
1972
+ }
1973
+ }
1974
+ }
1975
+ updates.updated_at = new Date().toISOString();
1976
+ const { data, error } = await db()
1977
+ .from('subscribers')
1978
+ .update(updates)
1979
+ .eq('id', contact_id)
1980
+ .eq('project_id', projectId)
1981
+ .select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
1982
+ .single();
1983
+ if (error?.code === '23505' && updates.phone_e164 !== undefined) {
1984
+ return contactInputError('conflict', 'Another contact in this project already has this phone number.');
1985
+ }
1986
+ if (error)
1987
+ return dbErrorResult(error);
1988
+ if (customPropertyChanges.length > 0) {
1989
+ logAudit({
1990
+ action: 'contact.custom_properties_changed',
1991
+ resourceType: 'contact',
1992
+ resourceId: contact_id,
1993
+ details: { changes: customPropertyChanges },
1994
+ });
1995
+ }
1996
+ await writeBackMcpContact(projectId, data, {
1997
+ ...(updates.email !== undefined ? { email: updates.email } : {}),
1998
+ ...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
1999
+ ...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
2000
+ ...(updates.tags !== undefined ? { tags: updates.tags } : {}),
2001
+ ...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
1899
2002
  });
2003
+ const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
2004
+ return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
2005
+ }
2006
+ catch (error) {
2007
+ return dbErrorResult(error);
1900
2008
  }
1901
- await writeBackMcpContact(projectId, data, {
1902
- ...(updates.email !== undefined ? { email: updates.email } : {}),
1903
- ...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
1904
- ...(updates.last_name !== undefined ? { last_name: updates.last_name } : {}),
1905
- ...(updates.tags !== undefined ? { tags: updates.tags } : {}),
1906
- ...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
1907
- });
1908
- const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
1909
- return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
1910
2009
  });
1911
2010
  server.registerTool('get_subscriber', {
1912
2011
  title: 'Get Subscriber',
@@ -2045,18 +2144,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2045
2144
  },
2046
2145
  }, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
2047
2146
  if (!add_tags.length && !remove_tags.length)
2048
- return txt('No tags to add or remove.');
2147
+ return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
2049
2148
  const projectId = getProjectId();
2050
2149
  let updated = 0;
2150
+ const missing_contact_ids = [];
2051
2151
  for (const cid of contact_ids) {
2052
- const { data: contact } = await db()
2152
+ const { data: contact, error: readError } = await db()
2053
2153
  .from('subscribers')
2054
2154
  .select('id, email, first_name, last_name, custom_properties, tags')
2055
2155
  .eq('id', cid)
2056
2156
  .eq('project_id', projectId)
2057
2157
  .maybeSingle();
2058
- if (!contact)
2158
+ if (readError)
2159
+ return toolError({ ...dbToolError(readError), updated });
2160
+ if (!contact) {
2161
+ missing_contact_ids.push(cid);
2059
2162
  continue;
2163
+ }
2060
2164
  const currentTags = contact.tags ?? [];
2061
2165
  const removeSet = new Set(remove_tags);
2062
2166
  const merged = currentTags.filter((t) => !removeSet.has(t));
@@ -2064,17 +2168,25 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2064
2168
  if (!merged.includes(t))
2065
2169
  merged.push(t);
2066
2170
  }
2067
- const { error } = await db()
2171
+ const { data: saved, error } = await db()
2068
2172
  .from('subscribers')
2069
2173
  .update({ tags: merged })
2070
2174
  .eq('id', cid)
2071
- .eq('project_id', projectId);
2072
- if (!error) {
2073
- updated++;
2074
- await writeBackMcpContact(projectId, contact, { tags: merged });
2175
+ .eq('project_id', projectId)
2176
+ .select('id').maybeSingle();
2177
+ if (error)
2178
+ return toolError({ ...dbToolError(error), updated });
2179
+ if (!saved) {
2180
+ missing_contact_ids.push(cid);
2181
+ continue;
2075
2182
  }
2183
+ updated++;
2184
+ await writeBackMcpContact(projectId, contact, { tags: merged });
2185
+ await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
2076
2186
  }
2077
- return j({ updated, total: contact_ids.length, add_tags, remove_tags });
2187
+ if (!updated)
2188
+ return toolError({ error: 'Subscriber not found', code: 'not_found', updated, missing_contact_ids });
2189
+ return j({ missing_contact_ids, updated, total: contact_ids.length, add_tags, remove_tags });
2078
2190
  });
2079
2191
  server.registerTool('bulk_update_subscribers', {
2080
2192
  title: 'Bulk Update Subscribers',