@sendinel/mcp-server 1.0.139 → 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.
@@ -0,0 +1,12 @@
1
+ /** Caller-safe database errors. Never serialize database messages, details or hints. */
2
+ export declare function mapDbError(error: unknown): {
3
+ error: string;
4
+ code: string;
5
+ };
6
+ export declare function dbErrorResult(error: unknown): {
7
+ isError: boolean;
8
+ content: {
9
+ type: 'text';
10
+ text: string;
11
+ }[];
12
+ };
@@ -0,0 +1,20 @@
1
+ /** Caller-safe database errors. Never serialize database messages, details or hints. */
2
+ export function mapDbError(error) {
3
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
4
+ if (code === '23505')
5
+ return { error: 'A record with these values already exists.', code: 'conflict' };
6
+ if (code === '23503' || code === 'PGRST116') {
7
+ return { error: 'The requested or referenced record was not found.', code: 'not_found' };
8
+ }
9
+ if (code.startsWith('23') || code.startsWith('22')) {
10
+ return { error: 'The supplied values are not valid. Check the input and try again.', code: 'validation_failed' };
11
+ }
12
+ return { error: 'The operation could not be completed. Please try again later.', code: 'internal_error' };
13
+ }
14
+ export function dbErrorResult(error) {
15
+ console.warn('[mcp] Database operation failed:', error);
16
+ return {
17
+ isError: true,
18
+ content: [{ type: 'text', text: JSON.stringify(mapDbError(error)) }],
19
+ };
20
+ }
@@ -1,3 +1,4 @@
1
+ import { dbErrorResult } from '../lib/db-errors.js';
1
2
  import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
2
3
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
3
4
  import { z } from 'zod';
@@ -882,40 +883,56 @@ export function registerCampaignTools(server) {
882
883
  send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
883
884
  },
884
885
  }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
885
- const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
886
- if (!properties.ok)
887
- return j({ error: properties.error, code: "INVALID_INPUT" });
888
- const renderVars = {};
889
- if (survey_id) {
890
- const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
891
- if (!surveyLink)
892
- return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
893
- renderVars.survey_link = surveyLink;
886
+ try {
887
+ const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
888
+ if (!properties.ok)
889
+ return j({ error: properties.error, code: "INVALID_INPUT" });
890
+ const { data: campaign, error: campaignError } = await db()
891
+ .schema('email')
892
+ .from('campaigns')
893
+ .select('id')
894
+ .eq('id', campaign_id)
895
+ .eq('project_id', getProjectId())
896
+ .maybeSingle();
897
+ if (campaignError)
898
+ return dbErrorResult(campaignError);
899
+ if (!campaign)
900
+ return { ...j({ error: 'Campaign not found.', code: 'not_found' }), isError: true };
901
+ const renderVars = {};
902
+ if (survey_id) {
903
+ const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
904
+ if (!surveyLink)
905
+ return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
906
+ renderVars.survey_link = surveyLink;
907
+ }
908
+ const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
909
+ const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
910
+ const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
911
+ const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
912
+ const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
913
+ const { data, error } = await db()
914
+ .from('campaign_steps')
915
+ .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
916
+ .select()
917
+ .single();
918
+ if (error)
919
+ return dbErrorResult(error);
920
+ await recordBrandAssetUsage({
921
+ client: db(),
922
+ projectId: getProjectId(),
923
+ source: 'campaign_step',
924
+ resourceId: data.id,
925
+ html: renderedHtml,
926
+ assetIds: brand_asset_ids,
927
+ }).catch((err) => {
928
+ console.warn('Failed to record brand asset usage for MCP campaign step:', err);
929
+ });
930
+ await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
931
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
932
+ }
933
+ catch (error) {
934
+ return dbErrorResult(error);
894
935
  }
895
- const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
896
- const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
897
- const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
898
- const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
899
- const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
900
- const { data, error } = await db()
901
- .from('campaign_steps')
902
- .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
903
- .select()
904
- .single();
905
- if (error)
906
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
907
- await recordBrandAssetUsage({
908
- client: db(),
909
- projectId: getProjectId(),
910
- source: 'campaign_step',
911
- resourceId: data.id,
912
- html: renderedHtml,
913
- assetIds: brand_asset_ids,
914
- }).catch((err) => {
915
- console.warn('Failed to record brand asset usage for MCP campaign step:', err);
916
- });
917
- await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
918
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
919
936
  });
