@sendinel/mcp-server 1.0.9 → 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.
Files changed (57) hide show
  1. package/README.md +35 -22
  2. package/build/anthropic-model.d.ts +30 -0
  3. package/build/anthropic-model.js +26 -1
  4. package/build/db.d.ts +17 -0
  5. package/build/db.js +15 -1
  6. package/build/index.js +6 -0
  7. package/build/lib/action-approvals.js +28 -0
  8. package/build/lib/brand-asset-persist.d.ts +1 -0
  9. package/build/lib/brand-asset-persist.js +20 -1
  10. package/build/lib/composer-blocks.d.ts +25 -25
  11. package/build/lib/composer-blocks.js +25 -5
  12. package/build/lib/connector-table-discovery.d.ts +12 -0
  13. package/build/lib/connector-table-discovery.js +117 -0
  14. package/build/lib/custom-properties-diff.d.ts +16 -0
  15. package/build/lib/custom-properties-diff.js +22 -0
  16. package/build/lib/schedule.d.ts +7 -2
  17. package/build/lib/schedule.js +8 -0
  18. package/build/lib/v1-client.d.ts +3 -0
  19. package/build/lib/v1-client.js +45 -0
  20. package/build/lib/voice.d.ts +7 -30
  21. package/build/lib/voice.js +6 -138
  22. package/build/prompts.d.ts +6 -0
  23. package/build/prompts.js +18 -0
  24. package/build/scoped-token.d.ts +1 -1
  25. package/build/server.d.ts +1 -0
  26. package/build/server.js +65 -6
  27. package/build/tools/analytics.js +97 -0
  28. package/build/tools/brand-kit.js +29 -30
  29. package/build/tools/campaigns.js +326 -52
  30. package/build/tools/cloud-import.d.ts +2 -0
  31. package/build/tools/cloud-import.js +76 -0
  32. package/build/tools/compound.d.ts +11 -0
  33. package/build/tools/compound.js +37 -7
  34. package/build/tools/connector.d.ts +2 -0
  35. package/build/tools/connector.js +280 -0
  36. package/build/tools/contacts.js +744 -16
  37. package/build/tools/content.js +228 -0
  38. package/build/tools/data.js +441 -0
  39. package/build/tools/delivery-ops.js +508 -1
  40. package/build/tools/forms.js +7 -6
  41. package/build/tools/gateway.d.ts +6 -0
  42. package/build/tools/gateway.js +92 -0
  43. package/build/tools/gdpr.js +185 -0
  44. package/build/tools/landing-pages.d.ts +2 -0
  45. package/build/tools/landing-pages.js +202 -0
  46. package/build/tools/lists.d.ts +2 -0
  47. package/build/tools/lists.js +428 -0
  48. package/build/tools/newsletters.js +1 -8
  49. package/build/tools/org.js +549 -33
  50. package/build/tools/segments.js +35 -184
  51. package/build/tools/signatures.d.ts +2 -0
  52. package/build/tools/signatures.js +168 -0
  53. package/build/tools/sites.js +3 -1
  54. package/build/tools/skills.d.ts +2 -0
  55. package/build/tools/skills.js +29 -0
  56. package/build/tools/templates.js +133 -7
  57. package/package.json +14 -13
@@ -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
- function isSegmentRuleGroup(value) {
7
- const node = value;
8
- return !!node && (node.op === 'AND' || node.op === 'OR') && Array.isArray(node.conditions);
9
- }
10
- function normalizeSegmentRulesToTree(rules) {
11
- const source = (rules ?? {});
12
- if (isSegmentRuleGroup(source))
13
- return source;
14
- const conditions = [];
15
- if (source.lifecycle_stage)
16
- conditions.push({ field: 'lifecycle_stage', operator: 'eq', value: source.lifecycle_stage });
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
- if (field.startsWith('payment.')) {
72
- return field.slice('payment.'.length).split('.').reduce((acc, part) => acc == null ? undefined : acc[part], contact.custom_properties?.payment);
73
- }
74
- return contact[field];
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 compareValues(getComparableValue(contact, node.field), node.operator ?? 'eq', node.value);
24
+ return fields;
111
25
  }
112
26
  async function getSegmentContactIds(rules) {
113
27
  const projectId = getProjectId();
114
28
  const tree = normalizeSegmentRulesToTree(rules);
115
- let q = db()
116
- .from('subscribers')
117
- .select('id, email, lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, tags, custom_properties')
118
- .eq('project_id', projectId)
119
- .eq('global_unsubscribed', false);
120
- if (!isSegmentRuleGroup(rules)) {
121
- if (rules.lifecycle_stage)
122
- q = q.eq('lifecycle_stage', rules.lifecycle_stage);
123
- q = applyPaymentRules(q, rules.payment);
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
- let contacts = data.filter((contact) => evaluateNode(contact, tree));
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,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSignatureTools(server: McpServer): void;
@@ -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
+ }
@@ -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,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerSkillTools(server: McpServer): void;
@@ -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
+ }