@sendinel/mcp-server 1.0.139 → 1.0.141
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/db-error.d.ts +16 -0
- package/build/lib/db-error.js +17 -0
- package/build/lib/db-errors.d.ts +12 -0
- package/build/lib/db-errors.js +20 -0
- package/build/tools/advisor.js +23 -19
- package/build/tools/analytics.d.ts +2 -1
- package/build/tools/analytics.js +20 -8
- package/build/tools/approvals.js +3 -2
- package/build/tools/campaigns.js +92 -48
- package/build/tools/contacts.js +422 -310
- package/build/tools/content.js +12 -2
- package/build/tools/delivery-ops.js +5 -4
- package/build/tools/landing-pages.js +86 -9
- package/build/tools/segments.js +60 -46
- package/package.json +1 -1
package/build/tools/content.js
CHANGED
|
@@ -2,6 +2,9 @@ import { withApproval } from '../lib/action-approvals.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { adminDb, db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
|
+
import { stripActiveEmailContent, validateUnsubscribeFooterTemplate } from '@sendinel/domain';
|
|
6
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
7
|
+
import { logAudit } from '../audit.js';
|
|
5
8
|
import { assertHttpUrl } from '../lib/url-validation.js';
|
|
6
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
7
10
|
const BLOG_IMAGE_FORMATS = ['square_image', 'story_image', 'animated_gif', 'short_video', 'carousel'];
|
|
@@ -219,7 +222,7 @@ export function registerContentTools(server) {
|
|
|
219
222
|
idempotentHint: false,
|
|
220
223
|
openWorldHint: false,
|
|
221
224
|
},
|
|
222
|
-
description: 'Create a reusable content block.',
|
|
225
|
+
description: 'Create a reusable content block. Active HTML is rejected; footer blocks require an unsubscribe link or merge tag.',
|
|
223
226
|
inputSchema: {
|
|
224
227
|
name: z.string().min(1),
|
|
225
228
|
category: z.enum(['header', 'footer', 'cta', 'legal', 'general']).optional().default('general'),
|
|
@@ -227,12 +230,19 @@ export function registerContentTools(server) {
|
|
|
227
230
|
description: z.string().optional(),
|
|
228
231
|
},
|
|
229
232
|
}, async ({ name, category = 'general', html, description }) => {
|
|
233
|
+
const active = stripActiveEmailContent(html);
|
|
234
|
+
if (active.removed.length)
|
|
235
|
+
return toolError({ error: 'Active HTML is not allowed in content blocks', code: 'validation_failed' });
|
|
236
|
+
if (category === 'footer' && !/href\s*=\s*["']\s*\{\{\s*unsubscribe_url\s*\}\}/i.test(html) && !validateUnsubscribeFooterTemplate(html).ok) {
|
|
237
|
+
return toolError({ error: 'Footer blocks require {{unsubscribe_url}} in a link or <unsubscribe>link text</unsubscribe>', code: 'validation_failed' });
|
|
238
|
+
}
|
|
230
239
|
const { data, error } = await db().from('content_blocks')
|
|
231
240
|
.insert({ project_id: getProjectId(), name, category, html, description: description ?? null })
|
|
232
241
|
.select('id, name, category, html, description, created_at')
|
|
233
242
|
.single();
|
|
234
243
|
if (error)
|
|
235
|
-
return
|
|
244
|
+
return toolError(dbToolError(error));
|
|
245
|
+
await logAudit({ action: 'content_block.created', resourceType: 'content_block', resourceId: data.id });
|
|
236
246
|
return j({ block: data });
|
|
237
247
|
});
|
|
238
248
|
server.registerTool('delete_content_block', {
|
|
@@ -6,6 +6,7 @@ import { adminDb, db } from '../db.js';
|
|
|
6
6
|
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
9
10
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
10
11
|
function asRecord(value) {
|
|
11
12
|
return value && typeof value === 'object' ? value : {};
|
|
@@ -154,18 +155,18 @@ export function registerDeliveryOpsTools(server) {
|
|
|
154
155
|
.eq('id', domain_id)
|
|
155
156
|
.maybeSingle();
|
|
156
157
|
if (error)
|
|
157
|
-
return
|
|
158
|
+
return toolError(dbToolError(error, 'Unable to load domain'));
|
|
158
159
|
if (!data)
|
|
159
|
-
return
|
|
160
|
+
return toolError({ error: 'Domain not found', code: 'NOT_FOUND' });
|
|
160
161
|
const { data: health, error: healthError } = await db().from('domain_health')
|
|
161
162
|
.select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
|
|
162
|
-
.eq('project_id', getProjectId())
|
|
163
|
+
.eq('sites.project_id', getProjectId())
|
|
163
164
|
.eq('sites.domain_id', domain_id)
|
|
164
165
|
.order('check_date', { ascending: false })
|
|
165
166
|
.limit(1)
|
|
166
167
|
.maybeSingle();
|
|
167
168
|
if (healthError)
|
|
168
|
-
return
|
|
169
|
+
return toolError(dbToolError(healthError, 'Unable to load domain health'));
|
|
169
170
|
const spf = health?.spf_valid ?? null;
|
|
170
171
|
const dkim = health?.dkim_valid ?? null;
|
|
171
172
|
const dmarc = health?.dmarc_valid ?? null;
|
|
@@ -3,10 +3,55 @@ import { z } from 'zod';
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { logAudit } from '../audit.js';
|
|
6
|
+
import { stripActiveEmailContent } from '@sendinel/domain';
|
|
7
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
6
8
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
7
9
|
const SLUG_RE = /^[a-z0-9][a-z0-9\-]{0,62}$/;
|
|
8
10
|
const VALID_STATUSES = ['draft', 'published', 'archived'];
|
|
9
11
|
const LIST_COLUMNS = 'id, title, slug, site_id, status, form_id, blocks, meta_title, meta_description, conversion_event, views, conversions, background_color, accent_color, custom_css, created_at, updated_at, published_at';
|
|
12
|
+
// Types rendered by LandingPageRenderer. Reuse the domain active-content pass.
|
|
13
|
+
const BLOCK_TYPES = new Set(['hero', 'text', 'image', 'form', 'features', 'testimonial', 'cta', 'spacer']);
|
|
14
|
+
const COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
15
|
+
function validatePageContent(input) {
|
|
16
|
+
for (const key of ['background_color', 'accent_color']) {
|
|
17
|
+
if (input[key] !== undefined && (typeof input[key] !== 'string' || !COLOR_RE.test(input[key]))) {
|
|
18
|
+
return `${key} must be a three- or six-digit hex color`;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (input.custom_css != null && (typeof input.custom_css !== 'string' || /<\s*\/\s*style|<\s*script|javascript\s*:|expression\s*\(|@import/i.test(input.custom_css))) {
|
|
22
|
+
return 'custom_css contains unsafe content';
|
|
23
|
+
}
|
|
24
|
+
if (input.blocks !== undefined && (!Array.isArray(input.blocks) || input.blocks.some(block => !block || typeof block !== 'object' || !BLOCK_TYPES.has(block.type)))) {
|
|
25
|
+
return `Each block must have a recognized type: ${[...BLOCK_TYPES].join(', ')}`;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function sanitizeBlockValue(value) {
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
// Also inspect bare URL fields (ctaUrl/url/src), using the same URL decoder
|
|
32
|
+
// as HTML attributes rather than a second scheme blacklist.
|
|
33
|
+
const url = stripActiveEmailContent(`<a href="${value.replace(/"/g, '"')}">`);
|
|
34
|
+
if (url.removed.some(finding => finding.endsWith(' URL')))
|
|
35
|
+
return '';
|
|
36
|
+
return stripActiveEmailContent(value).html;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.map(sanitizeBlockValue);
|
|
40
|
+
if (value && typeof value === 'object')
|
|
41
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitizeBlockValue(item)]));
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
async function validatePageForm(formId) {
|
|
45
|
+
if (!formId)
|
|
46
|
+
return null;
|
|
47
|
+
const { data, error } = await db().from('signup_forms').select('id')
|
|
48
|
+
.eq('project_id', getProjectId()).eq('id', formId).maybeSingle();
|
|
49
|
+
if (error)
|
|
50
|
+
return dbToolError(error, 'Unable to load form');
|
|
51
|
+
if (!data)
|
|
52
|
+
return { error: 'form_id not found in this project', code: 'not_found' };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
10
55
|
function slugify(text) {
|
|
11
56
|
return text
|
|
12
57
|
.toLowerCase()
|
|
@@ -53,7 +98,7 @@ export function registerLandingPageTools(server) {
|
|
|
53
98
|
server.registerTool('create_landing_page', {
|
|
54
99
|
title: 'Create Landing Page',
|
|
55
100
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
56
|
-
description: 'Create a draft landing page.
|
|
101
|
+
description: 'Create a draft landing page. Blocks require a renderer type and have active content stripped; colors must be hex and custom_css must be safe. Publish with update_landing_page.',
|
|
57
102
|
inputSchema: {
|
|
58
103
|
title: z.string().trim().min(1).max(200),
|
|
59
104
|
slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
|
|
@@ -72,7 +117,13 @@ export function registerLandingPageTools(server) {
|
|
|
72
117
|
const normalizedTitle = title.trim();
|
|
73
118
|
const normalizedSlug = (slug ?? slugify(normalizedTitle)).trim();
|
|
74
119
|
if (!SLUG_RE.test(normalizedSlug))
|
|
75
|
-
return
|
|
120
|
+
return toolError({ error: 'Invalid slug (lowercase alphanumeric + hyphens, max 63 chars)', code: 'validation_failed' });
|
|
121
|
+
const contentError = validatePageContent({ blocks, background_color, accent_color, custom_css });
|
|
122
|
+
if (contentError)
|
|
123
|
+
return toolError({ error: contentError, code: 'validation_failed' });
|
|
124
|
+
const formError = await validatePageForm(form_id);
|
|
125
|
+
if (formError)
|
|
126
|
+
return toolError(formError);
|
|
76
127
|
const insert = {
|
|
77
128
|
project_id: projectId,
|
|
78
129
|
title: normalizedTitle,
|
|
@@ -83,7 +134,7 @@ export function registerLandingPageTools(server) {
|
|
|
83
134
|
if (form_id)
|
|
84
135
|
insert.form_id = form_id;
|
|
85
136
|
if (blocks)
|
|
86
|
-
insert.blocks = blocks;
|
|
137
|
+
insert.blocks = sanitizeBlockValue(blocks);
|
|
87
138
|
if (meta_title)
|
|
88
139
|
insert.meta_title = meta_title;
|
|
89
140
|
if (meta_description)
|
|
@@ -103,8 +154,10 @@ export function registerLandingPageTools(server) {
|
|
|
103
154
|
.single();
|
|
104
155
|
if (error) {
|
|
105
156
|
if (error.code === '23505')
|
|
106
|
-
return
|
|
107
|
-
|
|
157
|
+
return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
|
|
158
|
+
if (error.code === '23503')
|
|
159
|
+
return toolError({ error: 'Referenced form or site not found', code: 'not_found' });
|
|
160
|
+
return toolError(dbToolError(error));
|
|
108
161
|
}
|
|
109
162
|
await logAudit({
|
|
110
163
|
action: 'landing_page.created',
|
|
@@ -136,6 +189,24 @@ export function registerLandingPageTools(server) {
|
|
|
136
189
|
},
|
|
137
190
|
}, withApproval('update_landing_page', async ({ page_id, title, slug, status, site_id, form_id, blocks, meta_title, meta_description, conversion_event, background_color, accent_color, custom_css }) => {
|
|
138
191
|
const projectId = getProjectId();
|
|
192
|
+
// Validate the effective document on publish, including unsafe older drafts.
|
|
193
|
+
let content = { blocks, background_color, accent_color, custom_css };
|
|
194
|
+
if (status === 'published') {
|
|
195
|
+
const { data: existing, error: readError } = await db().from('landing_pages')
|
|
196
|
+
.select('blocks, background_color, accent_color, custom_css, form_id')
|
|
197
|
+
.eq('id', page_id).eq('project_id', projectId).maybeSingle();
|
|
198
|
+
if (readError)
|
|
199
|
+
return toolError(dbToolError(readError));
|
|
200
|
+
if (!existing)
|
|
201
|
+
return toolError({ error: 'Landing page not found', code: 'not_found' });
|
|
202
|
+
content = { ...existing, ...Object.fromEntries(Object.entries(content).filter(([, value]) => value !== undefined)) };
|
|
203
|
+
}
|
|
204
|
+
const contentError = validatePageContent(content);
|
|
205
|
+
if (contentError)
|
|
206
|
+
return toolError({ error: contentError, code: 'validation_failed' });
|
|
207
|
+
const formError = await validatePageForm(form_id === undefined ? content.form_id : form_id);
|
|
208
|
+
if (formError)
|
|
209
|
+
return toolError(formError);
|
|
139
210
|
const updates = { updated_at: new Date().toISOString() };
|
|
140
211
|
if (title !== undefined)
|
|
141
212
|
updates.title = title.trim();
|
|
@@ -151,7 +222,7 @@ export function registerLandingPageTools(server) {
|
|
|
151
222
|
if (form_id !== undefined)
|
|
152
223
|
updates.form_id = form_id;
|
|
153
224
|
if (blocks !== undefined)
|
|
154
|
-
updates.blocks = blocks;
|
|
225
|
+
updates.blocks = sanitizeBlockValue(blocks);
|
|
155
226
|
if (meta_title !== undefined)
|
|
156
227
|
updates.meta_title = meta_title;
|
|
157
228
|
if (meta_description !== undefined)
|
|
@@ -164,18 +235,24 @@ export function registerLandingPageTools(server) {
|
|
|
164
235
|
updates.accent_color = accent_color;
|
|
165
236
|
if (custom_css !== undefined)
|
|
166
237
|
updates.custom_css = custom_css;
|
|
238
|
+
if (status === 'published' && content.blocks !== undefined)
|
|
239
|
+
updates.blocks = sanitizeBlockValue(content.blocks);
|
|
167
240
|
const { data, error } = await db()
|
|
168
241
|
.from('landing_pages')
|
|
169
242
|
.update(updates)
|
|
170
243
|
.eq('id', page_id)
|
|
171
244
|
.eq('project_id', projectId)
|
|
172
245
|
.select()
|
|
173
|
-
.
|
|
246
|
+
.maybeSingle();
|
|
174
247
|
if (error) {
|
|
175
248
|
if (error.code === '23505')
|
|
176
|
-
return
|
|
177
|
-
|
|
249
|
+
return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
|
|
250
|
+
if (error.code === '23503')
|
|
251
|
+
return toolError({ error: 'Referenced form or site not found', code: 'not_found' });
|
|
252
|
+
return toolError(dbToolError(error));
|
|
178
253
|
}
|
|
254
|
+
if (!data)
|
|
255
|
+
return toolError({ error: 'Landing page not found', code: 'not_found' });
|
|
179
256
|
await logAudit({
|
|
180
257
|
action: 'landing_page.updated',
|
|
181
258
|
resourceType: 'landing_page',
|
package/build/tools/segments.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { dbErrorResult } from '../lib/db-errors.js';
|
|
1
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import Anthropic from '@anthropic-ai/sdk';
|
|
4
5
|
import { db } from '../db.js';
|
|
5
6
|
import { getProjectId } from '../project.js';
|
|
7
|
+
import { dbToolError, toolError } from '../lib/db-error.js';
|
|
6
8
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
7
9
|
import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/domain';
|
|
8
10
|
import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
|
|
@@ -80,31 +82,38 @@ export function registerSegmentTools(server) {
|
|
|
80
82
|
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>, email_sends (eq only, value: {min_count: positive integer, within_days: positive number}; at most one per segment). tag may be a plain string (one tag) or {values: string[], mode: "any"|"all"} to match several tags at once.'),
|
|
81
83
|
},
|
|
82
84
|
}, async ({ name, description, rules }) => {
|
|
83
|
-
let storedRules;
|
|
84
85
|
try {
|
|
85
|
-
storedRules
|
|
86
|
+
let storedRules;
|
|
87
|
+
try {
|
|
88
|
+
storedRules = prepareSegmentRulesForStorage(rules);
|
|
89
|
+
}
|
|
90
|
+
catch (rulesError) {
|
|
91
|
+
const invalid = invalidRules(rulesError);
|
|
92
|
+
if (invalid)
|
|
93
|
+
return toolError({ error: rulesError instanceof Error ? rulesError.message : 'Invalid segment rules', code: 'validation_failed' });
|
|
94
|
+
throw rulesError;
|
|
95
|
+
}
|
|
96
|
+
const { data, error } = await db()
|
|
97
|
+
.from('segments')
|
|
98
|
+
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
99
|
+
.select('id, name, description, rules, created_at')
|
|
100
|
+
.single();
|
|
101
|
+
if (error?.code === '23505')
|
|
102
|
+
return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
|
|
103
|
+
if (error)
|
|
104
|
+
return toolError(dbToolError(error));
|
|
105
|
+
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
106
|
+
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
107
|
+
// does for this same segment a moment later. Pushing the flat rules down instead
|
|
108
|
+
// makes the two disagree wherever SQL and JS compare differently -- notably
|
|
109
|
+
// custom_property against a non-string JSONB value (boolean/number), where
|
|
110
|
+
// `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
|
|
111
|
+
const ids = await getSegmentContactIds(data.rules);
|
|
112
|
+
return j({ ...data, estimated_contacts: ids.length });
|
|
86
113
|
}
|
|
87
|
-
catch (
|
|
88
|
-
|
|
89
|
-
if (invalid)
|
|
90
|
-
return invalid;
|
|
91
|
-
throw rulesError;
|
|
114
|
+
catch (error) {
|
|
115
|
+
return dbErrorResult(error);
|
|
92
116
|
}
|
|
93
|
-
const { data, error } = await db()
|
|
94
|
-
.from('segments')
|
|
95
|
-
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
96
|
-
.select('id, name, description, rules, created_at')
|
|
97
|
-
.single();
|
|
98
|
-
if (error)
|
|
99
|
-
return txt(`Error: ${error.message}`);
|
|
100
|
-
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
101
|
-
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
102
|
-
// does for this same segment a moment later. Pushing the flat rules down instead
|
|
103
|
-
// makes the two disagree wherever SQL and JS compare differently -- notably
|
|
104
|
-
// custom_property against a non-string JSONB value (boolean/number), where
|
|
105
|
-
// `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
|
|
106
|
-
const ids = await getSegmentContactIds(data.rules);
|
|
107
|
-
return j({ ...data, estimated_contacts: ids.length });
|
|
108
117
|
});
|
|
109
118
|
server.registerTool('list_segments', {
|
|
110
119
|
title: 'List Segments',
|
|
@@ -157,32 +166,37 @@ export function registerSegmentTools(server) {
|
|
|
157
166
|
rules: z.record(z.string(), z.unknown()).optional(),
|
|
158
167
|
},
|
|
159
168
|
}, async ({ segment_id, name, description, rules }) => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
try {
|
|
170
|
+
const updates = { updated_at: new Date().toISOString() };
|
|
171
|
+
if (name)
|
|
172
|
+
updates.name = name;
|
|
173
|
+
if (description !== undefined)
|
|
174
|
+
updates.description = description;
|
|
175
|
+
if (rules) {
|
|
176
|
+
try {
|
|
177
|
+
updates.rules = prepareSegmentRulesForStorage(rules);
|
|
178
|
+
}
|
|
179
|
+
catch (rulesError) {
|
|
180
|
+
const invalid = invalidRules(rulesError);
|
|
181
|
+
if (invalid)
|
|
182
|
+
return invalid;
|
|
183
|
+
throw rulesError;
|
|
184
|
+
}
|
|
174
185
|
}
|
|
186
|
+
const { data, error } = await db()
|
|
187
|
+
.from('segments')
|
|
188
|
+
.update(updates)
|
|
189
|
+
.eq('id', segment_id)
|
|
190
|
+
.eq('project_id', getProjectId())
|
|
191
|
+
.select('id, name, description, rules, updated_at')
|
|
192
|
+
.single();
|
|
193
|
+
if (error)
|
|
194
|
+
return dbErrorResult(error);
|
|
195
|
+
return j(data);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
return dbErrorResult(error);
|
|
175
199
|
}
|
|
176
|
-
const { data, error } = await db()
|
|
177
|
-
.from('segments')
|
|
178
|
-
.update(updates)
|
|
179
|
-
.eq('id', segment_id)
|
|
180
|
-
.eq('project_id', getProjectId())
|
|
181
|
-
.select('id, name, description, rules, updated_at')
|
|
182
|
-
.single();
|
|
183
|
-
if (error)
|
|
184
|
-
return txt(`Error: ${error.message}`);
|
|
185
|
-
return j(data);
|
|
186
200
|
});
|
|
187
201
|
server.registerTool('delete_segment', {
|
|
188
202
|
title: 'Delete Segment',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.141",
|
|
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",
|