920
937
  server.registerTool('update_campaign_status', {
921
938
  title: 'Update Campaign Status',
@@ -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';
@@ -1253,133 +1254,138 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1253
1254
  utm_term: z.string().optional(),
1254
1255
  },
1255
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 }) => {
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;
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.`);
1327
1266
  }
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;
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));
1336
1278
  }
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 };
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 });
1343
1296
  }
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;
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();
1354
1318
  if (error)
1355
- 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) }] };
1356
1379
  }
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
1380
  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) }] };
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
+ }
1383
1389
  });
1384
1390
  server.registerTool('import_subscribers', {
1385
1391
  title: 'Import Subscribers',
@@ -1719,194 +1725,199 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1719
1725
  timezone: z.string().nullable().optional().describe('IANA time zone identifier, e.g. America/Los_Angeles'),
1720
1726
  },
1721
1727
  }, 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
- }
1728
+ try {
1729
+ const projectId = getProjectId();
1730
+ let propertyWarnings = [];
1731
+ let fields;
1732
+ if (custom_properties !== undefined) {
1733
+ let definitions;
1839
1734
  try {
1840
- new Intl.DateTimeFormat('en-US', { timeZone: timezone });
1735
+ definitions = await registeredCustomFields(projectId);
1841
1736
  }
1842
1737
  catch {
1843
- 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' });
1844
1739
  }
1845
- 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);
1846
1756
  }
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()
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()
1855
1779
  .from('subscribers')
1856
- .select('tags')
1780
+ .select('custom_properties')
1857
1781
  .eq('id', contact_id)
1858
1782
  .eq('project_id', projectId)
1859
1783
  .maybeSingle();
1860
- if (!readError && Array.isArray(existing?.tags) && existing.tags.every((tag) => typeof tag === 'string')) {
1861
- 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];
1862
1806
  }
1863
1807
  }
1864
- catch {
1865
- // 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
+ };
1866
1824
  }
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.'];
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
+ }
1869
1835
  }
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.'];
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;
1877
1853
  }
1878
1854
  }
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 },
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 } : {}),
1899
1914
  });
1915
+ const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
1916
+ return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
1917
+ }
1918
+ catch (error) {
1919
+ return dbErrorResult(error);
1900
1920
  }
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
1921
  });
1911
1922
  server.registerTool('get_subscriber', {
1912
1923
  title: 'Get Subscriber',
@@ -1,3 +1,4 @@
1
+ import { dbErrorResult } from '../lib/db-errors.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
@@ -80,31 +81,36 @@ export function registerSegmentTools(server) {
80
81
  rules: z.record(z.string(), z.unknown()).describe('Segment rule tree: {op:"AND"|"OR", conditions:[{field, operator, value}]} or legacy flat rules. Supported fields include lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.<key>, email_sends (eq only, value: {min_count: positive integer, within_days: positive number}; at most one per segment). tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
81
82
  },
82
83
  }, async ({ name, description, rules }) => {
83
- let storedRules;
84
84
  try {
85
- storedRules = prepareSegmentRulesForStorage(rules);
85
+ let storedRules;
86
+ try {
87
+ storedRules = prepareSegmentRulesForStorage(rules);
88
+ }
89
+ catch (rulesError) {
90
+ const invalid = invalidRules(rulesError);
91
+ if (invalid)
92
+ return invalid;
93
+ throw rulesError;
94
+ }
95
+ const { data, error } = await db()
96
+ .from('segments')
97
+ .insert({ project_id: getProjectId(), name, description, rules: storedRules })
98
+ .select('id, name, description, rules, created_at')
99
+ .single();
100
+ if (error)
101
+ return dbErrorResult(error);
102
+ // Count against the stored (normalized) tree, not the raw input: a tree skips
103
+ // applySegmentRules' SQL push-down and filters in JS, which is what list_segments
104
+ // does for this same segment a moment later. Pushing the flat rules down instead
105
+ // makes the two disagree wherever SQL and JS compare differently -- notably
106
+ // custom_property against a non-string JSONB value (boolean/number), where
107
+ // `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
108
+ const ids = await getSegmentContactIds(data.rules);
109
+ return j({ ...data, estimated_contacts: ids.length });
86
110
  }
