@sendinel/mcp-server 1.0.145 → 1.0.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/lib/action-approvals.js +3 -1
- package/build/lib/db-error.d.ts +39 -6
- package/build/lib/db-error.js +221 -11
- package/build/lib/tags.d.ts +10 -0
- package/build/lib/tags.js +29 -0
- package/build/server.js +5 -3
- package/build/tools/ab-testing.js +4 -3
- package/build/tools/advisor.js +2 -2
- package/build/tools/ai-work.js +14 -13
- package/build/tools/analytics.js +26 -26
- package/build/tools/approvals.js +3 -1
- package/build/tools/automations.js +12 -11
- package/build/tools/brand-kit.js +4 -3
- package/build/tools/campaign-sets.js +11 -10
- package/build/tools/campaigns.js +73 -74
- package/build/tools/compound.js +7 -6
- package/build/tools/connector.js +4 -3
- package/build/tools/contacts.js +116 -86
- package/build/tools/content.js +16 -16
- package/build/tools/convert-imported.js +8 -7
- package/build/tools/data-proposals.js +8 -7
- package/build/tools/data.js +39 -38
- package/build/tools/delivery-ops.js +32 -32
- package/build/tools/drafts.js +10 -9
- package/build/tools/forms.js +5 -4
- package/build/tools/gdpr.js +10 -9
- package/build/tools/landing-pages.js +3 -3
- package/build/tools/lists.js +21 -20
- package/build/tools/newsletters.js +26 -25
- package/build/tools/org.js +26 -23
- package/build/tools/segments.js +4 -5
- package/build/tools/short-links.js +7 -6
- package/build/tools/signatures.js +10 -9
- package/build/tools/sites.js +9 -8
- package/build/tools/sms.js +14 -13
- package/build/tools/social-posts.js +20 -19
- package/build/tools/social-replies.js +7 -6
- package/build/tools/surveys.js +10 -9
- package/build/tools/templates.js +14 -13
- package/build/tools/warmup.js +5 -4
- package/build/tools/webhooks.js +5 -4
- package/package.json +1 -1
- package/build/lib/db-errors.d.ts +0 -12
- package/build/lib/db-errors.js +0 -20
package/build/tools/contacts.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
|
|
2
2
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
4
4
|
import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
|
|
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { db, projectClientForWriteback } from '../db.js';
|
|
9
9
|
import { getProjectId } from '../project.js';
|
|
10
|
-
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
10
|
+
import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
|
|
11
11
|
import { resolveSegmentContactIds } from '../lib/send-audience.js';
|
|
12
12
|
import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
|
|
13
13
|
import { logConsent, logAudit } from '../audit.js';
|
|
@@ -335,7 +335,7 @@ async function resolveSubscriberViewSiteId(projectId, siteParam) {
|
|
|
335
335
|
.eq('active', true)
|
|
336
336
|
.order('name');
|
|
337
337
|
if (error)
|
|
338
|
-
return { error
|
|
338
|
+
return { ...dbToolError(error) };
|
|
339
339
|
const all = (sites ?? []);
|
|
340
340
|
if (SUBSCRIBER_VIEW_UUID_RE.test(siteParam)) {
|
|
341
341
|
return { siteId: all.some((site) => site.id === siteParam) ? siteParam : undefined };
|
|
@@ -425,7 +425,7 @@ function isMissingRelationshipError(error) {
|
|
|
425
425
|
return false;
|
|
426
426
|
if (error.code === 'PGRST200')
|
|
427
427
|
return true;
|
|
428
|
-
return /could not find a relationship/i.test(error.
|
|
428
|
+
return /could not find a relationship/i.test((error ? dbToolError(error).error : undefined) ?? '');
|
|
429
429
|
}
|
|
430
430
|
async function hasCompaniesEmbed(projectId) {
|
|
431
431
|
const cached = companiesEmbedCache.get(projectId);
|
|
@@ -574,7 +574,7 @@ async function exportSubscriberData(projectId, contactId) {
|
|
|
574
574
|
.eq('project_id', projectId)
|
|
575
575
|
.maybeSingle();
|
|
576
576
|
if (contactError)
|
|
577
|
-
return {
|
|
577
|
+
return { ...dbToolError(contactError) };
|
|
578
578
|
if (!contact)
|
|
579
579
|
return { error: 'Subscriber not found', code: 'NOT_FOUND' };
|
|
580
580
|
const { data: emailHistory, error: logError } = await db()
|
|
@@ -585,7 +585,7 @@ async function exportSubscriberData(projectId, contactId) {
|
|
|
585
585
|
.not('to_email', 'like', 'sha256:%')
|
|
586
586
|
.order('sent_at', { ascending: false });
|
|
587
587
|
if (logError)
|
|
588
|
-
return {
|
|
588
|
+
return { ...dbToolError(logError) };
|
|
589
589
|
const { data: suppression, error: suppressionError } = await db()
|
|
590
590
|
.from('suppressions')
|
|
591
591
|
.select('reason, created_at')
|
|
@@ -594,7 +594,7 @@ async function exportSubscriberData(projectId, contactId) {
|
|
|
594
594
|
.not('email', 'like', 'sha256:%')
|
|
595
595
|
.maybeSingle();
|
|
596
596
|
if (suppressionError)
|
|
597
|
-
return {
|
|
597
|
+
return { ...dbToolError(suppressionError) };
|
|
598
598
|
return {
|
|
599
599
|
contact: {
|
|
600
600
|
id: contact.id,
|
|
@@ -713,7 +713,7 @@ export function registerContactTools(server) {
|
|
|
713
713
|
}, async () => {
|
|
714
714
|
const { data, error } = await db().from('hygiene_config').select('*').eq('project_id', getProjectId()).maybeSingle();
|
|
715
715
|
if (error)
|
|
716
|
-
return j(
|
|
716
|
+
return j(dbToolError(error));
|
|
717
717
|
return j(data ?? { enabled: false, inactive_days: 90, action: 'tag', tag_name: 'cold', suppress_after_days: 180, activity_property: null, last_run_at: null });
|
|
718
718
|
});
|
|
719
719
|
server.registerTool('update_hygiene_config', {
|
|
@@ -746,7 +746,7 @@ export function registerContactTools(server) {
|
|
|
746
746
|
row.activity_property = activityProperty;
|
|
747
747
|
const { error } = await db().from('hygiene_config').upsert(row, { onConflict: 'project_id' });
|
|
748
748
|
if (error)
|
|
749
|
-
return j(
|
|
749
|
+
return j(dbToolError(error));
|
|
750
750
|
return j({ updated: true });
|
|
751
751
|
});
|
|
752
752
|
server.registerTool('run_list_hygiene', {
|
|
@@ -766,7 +766,7 @@ export function registerContactTools(server) {
|
|
|
766
766
|
// read past 1000 is now safe as a follow-up.
|
|
767
767
|
const { data: contacts, error: readError } = await db().from('subscribers').select('id, email, first_name, last_name, custom_properties, tags, last_engaged_at').eq('project_id', projectId).lt('last_engaged_at', cutoff).limit(1000);
|
|
768
768
|
if (readError)
|
|
769
|
-
return j(
|
|
769
|
+
return j(dbToolError(readError));
|
|
770
770
|
let tagged = 0;
|
|
771
771
|
let skipped = 0;
|
|
772
772
|
let failed = 0;
|
|
@@ -907,7 +907,7 @@ export function registerContactTools(server) {
|
|
|
907
907
|
q = q.contains('tags', [`segment:${segmentId}`]);
|
|
908
908
|
const { data: sample, error } = await q;
|
|
909
909
|
if (error)
|
|
910
|
-
return txt(`Error: ${error.
|
|
910
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
911
911
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
912
912
|
if (!apiKey)
|
|
913
913
|
return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_UNAVAILABLE' });
|
|
@@ -983,7 +983,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
983
983
|
.eq('project_id', projectId)
|
|
984
984
|
.maybeSingle();
|
|
985
985
|
if (viewError)
|
|
986
|
-
return txt(`Error: ${viewError.
|
|
986
|
+
return txt(`Error: ${dbToolError(viewError).error}`);
|
|
987
987
|
if (!view)
|
|
988
988
|
return txt('Error: View not found');
|
|
989
989
|
viewFilters = normalizeSubscriberViewFilters(view.filters);
|
|
@@ -1049,7 +1049,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1049
1049
|
.select('contact_id, subscribed')
|
|
1050
1050
|
.eq('site_id', statusSiteId);
|
|
1051
1051
|
if (siteSubscriptionsError)
|
|
1052
|
-
return txt(`Error: ${siteSubscriptionsError.
|
|
1052
|
+
return txt(`Error: ${dbToolError(siteSubscriptionsError).error}`);
|
|
1053
1053
|
if (viewStatus === 'not_subscribed') {
|
|
1054
1054
|
const subscribedIds = (siteSubscriptions ?? [])
|
|
1055
1055
|
.map((subscription) => subscription.contact_id)
|
|
@@ -1081,7 +1081,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1081
1081
|
}
|
|
1082
1082
|
const { data, error } = await q;
|
|
1083
1083
|
if (error)
|
|
1084
|
-
return { content: [{ type: 'text', text: `Error: ${error.
|
|
1084
|
+
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
1085
1085
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1086
1086
|
});
|
|
1087
1087
|
server.registerTool('list_subscriber_views', {
|
|
@@ -1101,7 +1101,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1101
1101
|
.eq('project_id', getProjectId())
|
|
1102
1102
|
.order('created_at', { ascending: true });
|
|
1103
1103
|
if (error)
|
|
1104
|
-
return txt(`Error: ${error.
|
|
1104
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
1105
1105
|
return j({ views: data ?? [] });
|
|
1106
1106
|
});
|
|
1107
1107
|
server.registerTool('create_subscriber_view', {
|
|
@@ -1219,7 +1219,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1219
1219
|
.select('id')
|
|
1220
1220
|
.maybeSingle();
|
|
1221
1221
|
if (error)
|
|
1222
|
-
return txt(`Error: ${error.
|
|
1222
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
1223
1223
|
if (!data)
|
|
1224
1224
|
return txt('View not found');
|
|
1225
1225
|
return j({ deleted: true, view_id });
|
|
@@ -1250,7 +1250,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1250
1250
|
}
|
|
1251
1251
|
const { data: companies, error } = await query;
|
|
1252
1252
|
if (error)
|
|
1253
|
-
return txt(`Error: ${error.
|
|
1253
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
1254
1254
|
const rows = companies ?? [];
|
|
1255
1255
|
if (!rows.length)
|
|
1256
1256
|
return j([]);
|
|
@@ -1265,7 +1265,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1265
1265
|
.order('id')
|
|
1266
1266
|
.range(offset, offset + 999);
|
|
1267
1267
|
if (subscriberError)
|
|
1268
|
-
return txt(`Error: ${subscriberError.
|
|
1268
|
+
return txt(`Error: ${dbToolError(subscriberError).error}`);
|
|
1269
1269
|
for (const subscriber of subscribers ?? []) {
|
|
1270
1270
|
const companyId = typeof subscriber.company_id === 'string' ? subscriber.company_id : null;
|
|
1271
1271
|
if (!companyId)
|
|
@@ -1299,6 +1299,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1299
1299
|
company_id: z.string().uuid().optional(),
|
|
1300
1300
|
company_name: z.string().optional(),
|
|
1301
1301
|
company_domain: z.string().optional(),
|
|
1302
|
+
tags: z.array(z.string()).optional().describe('Initial tags for new subscribers; existing contacts keep their stored tags.'),
|
|
1302
1303
|
source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
|
|
1303
1304
|
utm_source: z.string().optional(),
|
|
1304
1305
|
utm_medium: z.string().optional(),
|
|
@@ -1306,8 +1307,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1306
1307
|
utm_content: z.string().optional(),
|
|
1307
1308
|
utm_term: z.string().optional(),
|
|
1308
1309
|
},
|
|
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 }) => {
|
|
1310
|
+
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1310
1311
|
try {
|
|
1312
|
+
const normalizedTags = normalizeTagInput(tags ?? []);
|
|
1313
|
+
const warnings = tagInputWarnings(normalizedTags);
|
|
1314
|
+
const tagWarnings = warnings.length ? { warnings } : {};
|
|
1311
1315
|
email = email.trim().toLowerCase();
|
|
1312
1316
|
const projectId = getProjectId();
|
|
1313
1317
|
// Block distribution list / system addresses
|
|
@@ -1351,6 +1355,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1351
1355
|
let contact;
|
|
1352
1356
|
if (!existingContact) {
|
|
1353
1357
|
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1358
|
+
if (tags !== undefined)
|
|
1359
|
+
row.tags = normalizedTags.tags;
|
|
1354
1360
|
if (customProperties)
|
|
1355
1361
|
row.custom_properties = customProperties;
|
|
1356
1362
|
if (!needsConfirm)
|
|
@@ -1426,15 +1432,15 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1426
1432
|
if (needsConfirm) {
|
|
1427
1433
|
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1428
1434
|
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 });
|
|
1435
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1430
1436
|
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) }] };
|
|
1437
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1432
1438
|
}
|
|
1433
|
-
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1439
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1434
1440
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1435
1441
|
if (!existing)
|
|
1436
1442
|
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) }] };
|
|
1443
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, ...existingMessage }, null, 2) }] };
|
|
1438
1444
|
}
|
|
1439
1445
|
catch (error) {
|
|
1440
1446
|
return dbErrorResult(error);
|
|
@@ -1524,7 +1530,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1524
1530
|
.upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: false })
|
|
1525
1531
|
.select('id, email, first_name, last_name, custom_properties');
|
|
1526
1532
|
if (uErr)
|
|
1527
|
-
return { content: [{ type: 'text', text: `Error: ${uErr.
|
|
1533
|
+
return { content: [{ type: 'text', text: `Error: ${dbToolError(uErr).error}` }] };
|
|
1528
1534
|
const contactRows = upserted ?? [];
|
|
1529
1535
|
const subRows = contactRows.map((c) => ({ contact_id: c.id, site_id, subscribed: true }));
|
|
1530
1536
|
if (subRows.length) {
|
|
@@ -1557,7 +1563,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1557
1563
|
.select('id, email, first_name, last_name, custom_properties')
|
|
1558
1564
|
.single();
|
|
1559
1565
|
if (error)
|
|
1560
|
-
return { content: [{ type: 'text', text: `Error: ${error.
|
|
1566
|
+
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
1561
1567
|
if (data) {
|
|
1562
1568
|
// Cascade to site subscriptions
|
|
1563
1569
|
await db()
|
|
@@ -1630,7 +1636,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1630
1636
|
q = q.ilike('subject', `%${subject_contains.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
|
|
1631
1637
|
const { data, error } = await q;
|
|
1632
1638
|
if (error)
|
|
1633
|
-
return { content: [{ type: 'text', text: `Error: ${error.
|
|
1639
|
+
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
1634
1640
|
const fetched = (data ?? []);
|
|
1635
1641
|
const hasMore = fetched.length > pageSize;
|
|
1636
1642
|
// source: where the email came from. A campaign id wins; otherwise the stored category (transactional, test, marketing).
|
|
@@ -1665,7 +1671,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1665
1671
|
q = q.eq('reason', reason);
|
|
1666
1672
|
const { data, error } = await q;
|
|
1667
1673
|
if (error)
|
|
1668
|
-
return { content: [{ type: 'text', text: `Error: ${error.
|
|
1674
|
+
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
1669
1675
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1670
1676
|
});
|
|
1671
1677
|
server.registerTool('add_suppression', {
|
|
@@ -1689,7 +1695,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1689
1695
|
.select()
|
|
1690
1696
|
.single();
|
|
1691
1697
|
if (error)
|
|
1692
|
-
return { content: [{ type: 'text', text: `Error: ${error.
|
|
1698
|
+
return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
|
|
1693
1699
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1694
1700
|
});
|
|
1695
1701
|
server.registerTool('list_subscribers_by_segment', {
|
|
@@ -1728,7 +1734,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1728
1734
|
segmentIds = await resolveSegmentContactIds(projectId, segment.rules);
|
|
1729
1735
|
}
|
|
1730
1736
|
catch (error) {
|
|
1731
|
-
return toolError(isSegmentRulesError(error) ? { error
|
|
1737
|
+
return toolError(isSegmentRulesError(error) ? { ...dbToolError(error), code: 'validation_failed' } : { error: 'Unable to evaluate segment', code: 'internal_error' });
|
|
1732
1738
|
}
|
|
1733
1739
|
if (!segmentIds.length)
|
|
1734
1740
|
return j([]);
|
|
@@ -1815,6 +1821,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1815
1821
|
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
|
|
1816
1822
|
try {
|
|
1817
1823
|
const projectId = getProjectId();
|
|
1824
|
+
const normalizedTags = tags !== undefined ? normalizeTagInput(tags) : undefined;
|
|
1825
|
+
if (normalizedTags)
|
|
1826
|
+
tags = normalizedTags.tags;
|
|
1818
1827
|
let propertyWarnings = [];
|
|
1819
1828
|
let fields;
|
|
1820
1829
|
if (custom_properties !== undefined) {
|
|
@@ -2000,7 +2009,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2000
2009
|
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
2001
2010
|
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
2002
2011
|
});
|
|
2003
|
-
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
2012
|
+
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings, ...(normalizedTags ? tagInputWarnings(normalizedTags) : [])];
|
|
2004
2013
|
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
2005
2014
|
}
|
|
2006
2015
|
catch (error) {
|
|
@@ -2035,7 +2044,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2035
2044
|
return txt('Provide either contact_id or email.');
|
|
2036
2045
|
const { data, error } = await q.maybeSingle();
|
|
2037
2046
|
if (error)
|
|
2038
|
-
return txt(`Error: ${error.
|
|
2047
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2039
2048
|
return j(data);
|
|
2040
2049
|
});
|
|
2041
2050
|
server.registerTool('enrich_subscriber', {
|
|
@@ -2061,7 +2070,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2061
2070
|
.eq('id', contact_id)
|
|
2062
2071
|
.maybeSingle();
|
|
2063
2072
|
if (error)
|
|
2064
|
-
return txt(`Error: ${error.
|
|
2073
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2065
2074
|
if (!contact?.email)
|
|
2066
2075
|
return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
|
|
2067
2076
|
const enriched = await enrichContactFromProviders(projectId, contact.email, provider);
|
|
@@ -2099,7 +2108,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2099
2108
|
.select(embedOk ? '*, company:companies(id, name, domain)' : '*')
|
|
2100
2109
|
.single();
|
|
2101
2110
|
if (updateError)
|
|
2102
|
-
return txt(`Error: ${updateError.
|
|
2111
|
+
return txt(`Error: ${dbToolError(updateError).error}`);
|
|
2103
2112
|
const appliedFields = [];
|
|
2104
2113
|
if (!contact.first_name && enriched.firstName)
|
|
2105
2114
|
appliedFields.push('first_name');
|
|
@@ -2136,57 +2145,78 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2136
2145
|
idempotentHint: true,
|
|
2137
2146
|
openWorldHint: false,
|
|
2138
2147
|
},
|
|
2139
|
-
description: 'Add or remove tags from one or more contacts',
|
|
2148
|
+
description: 'Add or remove tags from one or more contacts. Tags are trimmed; empty values are ignored; case variants of an existing tag are not added again. Removal is case-insensitive. Ids not found are returned in not_found.',
|
|
2140
2149
|
inputSchema: {
|
|
2141
2150
|
contact_ids: z.array(z.string().uuid()).min(1),
|
|
2142
2151
|
add_tags: z.array(z.string()).optional(),
|
|
2143
2152
|
remove_tags: z.array(z.string()).optional(),
|
|
2144
2153
|
},
|
|
2145
2154
|
}, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
|
|
2155
|
+
const normalized = normalizeTagInput(add_tags);
|
|
2156
|
+
add_tags = normalized.tags;
|
|
2157
|
+
remove_tags = remove_tags.map(tag => tag.trim()).filter(Boolean);
|
|
2158
|
+
const warnings = new Set(tagInputWarnings(normalized));
|
|
2146
2159
|
if (!add_tags.length && !remove_tags.length)
|
|
2147
2160
|
return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
|
|
2148
2161
|
const projectId = getProjectId();
|
|
2149
2162
|
let updated = 0;
|
|
2150
2163
|
const missing_contact_ids = [];
|
|
2164
|
+
const failed = [];
|
|
2151
2165
|
for (const cid of contact_ids) {
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2166
|
+
try {
|
|
2167
|
+
const { data: contact, error: readError } = await db()
|
|
2168
|
+
.from('subscribers')
|
|
2169
|
+
.select('id, email, first_name, last_name, custom_properties, tags')
|
|
2170
|
+
.eq('id', cid)
|
|
2171
|
+
.eq('project_id', projectId)
|
|
2172
|
+
.maybeSingle();
|
|
2173
|
+
if (readError) {
|
|
2174
|
+
failed.push({ id: cid, ...dbToolError(readError) });
|
|
2175
|
+
continue;
|
|
2176
|
+
}
|
|
2177
|
+
if (!contact) {
|
|
2178
|
+
missing_contact_ids.push(cid);
|
|
2179
|
+
continue;
|
|
2180
|
+
}
|
|
2181
|
+
const currentTags = contact.tags ?? [];
|
|
2182
|
+
const removeSet = new Set(remove_tags.map(tag => tag.toLowerCase()));
|
|
2183
|
+
const merged = currentTags.filter((t) => !removeSet.has(t.trim().toLowerCase()));
|
|
2184
|
+
for (const tag of add_tags) {
|
|
2185
|
+
const existing = merged.find(value => value.trim().toLowerCase() === tag.toLowerCase());
|
|
2186
|
+
if (existing !== undefined)
|
|
2187
|
+
warnings.add(`${tag} already exists as ${existing}`);
|
|
2188
|
+
else
|
|
2189
|
+
merged.push(tag);
|
|
2190
|
+
}
|
|
2191
|
+
const { data: saved, error } = await db()
|
|
2192
|
+
.from('subscribers')
|
|
2193
|
+
.update({ tags: merged })
|
|
2194
|
+
.eq('id', cid)
|
|
2195
|
+
.eq('project_id', projectId)
|
|
2196
|
+
.select('id').maybeSingle();
|
|
2197
|
+
if (error) {
|
|
2198
|
+
failed.push({ id: cid, ...dbToolError(error) });
|
|
2199
|
+
continue;
|
|
2200
|
+
}
|
|
2201
|
+
if (!saved) {
|
|
2202
|
+
missing_contact_ids.push(cid);
|
|
2203
|
+
continue;
|
|
2204
|
+
}
|
|
2205
|
+
updated++;
|
|
2206
|
+
await writeBackMcpContact(projectId, contact, { tags: merged });
|
|
2207
|
+
await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
|
|
2170
2208
|
}
|
|
2171
|
-
|
|
2172
|
-
.
|
|
2173
|
-
.update({ tags: merged })
|
|
2174
|
-
.eq('id', cid)
|
|
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;
|
|
2209
|
+
catch {
|
|
2210
|
+
failed.push({ id: cid, ...dbToolError({}) });
|
|
2182
2211
|
}
|
|
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 } });
|
|
2186
2212
|
}
|
|
2213
|
+
const result = { missing_contact_ids, not_found: missing_contact_ids, failed, updated, total: contact_ids.length, add_tags, remove_tags, ...(warnings.size ? { warnings: [...warnings] } : {}) };
|
|
2214
|
+
// Preserve the existing coded-error convention when no requested write succeeded.
|
|
2215
|
+
if (!updated && failed.length)
|
|
2216
|
+
return toolError({ ...dbToolError({}), ...result, code: failed[0].code, error: failed[0].error });
|
|
2187
2217
|
if (!updated)
|
|
2188
|
-
return toolError({ error: 'Subscriber not found', code: 'not_found',
|
|
2189
|
-
return j(
|
|
2218
|
+
return toolError({ error: 'Subscriber not found', code: 'not_found', ...result });
|
|
2219
|
+
return j(result);
|
|
2190
2220
|
});
|
|
2191
2221
|
server.registerTool('bulk_update_subscribers', {
|
|
2192
2222
|
title: 'Bulk Update Subscribers',
|
|
@@ -2324,7 +2354,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2324
2354
|
}
|
|
2325
2355
|
catch (error) {
|
|
2326
2356
|
if (isSegmentRulesError(error))
|
|
2327
|
-
return txt(`Error: ${error.
|
|
2357
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2328
2358
|
throw error;
|
|
2329
2359
|
}
|
|
2330
2360
|
}
|
|
@@ -2349,7 +2379,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2349
2379
|
}
|
|
2350
2380
|
const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
|
|
2351
2381
|
if (error)
|
|
2352
|
-
return txt(`Error: ${error.
|
|
2382
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2353
2383
|
// Get full count separately
|
|
2354
2384
|
let countQ = db()
|
|
2355
2385
|
.from('subscribers')
|
|
@@ -2435,7 +2465,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2435
2465
|
.order('email')
|
|
2436
2466
|
.range(offset, offset + 999);
|
|
2437
2467
|
if (suppressionError)
|
|
2438
|
-
return txt(`Error: ${suppressionError.
|
|
2468
|
+
return txt(`Error: ${dbToolError(suppressionError).error}`);
|
|
2439
2469
|
for (const suppression of suppressed ?? [])
|
|
2440
2470
|
suppressedSet.add(suppression.email);
|
|
2441
2471
|
if ((suppressed ?? []).length < 1000)
|
|
@@ -2443,7 +2473,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2443
2473
|
}
|
|
2444
2474
|
const { data: contacts, error } = await q.order('engagement_score', { ascending: true }).limit(sampleLimit);
|
|
2445
2475
|
if (error)
|
|
2446
|
-
return txt(`Error: ${error.
|
|
2476
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2447
2477
|
const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
|
|
2448
2478
|
if (!dry_run && inactive.length > 0) {
|
|
2449
2479
|
const channelContext = currentChannelContext();
|
|
@@ -2475,7 +2505,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2475
2505
|
.from('suppressions')
|
|
2476
2506
|
.upsert(suppressionRows, { onConflict: 'project_id,email', ignoreDuplicates: true });
|
|
2477
2507
|
if (supErr)
|
|
2478
|
-
return txt(`Error suppressing: ${supErr.
|
|
2508
|
+
return txt(`Error suppressing: ${dbToolError(supErr).error}`);
|
|
2479
2509
|
return j({ suppressed: inactive.length, days_threshold: days, sample: inactive });
|
|
2480
2510
|
}
|
|
2481
2511
|
return j({
|
|
@@ -2511,7 +2541,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2511
2541
|
.select()
|
|
2512
2542
|
.single();
|
|
2513
2543
|
if (error)
|
|
2514
|
-
return txt(`Error: ${error.
|
|
2544
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2515
2545
|
if (!data)
|
|
2516
2546
|
return txt(`No suppression found for ${email}.`);
|
|
2517
2547
|
return j({ removed: true, email, previous_reason: data.reason });
|
|
@@ -2594,7 +2624,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2594
2624
|
for (const ids of chunkIds(siteIds)) {
|
|
2595
2625
|
const part = await scanChunk(ids);
|
|
2596
2626
|
if (!part.ok)
|
|
2597
|
-
return txt(`Error: ${part.error.
|
|
2627
|
+
return txt(`Error: ${dbToolError(part.error).error}`);
|
|
2598
2628
|
all.push(...part.rows);
|
|
2599
2629
|
total = total === null || part.total === null ? null : total + part.total;
|
|
2600
2630
|
}
|
|
@@ -2606,7 +2636,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2606
2636
|
scan = await scanChunk(siteIds);
|
|
2607
2637
|
}
|
|
2608
2638
|
if (!scan.ok)
|
|
2609
|
-
return txt(`Error: ${scan.error.
|
|
2639
|
+
return txt(`Error: ${dbToolError(scan.error).error}`);
|
|
2610
2640
|
const contacts = scan.rows;
|
|
2611
2641
|
// #3604: say when the scan stopped at the limit instead of presenting a partial scan as complete.
|
|
2612
2642
|
const scanned = contacts?.length ?? 0;
|
|
@@ -2682,7 +2712,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2682
2712
|
p_source_ids: source_ids,
|
|
2683
2713
|
});
|
|
2684
2714
|
if (error)
|
|
2685
|
-
return txt(`Error: ${error.
|
|
2715
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2686
2716
|
logAudit({ action: 'contacts.merged', resourceType: 'contact', resourceId: target_id, details: { source_ids, result: data } });
|
|
2687
2717
|
return j(data);
|
|
2688
2718
|
});
|
|
@@ -2706,7 +2736,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2706
2736
|
...(siteScope ? { p_site_id: siteScope } : {}),
|
|
2707
2737
|
});
|
|
2708
2738
|
if (error)
|
|
2709
|
-
return { error: `Error: ${error.
|
|
2739
|
+
return { error: `Error: ${dbToolError(error).error}` };
|
|
2710
2740
|
const result = (Array.isArray(data) ? data[0] : data);
|
|
2711
2741
|
if (typeof result?.has_membership !== 'boolean' || !Array.isArray(result.keys)) {
|
|
2712
2742
|
return { error: 'Error: get_contact_schema_keys returned an unreadable result' };
|
|
@@ -2811,7 +2841,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2811
2841
|
if (error) {
|
|
2812
2842
|
if (error.code === '23505')
|
|
2813
2843
|
return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
|
|
2814
|
-
return j({ error: `Error: ${error.
|
|
2844
|
+
return j({ error: `Error: ${dbToolError(error).error}`, code: 'INTERNAL_ERROR' });
|
|
2815
2845
|
}
|
|
2816
2846
|
await logAudit({
|
|
2817
2847
|
action: 'custom_field.created',
|
|
@@ -2842,7 +2872,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2842
2872
|
.eq('project_id', projectId)
|
|
2843
2873
|
.maybeSingle();
|
|
2844
2874
|
if (currentError)
|
|
2845
|
-
return txt(`Error: ${currentError.
|
|
2875
|
+
return txt(`Error: ${dbToolError(currentError).error}`);
|
|
2846
2876
|
if (!current)
|
|
2847
2877
|
return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
|
|
2848
2878
|
const nextType = (type ?? current.type);
|
|
@@ -2868,7 +2898,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2868
2898
|
.select('id, key, label, type, required, options, display_order')
|
|
2869
2899
|
.maybeSingle();
|
|
2870
2900
|
if (error)
|
|
2871
|
-
return txt(`Error: ${error.
|
|
2901
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2872
2902
|
if (!data)
|
|
2873
2903
|
return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
|
|
2874
2904
|
await logAudit({
|
|
@@ -2894,7 +2924,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2894
2924
|
.select('id, key')
|
|
2895
2925
|
.maybeSingle();
|
|
2896
2926
|
if (error)
|
|
2897
|
-
return txt(`Error: ${error.
|
|
2927
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2898
2928
|
if (!data)
|
|
2899
2929
|
return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
|
|
2900
2930
|
await logAudit({
|
|
@@ -2921,7 +2951,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2921
2951
|
}, async ({ contact_id, window_hours }) => {
|
|
2922
2952
|
const { data, error } = await getContactNeighborhood(db(), getProjectId(), contact_id, window_hours);
|
|
2923
2953
|
if (error)
|
|
2924
|
-
return txt(`Error: ${error.
|
|
2954
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2925
2955
|
if (data == null)
|
|
2926
2956
|
return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
|
|
2927
2957
|
return j({ ...data, attention: buildPreviewAttentionEnvelope() });
|
|
@@ -2942,7 +2972,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2942
2972
|
}, async ({ contact_id, window_hours }) => {
|
|
2943
2973
|
const { data, error } = await getScheduledTouches(db(), getProjectId(), contact_id, window_hours);
|
|
2944
2974
|
if (error)
|
|
2945
|
-
return txt(`Error: ${error.
|
|
2975
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2946
2976
|
if (data == null)
|
|
2947
2977
|
return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
|
|
2948
2978
|
return j({ ...data, attention: buildPreviewAttentionEnvelope() });
|
|
@@ -2969,7 +2999,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2969
2999
|
enrollmentId: enrollment_id,
|
|
2970
3000
|
});
|
|
2971
3001
|
if (error)
|
|
2972
|
-
return txt(`Error: ${error.
|
|
3002
|
+
return txt(`Error: ${dbToolError(error).error}`);
|
|
2973
3003
|
if (data == null)
|
|
2974
3004
|
return j({ error: 'Not found', code: 'NOT_FOUND' });
|
|
2975
3005
|
return j(data);
|
|
@@ -2994,7 +3024,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2994
3024
|
}
|
|
2995
3025
|
const { data, error } = await getContactNeighborhood(db(), getProjectId(), contactId);
|
|
2996
3026
|
const payload = error
|
|
2997
|
-
? { error
|
|
3027
|
+
? { ...dbToolError(error) }
|
|
2998
3028
|
: data == null
|
|
2999
3029
|
? { error: 'Subscriber not found', code: 'NOT_FOUND' }
|
|
3000
3030
|
: data;
|