@sendinel/mcp-server 1.0.171 → 1.0.173
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type Anthropic from '@anthropic-ai/sdk';
|
|
2
2
|
import { type ComposerBlock } from './composer-blocks.js';
|
|
3
|
+
/** Known unsupported targeting must fail before a model can invent a property rule. */
|
|
4
|
+
export declare function unsupportedAudienceReason(description: string): string | null;
|
|
5
|
+
export declare class AudienceNotUnderstoodError extends Error {
|
|
6
|
+
}
|
|
3
7
|
export declare function parseAudienceRules(anthropic: Anthropic, description: string): Promise<Record<string, unknown>>;
|
|
4
8
|
export type GeneratedEmail = {
|
|
5
9
|
subject: string;
|
|
@@ -11,24 +11,39 @@ import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerB
|
|
|
11
11
|
function parseJsonResponse(raw) {
|
|
12
12
|
return JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
|
|
13
13
|
}
|
|
14
|
+
const EMAIL_DOMAIN_AUDIENCE_MESSAGE = 'Audiences by email address or domain are not supported. Create a segment using a tag or property rule, or pass segment_id for a saved segment.';
|
|
15
|
+
/** Known unsupported targeting must fail before a model can invent a property rule. */
|
|
16
|
+
export function unsupportedAudienceReason(description) {
|
|
17
|
+
return /\be-?mail[\s_-]+domains?\b|\be-?mail(?:\s+addresses?)?\s+(?:ends?\s+with|contains?|matches?|is\b|at\b)|@[a-z0-9](?:[a-z0-9.-]*\.)[a-z]{2,}\b/i.test(description)
|
|
18
|
+
? EMAIL_DOMAIN_AUDIENCE_MESSAGE : null;
|
|
19
|
+
}
|
|
20
|
+
export class AudienceNotUnderstoodError extends Error {
|
|
21
|
+
}
|
|
14
22
|
export async function parseAudienceRules(anthropic, description) {
|
|
23
|
+
const unsupported = unsupportedAudienceReason(description);
|
|
24
|
+
if (unsupported)
|
|
25
|
+
throw new AudienceNotUnderstoodError(unsupported);
|
|
15
26
|
const resp = await anthropic.messages.create({
|
|
16
27
|
model: getAnthropicModelId(),
|
|
17
28
|
max_tokens: AI_MAX_TOKENS,
|
|
18
29
|
...outputConfig("low"),
|
|
19
|
-
system: 'You translate natural language audience descriptions into a JSON filter rules object. Return ONLY valid JSON. Available keys: lifecycle_stage (lead|trial|customer|churned|win-back), payment (object with plan, mrr_cents, ltv_cents, subscription_status, trial_ends_at, churned_at, paid_at), min_score, max_score, source, subscribed_after, subscribed_before, has_opened, has_clicked, tag, custom_property, exclude_suppressed. Example: {"payment":{"plan":"pro","trial_ends_at":{"within_days":7}},"lifecycle_stage":"trial"}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
|
|
30
|
+
system: 'You translate natural language audience descriptions into a JSON filter rules object. Return ONLY valid JSON. Available keys: match_all, lifecycle_stage (lead|trial|customer|churned|win-back), payment (object with plan, mrr_cents, ltv_cents, subscription_status, trial_ends_at, churned_at, paid_at), min_score, max_score, source, subscribed_after, subscribed_before, has_opened, has_clicked, tag, custom_property (a string like "plan=pro"), exclude_suppressed. Everyone, all contacts, all subscribers or all means {"match_all":true}. There is no email address or email-domain rule. Never invent keys or use custom_property for email targeting; return {"error":"' + EMAIL_DOMAIN_AUDIENCE_MESSAGE + '"} instead. For any other unsupported audience return an error object explaining why. Example: {"payment":{"plan":"pro","trial_ends_at":{"within_days":7}},"lifecycle_stage":"trial"}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
|
|
20
31
|
messages: [{ role: 'user', content: description }],
|
|
21
32
|
});
|
|
22
33
|
const text = anthropicCheckedText(resp);
|
|
34
|
+
let rules;
|
|
23
35
|
try {
|
|
24
|
-
|
|
36
|
+
rules = parseJsonResponse(text);
|
|
25
37
|
if (!rules || typeof rules !== 'object' || Array.isArray(rules))
|
|
26
38
|
throw new Error('not an object');
|
|
27
|
-
return rules;
|
|
28
39
|
}
|
|
29
40
|
catch {
|
|
30
|
-
throw new
|
|
41
|
+
throw new AudienceNotUnderstoodError(`Could not understand the audience "${description}". Try a saved segment instead.`);
|
|
31
42
|
}
|
|
43
|
+
if ('error' in rules)
|
|
44
|
+
throw new AudienceNotUnderstoodError(typeof rules.error === 'string'
|
|
45
|
+
? rules.error : 'This audience is not supported. Try a saved segment instead.');
|
|
46
|
+
return rules;
|
|
32
47
|
}
|
|
33
48
|
export async function generateEmailFromBrief(anthropic, projectId, siteId, brief) {
|
|
34
49
|
const [{ data: site, error: siteError }, { data: project, error: projectError }] = await Promise.all([
|
package/build/tools/campaigns.js
CHANGED
|
@@ -4,7 +4,7 @@ import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } fro
|
|
|
4
4
|
import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import Anthropic from '@anthropic-ai/sdk';
|
|
7
|
-
import { stripActiveEmailContent, assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
|
|
7
|
+
import { stripActiveEmailContent, assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, SEGMENT_MATCH_ALL, } from '@sendinel/domain';
|
|
8
8
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
9
9
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
10
10
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -22,7 +22,7 @@ import { fireWebhookEvent } from '../lib/webhook-events.js';
|
|
|
22
22
|
import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
|
|
23
23
|
import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
|
|
24
24
|
import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
|
|
25
|
-
import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
|
|
25
|
+
import { AudienceNotUnderstoodError, generateEmailFromBrief, parseAudienceRules, unsupportedAudienceReason } from '../lib/email-generation.js';
|
|
26
26
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
27
27
|
import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/domain';
|
|
28
28
|
import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } from '../lib/schedule.js';
|
|
@@ -1580,8 +1580,12 @@ export function registerCampaignTools(server) {
|
|
|
1580
1580
|
sites: (sites ?? []).map((s) => ({ id: s.id, name: s.name, from_email: s.from_email })),
|
|
1581
1581
|
});
|
|
1582
1582
|
}
|
|
1583
|
+
const everyone = audience !== undefined && ['everyone', 'all contacts', 'all subscribers', 'all'].includes(audience.trim().toLowerCase().replace(/[.!?,;:]+$/, '').trim());
|
|
1584
|
+
const unsupported = audience ? unsupportedAudienceReason(audience) : null;
|
|
1585
|
+
if (unsupported)
|
|
1586
|
+
return j({ error: unsupported, code: 'AUDIENCE_NOT_UNDERSTOOD' });
|
|
1583
1587
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
1584
|
-
if ((audience || brief) && !apiKey)
|
|
1588
|
+
if (((audience && !everyone) || brief) && !apiKey)
|
|
1585
1589
|
return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_NOT_CONFIGURED' });
|
|
1586
1590
|
const anthropic = apiKey ? new Anthropic({ apiKey }) : null;
|
|
1587
1591
|
let rules;
|
|
@@ -1600,12 +1604,17 @@ export function registerCampaignTools(server) {
|
|
|
1600
1604
|
rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
|
|
1601
1605
|
segmentName = segment.name;
|
|
1602
1606
|
}
|
|
1607
|
+
else if (everyone) {
|
|
1608
|
+
rules = { ...SEGMENT_MATCH_ALL };
|
|
1609
|
+
segmentName = audience;
|
|
1610
|
+
}
|
|
1603
1611
|
else {
|
|
1604
1612
|
try {
|
|
1605
1613
|
rules = await parseAudienceRules(anthropic, audience);
|
|
1606
1614
|
}
|
|
1607
1615
|
catch (error) {
|
|
1608
|
-
return j({ error: error instanceof
|
|
1616
|
+
return j({ error: error instanceof AudienceNotUnderstoodError ? error.message
|
|
1617
|
+
: error instanceof Error ? dbToolError(error).error : String(error), code: 'AUDIENCE_NOT_UNDERSTOOD' });
|
|
1609
1618
|
}
|
|
1610
1619
|
segmentName = audience;
|
|
1611
1620
|
}
|
package/build/tools/contacts.js
CHANGED
|
@@ -2,7 +2,7 @@ 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';
|
|
5
|
-
import { buildPreviewAttentionEnvelope, buildSafeIlikeOrFilter, literalIlikePattern, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, validateContactPropertyPatch, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
5
|
+
import { buildPreviewAttentionEnvelope, buildSafeIlikeOrFilter, literalIlikePattern, applySegmentRules, assertValidSegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, validateContactPropertyPatch, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { db, projectClientForWriteback } from '../db.js';
|
|
@@ -2315,6 +2315,17 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2315
2315
|
}, async (params) => {
|
|
2316
2316
|
const projectId = getProjectId();
|
|
2317
2317
|
const sampleSize = params.sample_size ?? 5;
|
|
2318
|
+
// Validate the same flat-rule shape as saved segments before any query.
|
|
2319
|
+
// No filters deliberately means all contacts for this estimation tool.
|
|
2320
|
+
const { sample_size: _sampleSize, ...rules } = params;
|
|
2321
|
+
try {
|
|
2322
|
+
assertValidSegmentRules(rules, { allowEmpty: true });
|
|
2323
|
+
}
|
|
2324
|
+
catch (error) {
|
|
2325
|
+
if (isSegmentRulesError(error))
|
|
2326
|
+
return toolError({ error: error.message, code: 'validation_failed' });
|
|
2327
|
+
throw error;
|
|
2328
|
+
}
|
|
2318
2329
|
let q = db()
|
|
2319
2330
|
.from('subscribers')
|
|
2320
2331
|
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
|
|
@@ -2352,7 +2363,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2352
2363
|
}
|
|
2353
2364
|
catch (error) {
|
|
2354
2365
|
if (isSegmentRulesError(error))
|
|
2355
|
-
return
|
|
2366
|
+
return toolError({ error: error.message, code: 'validation_failed' });
|
|
2356
2367
|
throw error;
|
|
2357
2368
|
}
|
|
2358
2369
|
}
|
|
@@ -2365,11 +2376,13 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2365
2376
|
q = siteFiltered.query;
|
|
2366
2377
|
}
|
|
2367
2378
|
if (params.company_domain) {
|
|
2368
|
-
const { data: companies } = await db()
|
|
2379
|
+
const { data: companies, error } = await db()
|
|
2369
2380
|
.from('companies')
|
|
2370
2381
|
.select('id')
|
|
2371
2382
|
.eq('project_id', projectId)
|
|
2372
2383
|
.eq('domain', params.company_domain.trim().toLowerCase());
|
|
2384
|
+
if (error)
|
|
2385
|
+
return dbErrorResult(error);
|
|
2373
2386
|
const ids = (companies ?? []).map((company) => company.id);
|
|
2374
2387
|
if (!ids.length)
|
|
2375
2388
|
return j({ total: 0, sample: [] });
|
|
@@ -2377,7 +2390,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2377
2390
|
}
|
|
2378
2391
|
const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
|
|
2379
2392
|
if (error)
|
|
2380
|
-
return
|
|
2393
|
+
return dbErrorResult(error);
|
|
2381
2394
|
// Get full count separately
|
|
2382
2395
|
let countQ = db()
|
|
2383
2396
|
.from('subscribers')
|
|
@@ -2412,7 +2425,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2412
2425
|
// The total must count the same site the sample came from (#3674).
|
|
2413
2426
|
if (params.site_id)
|
|
2414
2427
|
({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
|
|
2415
|
-
const { count } = await countQ;
|
|
2428
|
+
const { count, error: countError } = await countQ;
|
|
2429
|
+
if (countError)
|
|
2430
|
+
return dbErrorResult(countError);
|
|
2416
2431
|
return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
|
|
2417
2432
|
});
|
|
2418
2433
|
server.registerTool('identify_inactive', {
|
package/build/tools/segments.js
CHANGED
|
@@ -63,7 +63,9 @@ const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
|
63
63
|
* never an audience of everyone. Returns null for any other error.
|
|
64
64
|
*/
|
|
65
65
|
function invalidRules(error) {
|
|
66
|
-
return isSegmentRulesError(error)
|
|
66
|
+
return isSegmentRulesError(error)
|
|
67
|
+
? toolError({ error: error.message, code: 'validation_failed' })
|
|
68
|
+
: null;
|
|
67
69
|
}
|
|
68
70
|
export function registerSegmentTools(server) {
|
|
69
71
|
server.registerTool('create_segment', {
|
|
@@ -89,7 +91,7 @@ export function registerSegmentTools(server) {
|
|
|
89
91
|
catch (rulesError) {
|
|
90
92
|
const invalid = invalidRules(rulesError);
|
|
91
93
|
if (invalid)
|
|
92
|
-
return
|
|
94
|
+
return invalid;
|
|
93
95
|
throw rulesError;
|
|
94
96
|
}
|
|
95
97
|
const { data, error } = await db()
|
|
@@ -100,7 +102,7 @@ export function registerSegmentTools(server) {
|
|
|
100
102
|
if (error?.code === '23505')
|
|
101
103
|
return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
|
|
102
104
|
if (error)
|
|
103
|
-
return
|
|
105
|
+
return dbErrorResult(error);
|
|
104
106
|
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
105
107
|
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
106
108
|
// does for this same segment a moment later. Pushing the flat rules down instead
|
|
@@ -171,7 +173,7 @@ export function registerSegmentTools(server) {
|
|
|
171
173
|
updates.name = name;
|
|
172
174
|
if (description !== undefined)
|
|
173
175
|
updates.description = description;
|
|
174
|
-
if (rules) {
|
|
176
|
+
if (rules !== undefined) {
|
|
175
177
|
try {
|
|
176
178
|
updates.rules = prepareSegmentRulesForStorage(rules);
|
|
177
179
|
}
|
|
@@ -242,11 +244,13 @@ export function registerSegmentTools(server) {
|
|
|
242
244
|
.eq('id', segment_id)
|
|
243
245
|
.eq('project_id', getProjectId())
|
|
244
246
|
.maybeSingle();
|
|
245
|
-
if (error
|
|
247
|
+
if (error)
|
|
248
|
+
return dbErrorResult(error);
|
|
249
|
+
if (!seg)
|
|
246
250
|
return txt('Error: segment not found');
|
|
247
251
|
segRules = seg.rules;
|
|
248
252
|
}
|
|
249
|
-
else if (rules) {
|
|
253
|
+
else if (rules !== undefined) {
|
|
250
254
|
segRules = rules;
|
|
251
255
|
}
|
|
252
256
|
else {
|
|
@@ -265,10 +269,12 @@ export function registerSegmentTools(server) {
|
|
|
265
269
|
const sample = ids.slice(0, 10);
|
|
266
270
|
let sampleContacts = [];
|
|
267
271
|
if (sample.length) {
|
|
268
|
-
const { data } = await db()
|
|
272
|
+
const { data, error } = await db()
|
|
269
273
|
.from('subscribers')
|
|
270
274
|
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, tags')
|
|
271
275
|
.in('id', sample);
|
|
276
|
+
if (error)
|
|
277
|
+
return dbErrorResult(error);
|
|
272
278
|
sampleContacts = data ?? [];
|
|
273
279
|
}
|
|
274
280
|
return j({ total_contacts: ids.length, sample: sampleContacts });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.173",
|
|
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",
|