87
- catch (rulesError) {
88
- const invalid = invalidRules(rulesError);
89
- if (invalid)
90
- return invalid;
91
- throw rulesError;
111
+ catch (error) {
112
+ return dbErrorResult(error);
92
113
  }
93
- const { data, error } = await db()
94
- .from('segments')
95
- .insert({ project_id: getProjectId(), name, description, rules: storedRules })
96
- .select('id, name, description, rules, created_at')
97
- .single();
98
- if (error)
99
- return txt(`Error: ${error.message}`);
100
- // Count against the stored (normalized) tree, not the raw input: a tree skips
101
- // applySegmentRules' SQL push-down and filters in JS, which is what list_segments
102
- // does for this same segment a moment later. Pushing the flat rules down instead
103
- // makes the two disagree wherever SQL and JS compare differently -- notably
104
- // custom_property against a non-string JSONB value (boolean/number), where
105
- // `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
106
- const ids = await getSegmentContactIds(data.rules);
107
- return j({ ...data, estimated_contacts: ids.length });
108
114
  });
109
115
  server.registerTool('list_segments', {
110
116
  title: 'List Segments',
@@ -157,32 +163,37 @@ export function registerSegmentTools(server) {
157
163
  rules: z.record(z.string(), z.unknown()).optional(),
158
164
  },
159
165
  }, async ({ segment_id, name, description, rules }) => {
160
- const updates = { updated_at: new Date().toISOString() };
161
- if (name)
162
- updates.name = name;
163
- if (description !== undefined)
164
- updates.description = description;
165
- if (rules) {
166
- try {
167
- updates.rules = prepareSegmentRulesForStorage(rules);
168
- }
169
- catch (rulesError) {
170
- const invalid = invalidRules(rulesError);
171
- if (invalid)
172
- return invalid;
173
- throw rulesError;
166
+ try {
167
+ const updates = { updated_at: new Date().toISOString() };
168
+ if (name)
169
+ updates.name = name;
170
+ if (description !== undefined)
171
+ updates.description = description;
172
+ if (rules) {
173
+ try {
174
+ updates.rules = prepareSegmentRulesForStorage(rules);
175
+ }
176
+ catch (rulesError) {
177
+ const invalid = invalidRules(rulesError);
178
+ if (invalid)
179
+ return invalid;
180
+ throw rulesError;
181
+ }
174
182
  }
183
+ const { data, error } = await db()
184
+ .from('segments')
185
+ .update(updates)
186
+ .eq('id', segment_id)
187
+ .eq('project_id', getProjectId())
188
+ .select('id, name, description, rules, updated_at')
189
+ .single();
190
+ if (error)
191
+ return dbErrorResult(error);
192
+ return j(data);
193
+ }
194
+ catch (error) {
195
+ return dbErrorResult(error);
175
196
  }
176
- const { data, error } = await db()
177
- .from('segments')
178
- .update(updates)
179
- .eq('id', segment_id)
180
- .eq('project_id', getProjectId())
181
- .select('id, name, description, rules, updated_at')
182
- .single();
183
- if (error)
184
- return txt(`Error: ${error.message}`);
185
- return j(data);
186
197
  });
187
198
  server.registerTool('delete_segment', {
188
199
  title: 'Delete Segment',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.139",
3
+ "version": "1.0.140",
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",