@sendinel/mcp-server 1.0.10 → 1.0.11
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/README.md +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/analytics.js +97 -0
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +326 -52
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data.js +441 -0
- package/build/tools/delivery-ops.js +508 -1
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +549 -33
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +13 -12
package/build/tools/segments.js
CHANGED
|
@@ -3,160 +3,41 @@ import Anthropic from '@anthropic-ai/sdk';
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (source.min_score !== undefined)
|
|
18
|
-
conditions.push({ field: 'engagement_score', operator: 'gte', value: source.min_score });
|
|
19
|
-
if (source.max_score !== undefined)
|
|
20
|
-
conditions.push({ field: 'engagement_score', operator: 'lte', value: source.max_score });
|
|
21
|
-
if (source.source)
|
|
22
|
-
conditions.push({ field: 'source', operator: 'eq', value: source.source });
|
|
23
|
-
if (source.subscribed_after)
|
|
24
|
-
conditions.push({ field: 'created_at', operator: 'on_or_after', value: source.subscribed_after });
|
|
25
|
-
if (source.subscribed_before)
|
|
26
|
-
conditions.push({ field: 'created_at', operator: 'on_or_before', value: source.subscribed_before });
|
|
27
|
-
if (source.has_opened === true)
|
|
28
|
-
conditions.push({ field: 'last_opened_at', operator: 'exists' });
|
|
29
|
-
if (source.has_opened === false)
|
|
30
|
-
conditions.push({ field: 'last_opened_at', operator: 'not_exists' });
|
|
31
|
-
if (source.has_clicked === true)
|
|
32
|
-
conditions.push({ field: 'last_clicked_at', operator: 'exists' });
|
|
33
|
-
if (source.has_clicked === false)
|
|
34
|
-
conditions.push({ field: 'last_clicked_at', operator: 'not_exists' });
|
|
35
|
-
if (source.tag)
|
|
36
|
-
conditions.push({ field: 'tags', operator: 'contains', value: source.tag });
|
|
37
|
-
if (source.custom_property) {
|
|
38
|
-
const idx = source.custom_property.indexOf('=');
|
|
39
|
-
if (idx > 0)
|
|
40
|
-
conditions.push({ field: `custom_properties.${source.custom_property.slice(0, idx)}`, operator: 'eq', value: source.custom_property.slice(idx + 1) });
|
|
41
|
-
}
|
|
42
|
-
if (source.payment) {
|
|
43
|
-
for (const [field, value] of Object.entries(source.payment)) {
|
|
44
|
-
if (value == null || value === '')
|
|
45
|
-
continue;
|
|
46
|
-
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
47
|
-
const rule = value;
|
|
48
|
-
if (typeof rule.within_days === 'number') {
|
|
49
|
-
conditions.push({ field: `payment.${field}`, operator: 'on_or_after', value: new Date().toISOString() });
|
|
50
|
-
conditions.push({ field: `payment.${field}`, operator: 'on_or_before', value: addDaysIso(rule.within_days) });
|
|
51
|
-
}
|
|
52
|
-
if (rule.after)
|
|
53
|
-
conditions.push({ field: `payment.${field}`, operator: 'on_or_after', value: rule.after });
|
|
54
|
-
if (rule.before)
|
|
55
|
-
conditions.push({ field: `payment.${field}`, operator: 'on_or_before', value: rule.before });
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
conditions.push({ field: `payment.${field}`, operator: 'eq', value });
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return { op: 'AND', conditions, exclude_suppressed: source.exclude_suppressed, site_id: source.site_id };
|
|
63
|
-
}
|
|
64
|
-
function addDaysIso(days) {
|
|
65
|
-
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
|
66
|
-
}
|
|
67
|
-
function getComparableValue(contact, field) {
|
|
68
|
-
if (field.startsWith('custom_properties.')) {
|
|
69
|
-
return field.slice('custom_properties.'.length).split('.').reduce((acc, part) => acc == null ? undefined : acc[part], contact.custom_properties);
|
|
6
|
+
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, } from '@sendinel/domain';
|
|
7
|
+
async function loadFieldTypes(projectId) {
|
|
8
|
+
const client = db();
|
|
9
|
+
const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
|
|
10
|
+
const { data, error } = await scoped
|
|
11
|
+
.from('custom_field_definitions')
|
|
12
|
+
.select('key, type, options')
|
|
13
|
+
.eq('project_id', projectId);
|
|
14
|
+
if (error) {
|
|
15
|
+
console.warn('[segments] custom field definition load failed:', error.message ?? error);
|
|
16
|
+
return {};
|
|
70
17
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
function compareValues(actual, operator, expected) {
|
|
77
|
-
if (operator === 'exists')
|
|
78
|
-
return actual !== null && actual !== undefined && actual !== '';
|
|
79
|
-
if (operator === 'not_exists')
|
|
80
|
-
return actual === null || actual === undefined || actual === '';
|
|
81
|
-
if (operator === 'contains')
|
|
82
|
-
return Array.isArray(actual) ? actual.map(String).includes(String(expected)) : typeof actual === 'string' && actual.includes(String(expected));
|
|
83
|
-
if (operator === 'eq')
|
|
84
|
-
return String(actual ?? '') === String(expected ?? '');
|
|
85
|
-
if (operator === 'neq')
|
|
86
|
-
return String(actual ?? '') !== String(expected ?? '');
|
|
87
|
-
const actualNumber = Number(actual);
|
|
88
|
-
const expectedNumber = Number(expected);
|
|
89
|
-
const numeric = Number.isFinite(actualNumber) && Number.isFinite(expectedNumber);
|
|
90
|
-
const left = numeric ? actualNumber : Date.parse(String(actual ?? ''));
|
|
91
|
-
const right = numeric ? expectedNumber : Date.parse(String(expected ?? ''));
|
|
92
|
-
if (!Number.isFinite(left) || !Number.isFinite(right))
|
|
93
|
-
return false;
|
|
94
|
-
if (operator === 'gt' || operator === 'after')
|
|
95
|
-
return left > right;
|
|
96
|
-
if (operator === 'gte' || operator === 'on_or_after')
|
|
97
|
-
return left >= right;
|
|
98
|
-
if (operator === 'lt' || operator === 'before')
|
|
99
|
-
return left < right;
|
|
100
|
-
if (operator === 'lte' || operator === 'on_or_before')
|
|
101
|
-
return left <= right;
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
function evaluateNode(contact, node) {
|
|
105
|
-
if (isSegmentRuleGroup(node)) {
|
|
106
|
-
if (!node.conditions.length)
|
|
107
|
-
return true;
|
|
108
|
-
return node.op === 'OR' ? node.conditions.some((child) => evaluateNode(contact, child)) : node.conditions.every((child) => evaluateNode(contact, child));
|
|
18
|
+
const fields = {};
|
|
19
|
+
for (const row of data ?? []) {
|
|
20
|
+
if (typeof row.key !== 'string' || typeof row.type !== 'string')
|
|
21
|
+
continue;
|
|
22
|
+
fields[row.key] = { type: row.type, options: Array.isArray(row.options) ? row.options : null };
|
|
109
23
|
}
|
|
110
|
-
return
|
|
24
|
+
return fields;
|
|
111
25
|
}
|
|
112
26
|
async function getSegmentContactIds(rules) {
|
|
113
27
|
const projectId = getProjectId();
|
|
114
28
|
const tree = normalizeSegmentRulesToTree(rules);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (rules.min_score !== undefined)
|
|
125
|
-
q = q.gte('engagement_score', rules.min_score);
|
|
126
|
-
if (rules.max_score !== undefined)
|
|
127
|
-
q = q.lte('engagement_score', rules.max_score);
|
|
128
|
-
if (rules.source)
|
|
129
|
-
q = q.eq('source', rules.source);
|
|
130
|
-
if (rules.subscribed_after)
|
|
131
|
-
q = q.gte('created_at', `${rules.subscribed_after}T00:00:00Z`);
|
|
132
|
-
if (rules.subscribed_before)
|
|
133
|
-
q = q.lte('created_at', `${rules.subscribed_before}T23:59:59Z`);
|
|
134
|
-
if (rules.has_opened === true)
|
|
135
|
-
q = q.not('last_opened_at', 'is', null);
|
|
136
|
-
if (rules.has_opened === false)
|
|
137
|
-
q = q.is('last_opened_at', null);
|
|
138
|
-
if (rules.has_clicked === true)
|
|
139
|
-
q = q.not('last_clicked_at', 'is', null);
|
|
140
|
-
if (rules.has_clicked === false)
|
|
141
|
-
q = q.is('last_clicked_at', null);
|
|
142
|
-
if (rules.tag)
|
|
143
|
-
q = q.contains('tags', [rules.tag]);
|
|
144
|
-
}
|
|
145
|
-
if (tree.site_id) {
|
|
146
|
-
const { data: subs } = await db()
|
|
147
|
-
.from('site_subscriptions')
|
|
148
|
-
.select('contact_id')
|
|
149
|
-
.eq('site_id', tree.site_id)
|
|
150
|
-
.eq('subscribed', true);
|
|
151
|
-
const siteIds = (subs ?? []).map((s) => s.contact_id);
|
|
152
|
-
if (!siteIds.length)
|
|
153
|
-
return [];
|
|
154
|
-
q = q.in('id', siteIds);
|
|
155
|
-
}
|
|
156
|
-
const { data, error } = await q;
|
|
29
|
+
const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
|
|
30
|
+
// Shared base query (#3084). This used to be hand-written here and omitted
|
|
31
|
+
// is_test / source_deleted_at / the cooled exclusion the dashboard applies,
|
|
32
|
+
// so the same stored segment counted differently through MCP.
|
|
33
|
+
const q = buildSegmentBaseQuery(db(), projectId);
|
|
34
|
+
const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
|
|
35
|
+
if (siteContactIds && siteContactIds.length === 0)
|
|
36
|
+
return [];
|
|
37
|
+
const { data, error } = await filtered;
|
|
157
38
|
if (error || !data)
|
|
158
39
|
return [];
|
|
159
|
-
|
|
40
|
+
const contacts = data.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
|
|
160
41
|
let ids = contacts.map((c) => String(c.id));
|
|
161
42
|
if (tree.exclude_suppressed !== false && ids.length > 0) {
|
|
162
43
|
const { data: suppressed } = await db()
|
|
@@ -172,42 +53,6 @@ async function getSegmentContactIds(rules) {
|
|
|
172
53
|
}
|
|
173
54
|
return ids;
|
|
174
55
|
}
|
|
175
|
-
function applyPaymentRules(query, payment) {
|
|
176
|
-
if (!payment)
|
|
177
|
-
return query;
|
|
178
|
-
let q = query;
|
|
179
|
-
if (payment.plan)
|
|
180
|
-
q = q.contains('custom_properties', { payment: { plan: payment.plan } });
|
|
181
|
-
if (payment.mrr_cents !== undefined)
|
|
182
|
-
q = q.contains('custom_properties', { payment: { mrr_cents: payment.mrr_cents } });
|
|
183
|
-
if (payment.ltv_cents !== undefined)
|
|
184
|
-
q = q.contains('custom_properties', { payment: { ltv_cents: payment.ltv_cents } });
|
|
185
|
-
if (payment.subscription_status)
|
|
186
|
-
q = q.contains('custom_properties', { payment: { subscription_status: payment.subscription_status } });
|
|
187
|
-
q = applyPaymentDateRule(q, 'trial_ends_at', payment.trial_ends_at);
|
|
188
|
-
q = applyPaymentDateRule(q, 'churned_at', payment.churned_at);
|
|
189
|
-
q = applyPaymentDateRule(q, 'paid_at', payment.paid_at);
|
|
190
|
-
return q;
|
|
191
|
-
}
|
|
192
|
-
function applyPaymentDateRule(query, field, rule) {
|
|
193
|
-
if (!rule)
|
|
194
|
-
return query;
|
|
195
|
-
if (typeof rule === 'string') {
|
|
196
|
-
return query.contains('custom_properties', { payment: { [field]: rule } });
|
|
197
|
-
}
|
|
198
|
-
let q = query;
|
|
199
|
-
const column = `custom_properties->payment->>${field}`;
|
|
200
|
-
if (typeof rule.within_days === 'number') {
|
|
201
|
-
const start = new Date();
|
|
202
|
-
const end = new Date(start.getTime() + rule.within_days * 24 * 60 * 60 * 1000);
|
|
203
|
-
q = q.gte(column, start.toISOString()).lte(column, end.toISOString());
|
|
204
|
-
}
|
|
205
|
-
if (rule.after)
|
|
206
|
-
q = q.gte(column, rule.after);
|
|
207
|
-
if (rule.before)
|
|
208
|
-
q = q.lte(column, rule.before);
|
|
209
|
-
return q;
|
|
210
|
-
}
|
|
211
56
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
212
57
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
213
58
|
export function registerSegmentTools(server) {
|
|
@@ -223,7 +68,7 @@ export function registerSegmentTools(server) {
|
|
|
223
68
|
inputSchema: {
|
|
224
69
|
name: z.string().min(1).describe('Segment name (unique per project)'),
|
|
225
70
|
description: z.string().optional(),
|
|
226
|
-
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, tags, custom_properties.<key>, payment.<key>.'),
|
|
71
|
+
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>. tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
|
|
227
72
|
},
|
|
228
73
|
}, async ({ name, description, rules }) => {
|
|
229
74
|
const { data, error } = await db()
|
|
@@ -233,6 +78,12 @@ export function registerSegmentTools(server) {
|
|
|
233
78
|
.single();
|
|
234
79
|
if (error)
|
|
235
80
|
return txt(`Error: ${error.message}`);
|
|
81
|
+
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
82
|
+
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
83
|
+
// does for this same segment a moment later. Pushing the flat rules down instead
|
|
84
|
+
// makes the two disagree wherever SQL and JS compare differently -- notably
|
|
85
|
+
// custom_property against a non-string JSONB value (boolean/number), where
|
|
86
|
+
// `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
|
|
236
87
|
const ids = await getSegmentContactIds(data.rules);
|
|
237
88
|
return j({ ...data, estimated_contacts: ids.length });
|
|
238
89
|
});
|
|
@@ -383,7 +234,7 @@ export function registerSegmentTools(server) {
|
|
|
383
234
|
const resp = await anthropic.messages.create({
|
|
384
235
|
model: getAnthropicModelId(),
|
|
385
236
|
max_tokens: 512,
|
|
386
|
-
system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
|
|
237
|
+
system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
|
|
387
238
|
messages: [{ role: 'user', content: description }],
|
|
388
239
|
});
|
|
389
240
|
const tb = resp.content.find((b) => b.type === 'text');
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { getProjectId } from '../project.js';
|
|
4
|
+
import { logAudit } from '../audit.js';
|
|
5
|
+
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
6
|
+
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
7
|
+
const COLUMNS = 'id, site_id, name, html, is_default, created_at, updated_at';
|
|
8
|
+
export function registerSignatureTools(server) {
|
|
9
|
+
server.registerTool('list_signatures', {
|
|
10
|
+
title: 'List Signatures',
|
|
11
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
12
|
+
description: 'List project-scoped sending signature blocks, optionally filtered by site.',
|
|
13
|
+
inputSchema: { site_id: z.string().uuid().optional() },
|
|
14
|
+
}, async ({ site_id }) => {
|
|
15
|
+
let query = db()
|
|
16
|
+
.from('signature_blocks')
|
|
17
|
+
.select(COLUMNS)
|
|
18
|
+
.eq('project_id', getProjectId())
|
|
19
|
+
.order('is_default', { ascending: false })
|
|
20
|
+
.order('created_at', { ascending: true });
|
|
21
|
+
if (site_id)
|
|
22
|
+
query = query.eq('site_id', site_id);
|
|
23
|
+
const { data, error } = await query;
|
|
24
|
+
if (error)
|
|
25
|
+
return j({ error: error.message });
|
|
26
|
+
return j({ signatures: data ?? [], count: data?.length ?? 0 });
|
|
27
|
+
});
|
|
28
|
+
server.registerTool('create_signature', {
|
|
29
|
+
title: 'Create Signature',
|
|
30
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
31
|
+
description: 'Create a signature block for a site. Setting it as default clears the existing default for that site.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
site_id: z.string().uuid(),
|
|
34
|
+
name: z.string().trim().min(1),
|
|
35
|
+
html: z.string().trim().min(1),
|
|
36
|
+
is_default: z.boolean().optional(),
|
|
37
|
+
},
|
|
38
|
+
}, async ({ site_id, name, html, is_default }) => {
|
|
39
|
+
const projectId = getProjectId();
|
|
40
|
+
const { data: site, error: siteError } = await db()
|
|
41
|
+
.from('sites')
|
|
42
|
+
.select('id')
|
|
43
|
+
.eq('id', site_id)
|
|
44
|
+
.eq('project_id', projectId)
|
|
45
|
+
.maybeSingle();
|
|
46
|
+
if (siteError)
|
|
47
|
+
return j({ error: siteError.message });
|
|
48
|
+
if (!site)
|
|
49
|
+
return j({ error: 'Site not found', code: 'NOT_FOUND' });
|
|
50
|
+
if (is_default) {
|
|
51
|
+
const { error: clearError } = await db()
|
|
52
|
+
.from('signature_blocks')
|
|
53
|
+
.update({ is_default: false })
|
|
54
|
+
.eq('project_id', projectId)
|
|
55
|
+
.eq('site_id', site_id);
|
|
56
|
+
if (clearError)
|
|
57
|
+
return j({ error: clearError.message });
|
|
58
|
+
}
|
|
59
|
+
const { data, error } = await db()
|
|
60
|
+
.from('signature_blocks')
|
|
61
|
+
.insert({
|
|
62
|
+
project_id: projectId,
|
|
63
|
+
site_id,
|
|
64
|
+
name: name.trim(),
|
|
65
|
+
html: html.trim(),
|
|
66
|
+
is_default: is_default ?? false,
|
|
67
|
+
})
|
|
68
|
+
.select(COLUMNS)
|
|
69
|
+
.single();
|
|
70
|
+
if (error)
|
|
71
|
+
return j({ error: error.message });
|
|
72
|
+
await logAudit({
|
|
73
|
+
action: 'signature.created',
|
|
74
|
+
resourceType: 'signature',
|
|
75
|
+
resourceId: data.id,
|
|
76
|
+
details: { name: data.name, site_id: data.site_id, is_default: data.is_default, via: 'mcp' },
|
|
77
|
+
});
|
|
78
|
+
return j({ signature: data });
|
|
79
|
+
});
|
|
80
|
+
server.registerTool('update_signature', {
|
|
81
|
+
title: 'Update Signature',
|
|
82
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
83
|
+
description: 'Update a signature name, HTML, or default state. Setting it as default clears the other default for its site.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
id: z.string().uuid(),
|
|
86
|
+
name: z.string().trim().min(1).optional(),
|
|
87
|
+
html: z.string().trim().min(1).optional(),
|
|
88
|
+
is_default: z.boolean().optional(),
|
|
89
|
+
},
|
|
90
|
+
}, async ({ id, name, html, is_default }) => {
|
|
91
|
+
const projectId = getProjectId();
|
|
92
|
+
const { data: existing, error: existingError } = await db()
|
|
93
|
+
.from('signature_blocks')
|
|
94
|
+
.select('id, site_id')
|
|
95
|
+
.eq('id', id)
|
|
96
|
+
.eq('project_id', projectId)
|
|
97
|
+
.maybeSingle();
|
|
98
|
+
if (existingError)
|
|
99
|
+
return j({ error: existingError.message });
|
|
100
|
+
if (!existing)
|
|
101
|
+
return j({ error: 'Signature not found', code: 'NOT_FOUND' });
|
|
102
|
+
if (is_default) {
|
|
103
|
+
const { error: clearError } = await db()
|
|
104
|
+
.from('signature_blocks')
|
|
105
|
+
.update({ is_default: false })
|
|
106
|
+
.eq('project_id', projectId)
|
|
107
|
+
.eq('site_id', existing.site_id)
|
|
108
|
+
.neq('id', id);
|
|
109
|
+
if (clearError)
|
|
110
|
+
return j({ error: clearError.message });
|
|
111
|
+
}
|
|
112
|
+
const updates = { updated_at: new Date().toISOString() };
|
|
113
|
+
if (name !== undefined)
|
|
114
|
+
updates.name = name.trim();
|
|
115
|
+
if (html !== undefined)
|
|
116
|
+
updates.html = html.trim();
|
|
117
|
+
if (is_default !== undefined)
|
|
118
|
+
updates.is_default = is_default;
|
|
119
|
+
const { data, error } = await db()
|
|
120
|
+
.from('signature_blocks')
|
|
121
|
+
.update(updates)
|
|
122
|
+
.eq('id', id)
|
|
123
|
+
.eq('project_id', projectId)
|
|
124
|
+
.select(COLUMNS)
|
|
125
|
+
.single();
|
|
126
|
+
if (error)
|
|
127
|
+
return j({ error: error.message });
|
|
128
|
+
await logAudit({
|
|
129
|
+
action: 'signature.updated',
|
|
130
|
+
resourceType: 'signature',
|
|
131
|
+
resourceId: data.id,
|
|
132
|
+
details: { name: data.name, site_id: data.site_id, is_default: data.is_default, via: 'mcp' },
|
|
133
|
+
});
|
|
134
|
+
return j({ signature: data });
|
|
135
|
+
});
|
|
136
|
+
server.registerTool('delete_signature', {
|
|
137
|
+
title: 'Delete Signature',
|
|
138
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
139
|
+
description: 'Permanently delete a project-scoped sending signature block.',
|
|
140
|
+
inputSchema: { id: z.string().uuid() },
|
|
141
|
+
}, async ({ id }) => {
|
|
142
|
+
const projectId = getProjectId();
|
|
143
|
+
const { data: existing, error: existingError } = await db()
|
|
144
|
+
.from('signature_blocks')
|
|
145
|
+
.select('id, name, site_id')
|
|
146
|
+
.eq('id', id)
|
|
147
|
+
.eq('project_id', projectId)
|
|
148
|
+
.maybeSingle();
|
|
149
|
+
if (existingError)
|
|
150
|
+
return j({ error: existingError.message });
|
|
151
|
+
if (!existing)
|
|
152
|
+
return j({ error: 'Signature not found', code: 'NOT_FOUND' });
|
|
153
|
+
const { error } = await db()
|
|
154
|
+
.from('signature_blocks')
|
|
155
|
+
.delete()
|
|
156
|
+
.eq('id', id)
|
|
157
|
+
.eq('project_id', projectId);
|
|
158
|
+
if (error)
|
|
159
|
+
return j({ error: error.message });
|
|
160
|
+
await logAudit({
|
|
161
|
+
action: 'signature.deleted',
|
|
162
|
+
resourceType: 'signature',
|
|
163
|
+
resourceId: id,
|
|
164
|
+
details: { name: existing.name, site_id: existing.site_id, via: 'mcp' },
|
|
165
|
+
});
|
|
166
|
+
return j({ ok: true, deleted: id });
|
|
167
|
+
});
|
|
168
|
+
}
|
package/build/tools/sites.js
CHANGED
|
@@ -73,9 +73,10 @@ const createSenderSchema = {
|
|
|
73
73
|
sending_domain: z.string().min(1).optional().describe('Legacy domain text'),
|
|
74
74
|
from_email: z.string().email().optional().describe('Legacy full from address'),
|
|
75
75
|
from_name: z.string().min(1),
|
|
76
|
+
physical_address: z.string().min(1).describe('CAN-SPAM-required physical mailing address for this sender. Must be an explicit value - there is no default.'),
|
|
76
77
|
reply_to: z.string().email().optional(),
|
|
77
78
|
};
|
|
78
|
-
async function createSender({ name, slug, domain_id, local_part, sending_domain, from_email, from_name, reply_to, }) {
|
|
79
|
+
async function createSender({ name, slug, domain_id, local_part, sending_domain, from_email, from_name, physical_address, reply_to, }) {
|
|
79
80
|
const limit = await checkPlanLimit('sites');
|
|
80
81
|
if (!limit.allowed)
|
|
81
82
|
return txt(`Site limit reached (${limit.current}/${limit.limit}).`);
|
|
@@ -93,6 +94,7 @@ async function createSender({ name, slug, domain_id, local_part, sending_domain,
|
|
|
93
94
|
is_shared_domain: domain.is_shared_domain,
|
|
94
95
|
from_email: domain.from_email,
|
|
95
96
|
from_name,
|
|
97
|
+
physical_address,
|
|
96
98
|
reply_to: reply_to ?? null,
|
|
97
99
|
})
|
|
98
100
|
.select()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { OPERATOR_SKILLS, getOperatorSkill } from '@sendinel/domain';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
4
|
+
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
5
|
+
export function registerSkillTools(server) {
|
|
6
|
+
server.registerTool('list_operator_skills', {
|
|
7
|
+
title: 'List Operator Skills',
|
|
8
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
9
|
+
description: 'List the Sendinel operator skills catalog: named, guided workflows that sequence existing MCP tools in a known-good order. Use this to discover which workflow fits a task before calling individual tools directly; use get_operator_skill for the full steps.',
|
|
10
|
+
inputSchema: {},
|
|
11
|
+
}, async () => {
|
|
12
|
+
return j({
|
|
13
|
+
skills: OPERATOR_SKILLS.map(({ name, description, metadata }) => ({ name, description, metadata })),
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
server.registerTool('get_operator_skill', {
|
|
17
|
+
title: 'Get Operator Skill',
|
|
18
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
19
|
+
description: 'Get one Sendinel operator skill by name, including its full workflow body: the guided steps and which MCP tools to call, in order, to complete it.',
|
|
20
|
+
inputSchema: {
|
|
21
|
+
name: z.string().min(1).describe('Skill name from list_operator_skills, e.g. "sendinel-campaign-build-launch".'),
|
|
22
|
+
},
|
|
23
|
+
}, async ({ name }) => {
|
|
24
|
+
const skill = getOperatorSkill(name);
|
|
25
|
+
if (!skill)
|
|
26
|
+
return txt(`Unknown skill: ${name}. Call list_operator_skills to see available skills.`);
|
|
27
|
+
return j(skill);
|
|
28
|
+
});
|
|
29
|
+
}
|