@sendinel/mcp-server 1.0.145 → 1.0.146
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/tags.d.ts +10 -0
- package/build/lib/tags.js +29 -0
- package/build/tools/contacts.js +72 -41
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Normalize input only: preserve first-seen spelling for exact-match consumers. */
|
|
2
|
+
export declare function normalizeTagInput(input: string[]): {
|
|
3
|
+
tags: string[];
|
|
4
|
+
dropped_empty: number;
|
|
5
|
+
case_duplicates: {
|
|
6
|
+
kept: string;
|
|
7
|
+
dropped: string;
|
|
8
|
+
}[];
|
|
9
|
+
};
|
|
10
|
+
export declare function tagInputWarnings(result: ReturnType<typeof normalizeTagInput>): string[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Normalize input only: preserve first-seen spelling for exact-match consumers. */
|
|
2
|
+
export function normalizeTagInput(input) {
|
|
3
|
+
const tags = [];
|
|
4
|
+
const seen = new Map();
|
|
5
|
+
let dropped_empty = 0;
|
|
6
|
+
const case_duplicates = [];
|
|
7
|
+
for (const value of input) {
|
|
8
|
+
const tag = value.trim();
|
|
9
|
+
if (!tag) {
|
|
10
|
+
dropped_empty++;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const key = tag.toLowerCase();
|
|
14
|
+
const kept = seen.get(key);
|
|
15
|
+
if (kept !== undefined) {
|
|
16
|
+
case_duplicates.push({ kept, dropped: tag });
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
seen.set(key, tag);
|
|
20
|
+
tags.push(tag);
|
|
21
|
+
}
|
|
22
|
+
return { tags, dropped_empty, case_duplicates };
|
|
23
|
+
}
|
|
24
|
+
export function tagInputWarnings(result) {
|
|
25
|
+
return [
|
|
26
|
+
...(result.dropped_empty ? [`Dropped ${result.dropped_empty} empty tag(s).`] : []),
|
|
27
|
+
...result.case_duplicates.map(({ kept, dropped }) => `Tag "${dropped}" duplicates "${kept}" (case-insensitive); kept "${kept}".`),
|
|
28
|
+
];
|
|
29
|
+
}
|
package/build/tools/contacts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
|
|
1
2
|
import { dbErrorResult } from '../lib/db-errors.js';
|
|
2
3
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
@@ -1299,6 +1300,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1299
1300
|
company_id: z.string().uuid().optional(),
|
|
1300
1301
|
company_name: z.string().optional(),
|
|
1301
1302
|
company_domain: z.string().optional(),
|
|
1303
|
+
tags: z.array(z.string()).optional().describe('Initial tags for new subscribers; existing contacts keep their stored tags.'),
|
|
1302
1304
|
source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
|
|
1303
1305
|
utm_source: z.string().optional(),
|
|
1304
1306
|
utm_medium: z.string().optional(),
|
|
@@ -1306,8 +1308,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1306
1308
|
utm_content: z.string().optional(),
|
|
1307
1309
|
utm_term: z.string().optional(),
|
|
1308
1310
|
},
|
|
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 }) => {
|
|
1311
|
+
}, 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
1312
|
try {
|
|
1313
|
+
const normalizedTags = normalizeTagInput(tags ?? []);
|
|
1314
|
+
const warnings = tagInputWarnings(normalizedTags);
|
|
1315
|
+
const tagWarnings = warnings.length ? { warnings } : {};
|
|
1311
1316
|
email = email.trim().toLowerCase();
|
|
1312
1317
|
const projectId = getProjectId();
|
|
1313
1318
|
// Block distribution list / system addresses
|
|
@@ -1351,6 +1356,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1351
1356
|
let contact;
|
|
1352
1357
|
if (!existingContact) {
|
|
1353
1358
|
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1359
|
+
if (tags !== undefined)
|
|
1360
|
+
row.tags = normalizedTags.tags;
|
|
1354
1361
|
if (customProperties)
|
|
1355
1362
|
row.custom_properties = customProperties;
|
|
1356
1363
|
if (!needsConfirm)
|
|
@@ -1426,15 +1433,15 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1426
1433
|
if (needsConfirm) {
|
|
1427
1434
|
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1428
1435
|
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 });
|
|
1436
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1430
1437
|
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) }] };
|
|
1438
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1432
1439
|
}
|
|
1433
|
-
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
|
|
1440
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1434
1441
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1435
1442
|
if (!existing)
|
|
1436
1443
|
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) }] };
|
|
1444
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, ...existingMessage }, null, 2) }] };
|
|
1438
1445
|
}
|
|
1439
1446
|
catch (error) {
|
|
1440
1447
|
return dbErrorResult(error);
|
|
@@ -1815,6 +1822,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1815
1822
|
}, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
|
|
1816
1823
|
try {
|
|
1817
1824
|
const projectId = getProjectId();
|
|
1825
|
+
const normalizedTags = tags !== undefined ? normalizeTagInput(tags) : undefined;
|
|
1826
|
+
if (normalizedTags)
|
|
1827
|
+
tags = normalizedTags.tags;
|
|
1818
1828
|
let propertyWarnings = [];
|
|
1819
1829
|
let fields;
|
|
1820
1830
|
if (custom_properties !== undefined) {
|
|
@@ -2000,7 +2010,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2000
2010
|
...(updates.tags !== undefined ? { tags: updates.tags } : {}),
|
|
2001
2011
|
...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
|
|
2002
2012
|
});
|
|
2003
|
-
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
|
|
2013
|
+
const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings, ...(normalizedTags ? tagInputWarnings(normalizedTags) : [])];
|
|
2004
2014
|
return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
|
|
2005
2015
|
}
|
|
2006
2016
|
catch (error) {
|
|
@@ -2136,57 +2146,78 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2136
2146
|
idempotentHint: true,
|
|
2137
2147
|
openWorldHint: false,
|
|
2138
2148
|
},
|
|
2139
|
-
description: 'Add or remove tags from one or more contacts',
|
|
2149
|
+
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
2150
|
inputSchema: {
|
|
2141
2151
|
contact_ids: z.array(z.string().uuid()).min(1),
|
|
2142
2152
|
add_tags: z.array(z.string()).optional(),
|
|
2143
2153
|
remove_tags: z.array(z.string()).optional(),
|
|
2144
2154
|
},
|
|
2145
2155
|
}, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
|
|
2156
|
+
const normalized = normalizeTagInput(add_tags);
|
|
2157
|
+
add_tags = normalized.tags;
|
|
2158
|
+
remove_tags = remove_tags.map(tag => tag.trim()).filter(Boolean);
|
|
2159
|
+
const warnings = new Set(tagInputWarnings(normalized));
|
|
2146
2160
|
if (!add_tags.length && !remove_tags.length)
|
|
2147
2161
|
return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
|
|
2148
2162
|
const projectId = getProjectId();
|
|
2149
2163
|
let updated = 0;
|
|
2150
2164
|
const missing_contact_ids = [];
|
|
2165
|
+
const failed = [];
|
|
2151
2166
|
for (const cid of contact_ids) {
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2167
|
+
try {
|
|
2168
|
+
const { data: contact, error: readError } = await db()
|
|
2169
|
+
.from('subscribers')
|
|
2170
|
+
.select('id, email, first_name, last_name, custom_properties, tags')
|
|
2171
|
+
.eq('id', cid)
|
|
2172
|
+
.eq('project_id', projectId)
|
|
2173
|
+
.maybeSingle();
|
|
2174
|
+
if (readError) {
|
|
2175
|
+
failed.push({ id: cid, ...dbToolError(readError) });
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
if (!contact) {
|
|
2179
|
+
missing_contact_ids.push(cid);
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
const currentTags = contact.tags ?? [];
|
|
2183
|
+
const removeSet = new Set(remove_tags.map(tag => tag.toLowerCase()));
|
|
2184
|
+
const merged = currentTags.filter((t) => !removeSet.has(t.trim().toLowerCase()));
|
|
2185
|
+
for (const tag of add_tags) {
|
|
2186
|
+
const existing = merged.find(value => value.trim().toLowerCase() === tag.toLowerCase());
|
|
2187
|
+
if (existing !== undefined)
|
|
2188
|
+
warnings.add(`${tag} already exists as ${existing}`);
|
|
2189
|
+
else
|
|
2190
|
+
merged.push(tag);
|
|
2191
|
+
}
|
|
2192
|
+
const { data: saved, error } = await db()
|
|
2193
|
+
.from('subscribers')
|
|
2194
|
+
.update({ tags: merged })
|
|
2195
|
+
.eq('id', cid)
|
|
2196
|
+
.eq('project_id', projectId)
|
|
2197
|
+
.select('id').maybeSingle();
|
|
2198
|
+
if (error) {
|
|
2199
|
+
failed.push({ id: cid, ...dbToolError(error) });
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
if (!saved) {
|
|
2203
|
+
missing_contact_ids.push(cid);
|
|
2204
|
+
continue;
|
|
2205
|
+
}
|
|
2206
|
+
updated++;
|
|
2207
|
+
await writeBackMcpContact(projectId, contact, { tags: merged });
|
|
2208
|
+
await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
|
|
2170
2209
|
}
|
|
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;
|
|
2210
|
+
catch {
|
|
2211
|
+
failed.push({ id: cid, ...dbToolError({}) });
|
|
2182
2212
|
}
|
|
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
2213
|
}
|
|
2214
|
+
const result = { missing_contact_ids, not_found: missing_contact_ids, failed, updated, total: contact_ids.length, add_tags, remove_tags, ...(warnings.size ? { warnings: [...warnings] } : {}) };
|
|
2215
|
+
// Preserve the existing coded-error convention when no requested write succeeded.
|
|
2216
|
+
if (!updated && failed.length)
|
|
2217
|
+
return toolError({ ...dbToolError({}), ...result, code: failed[0].code, error: failed[0].error });
|
|
2187
2218
|
if (!updated)
|
|
2188
|
-
return toolError({ error: 'Subscriber not found', code: 'not_found',
|
|
2189
|
-
return j(
|
|
2219
|
+
return toolError({ error: 'Subscriber not found', code: 'not_found', ...result });
|
|
2220
|
+
return j(result);
|
|
2190
2221
|
});
|
|
2191
2222
|
server.registerTool('bulk_update_subscribers', {
|
|
2192
2223
|
title: 'Bulk Update Subscribers',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.146",
|
|
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",
|