@sendinel/mcp-server 1.0.138 → 1.0.140
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 +2 -2
- package/build/lib/db-errors.d.ts +12 -0
- package/build/lib/db-errors.js +20 -0
- package/build/lib/url-validation.d.ts +3 -0
- package/build/lib/url-validation.js +15 -0
- package/build/tools/approvals.js +3 -2
- package/build/tools/campaigns.d.ts +2 -1
- package/build/tools/campaigns.js +122 -44
- package/build/tools/contacts.js +375 -267
- package/build/tools/content.js +9 -0
- package/build/tools/delivery-ops.js +6 -2
- package/build/tools/forms.js +30 -3
- package/build/tools/newsletters.js +41 -9
- package/build/tools/segments.js +57 -46
- package/build/tools/short-links.js +20 -14
- package/build/tools/surveys.js +41 -4
- package/build/tools/tool-search.js +1 -0
- package/build/tools/webhooks.js +2 -2
- package/package.json +1 -1
package/build/tools/content.js
CHANGED
|
@@ -2,6 +2,7 @@ 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 { assertHttpUrl } from '../lib/url-validation.js';
|
|
5
6
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
6
7
|
const BLOG_IMAGE_FORMATS = ['square_image', 'story_image', 'animated_gif', 'short_video', 'carousel'];
|
|
7
8
|
const BLOG_IMAGE_GOALS = ['generate_leads', 'educate', 'launch_offer', 'build_trust'];
|
|
@@ -106,6 +107,12 @@ export function registerContentTools(server) {
|
|
|
106
107
|
gate_body: z.string().max(1000).optional(),
|
|
107
108
|
},
|
|
108
109
|
}, async ({ title, slug, asset_url, asset_type = 'page', site_id, source_attribution, gate_headline, gate_body }) => {
|
|
110
|
+
try {
|
|
111
|
+
assertHttpUrl(asset_url);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return j({ error: 'asset_url must use http or https', code: 'validation_failed' });
|
|
115
|
+
}
|
|
109
116
|
const { data, error } = await db().from('asset_links')
|
|
110
117
|
.insert({
|
|
111
118
|
project_id: getProjectId(),
|
|
@@ -120,6 +127,8 @@ export function registerContentTools(server) {
|
|
|
120
127
|
})
|
|
121
128
|
.select('id, title, slug, asset_type, status, source_attribution, gate_headline, gate_body, total_views, total_captures, created_at')
|
|
122
129
|
.single();
|
|
130
|
+
if (error?.code === '23505')
|
|
131
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
123
132
|
if (error)
|
|
124
133
|
return j({ error: error.message, code: error.code });
|
|
125
134
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai';
|
|
@@ -97,8 +97,12 @@ export function registerDeliveryOpsTools(server) {
|
|
|
97
97
|
reason: z.string().optional().describe('Optional human-readable reason'),
|
|
98
98
|
},
|
|
99
99
|
}, async ({ date, reason }) => {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
const parsed = new Date(`${date}T00:00:00Z`);
|
|
101
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
|
|
102
|
+
return j({ error: 'date must be a valid YYYY-MM-DD date', code: 'validation_failed' });
|
|
103
|
+
}
|
|
104
|
+
if (date < new Date().toISOString().slice(0, 10)) {
|
|
105
|
+
return j({ error: 'date must be today or later (UTC)', code: 'validation_failed' });
|
|
102
106
|
}
|
|
103
107
|
const { data, error } = await db()
|
|
104
108
|
.from('blackout_dates')
|
package/build/tools/forms.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
|
+
import { assertHttpUrl } from '../lib/url-validation.js';
|
|
4
5
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
5
6
|
export function registerFormTools(server) {
|
|
6
7
|
server.registerTool('list_forms', {
|
|
@@ -14,7 +15,7 @@ export function registerFormTools(server) {
|
|
|
14
15
|
description: 'List forms with destination, field, and submission counts.',
|
|
15
16
|
inputSchema: { site_id: z.string().uuid().optional() },
|
|
16
17
|
}, async ({ site_id }) => {
|
|
17
|
-
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, form_type, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
18
|
+
let query = db().from('signup_forms').select('id, name, slug, fields, status, submissions, site_id, form_type, settings, created_at').eq('project_id', getProjectId()).order('created_at', { ascending: false });
|
|
18
19
|
if (site_id)
|
|
19
20
|
query = query.eq('site_id', site_id);
|
|
20
21
|
const { data, error } = await query;
|
|
@@ -68,13 +69,39 @@ export function registerFormTools(server) {
|
|
|
68
69
|
form_type: z.enum(['signup', 'management']).optional(),
|
|
69
70
|
},
|
|
70
71
|
}, async ({ site_id, name, fields, redirect_url, double_opt_in, form_type }) => {
|
|
72
|
+
if (!fields.length)
|
|
73
|
+
return j({ error: 'fields must contain at least one field', code: 'validation_failed' });
|
|
74
|
+
const names = new Set();
|
|
75
|
+
for (const field of fields) {
|
|
76
|
+
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
77
|
+
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
78
|
+
return j({ error: 'Each field needs a name (lowercase snake_case) and a supported type', code: 'validation_failed' });
|
|
79
|
+
}
|
|
80
|
+
if (names.has(field.name))
|
|
81
|
+
return j({ error: `Duplicate field name: ${field.name}`, code: 'validation_failed' });
|
|
82
|
+
names.add(field.name);
|
|
83
|
+
}
|
|
84
|
+
// The public handler always reads fields.email to identify the subscriber.
|
|
85
|
+
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
86
|
+
return j({ error: 'fields must include an email field named email', code: 'validation_failed' });
|
|
87
|
+
}
|
|
88
|
+
if (redirect_url !== undefined) {
|
|
89
|
+
try {
|
|
90
|
+
assertHttpUrl(redirect_url);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return j({ error: 'redirect_url must use http or https', code: 'validation_failed' });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
71
96
|
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
|
72
97
|
const { data, error } = await db().from('signup_forms')
|
|
73
98
|
.insert({ project_id: getProjectId(), site_id, name, slug, fields, settings: { redirect_url: redirect_url ?? null, double_opt_in: double_opt_in ?? false }, form_type: form_type ?? 'signup', status: 'active' })
|
|
74
|
-
.select('id, name, slug, fields, status, form_type')
|
|
99
|
+
.select('id, name, slug, fields, status, form_type, settings')
|
|
75
100
|
.single();
|
|
101
|
+
if (error?.code === '23505')
|
|
102
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
76
103
|
if (error)
|
|
77
|
-
return j({ error: error.message });
|
|
104
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
78
105
|
return j({ form: data });
|
|
79
106
|
});
|
|
80
107
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { withApproval } from '../lib/action-approvals.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { stripActiveEmailContent } from '@sendinel/domain';
|
|
3
4
|
import { db } from '../db.js';
|
|
4
5
|
import { getProjectId } from '../project.js';
|
|
5
6
|
import { parseFutureDate } from '../lib/schedule.js';
|
|
@@ -23,6 +24,20 @@ async function getProjectOrgId(projectId) {
|
|
|
23
24
|
const { data } = await db().from('projects').select('org_id').eq('id', projectId).maybeSingle();
|
|
24
25
|
return data?.org_id ?? null;
|
|
25
26
|
}
|
|
27
|
+
async function validateNewsletterSender(projectId, email) {
|
|
28
|
+
const domain = email.slice(email.lastIndexOf('@') + 1).toLowerCase();
|
|
29
|
+
const { data: sendingDomain, error } = await db().from('domains').select('id')
|
|
30
|
+
.eq('project_id', projectId).eq('name', domain).eq('status', 'verified').maybeSingle();
|
|
31
|
+
if (error)
|
|
32
|
+
return { error: 'Sending domain verification unavailable', code: 'INTERNAL_ERROR' };
|
|
33
|
+
if (!sendingDomain)
|
|
34
|
+
return { error: 'sender_email must use a verified sending domain of a project site', code: 'validation_failed' };
|
|
35
|
+
const { data: site, error: siteError } = await db().from('sites').select('id')
|
|
36
|
+
.eq('project_id', projectId).eq('domain_id', sendingDomain.id).limit(1).maybeSingle();
|
|
37
|
+
if (siteError)
|
|
38
|
+
return { error: 'Sending site verification unavailable', code: 'INTERNAL_ERROR' };
|
|
39
|
+
return site ? null : { error: 'sender_email must use a verified sending domain of a project site', code: 'validation_failed' };
|
|
40
|
+
}
|
|
26
41
|
async function loadSendableIssue(newsletterId, issueId, projectId) {
|
|
27
42
|
const [{ data: newsletter, error: newsletterError }, { data: issue, error: issueError }] = await Promise.all([
|
|
28
43
|
db().from('newsletters')
|
|
@@ -195,15 +210,15 @@ export function registerNewsletterTools(server) {
|
|
|
195
210
|
idempotentHint: false,
|
|
196
211
|
openWorldHint: false,
|
|
197
212
|
},
|
|
198
|
-
description: 'Create a newsletter publication for the current project.',
|
|
213
|
+
description: 'Create a newsletter publication for the current project. Explicit sender_email must use a verified domain linked to a project site.',
|
|
199
214
|
inputSchema: {
|
|
200
215
|
name: z.string().min(1).max(200),
|
|
201
216
|
slug: z.string().optional(),
|
|
202
217
|
description: z.string().optional(),
|
|
203
218
|
site_id: z.string().uuid().nullable().optional(),
|
|
204
219
|
sender_name: z.string().optional(),
|
|
205
|
-
sender_email: z.string().optional(),
|
|
206
|
-
reply_to: z.string().optional(),
|
|
220
|
+
sender_email: z.string().email().optional(),
|
|
221
|
+
reply_to: z.string().email().optional(),
|
|
207
222
|
cadence: z.string().optional(),
|
|
208
223
|
branding: z.record(z.string(), z.unknown()).optional(),
|
|
209
224
|
public_signup_enabled: z.boolean().default(true).optional(),
|
|
@@ -212,6 +227,11 @@ export function registerNewsletterTools(server) {
|
|
|
212
227
|
},
|
|
213
228
|
}, async (args) => {
|
|
214
229
|
const projectId = getProjectId();
|
|
230
|
+
if (args.sender_email) {
|
|
231
|
+
const senderError = await validateNewsletterSender(projectId, args.sender_email);
|
|
232
|
+
if (senderError)
|
|
233
|
+
return j(senderError);
|
|
234
|
+
}
|
|
215
235
|
const orgId = await getProjectOrgId(projectId);
|
|
216
236
|
if (!orgId)
|
|
217
237
|
return j({ error: 'Organization not found for project', code: 'NOT_FOUND' });
|
|
@@ -233,8 +253,10 @@ export function registerNewsletterTools(server) {
|
|
|
233
253
|
source: 'mcp',
|
|
234
254
|
source_payload: args,
|
|
235
255
|
}).select(NEWSLETTER_COLUMNS).single();
|
|
256
|
+
if (error?.code === '23505')
|
|
257
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
236
258
|
if (error)
|
|
237
|
-
return j({ error: error.message, code:
|
|
259
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
238
260
|
return j(data);
|
|
239
261
|
});
|
|
240
262
|
server.registerTool('update_newsletter', {
|
|
@@ -245,7 +267,7 @@ export function registerNewsletterTools(server) {
|
|
|
245
267
|
idempotentHint: true,
|
|
246
268
|
openWorldHint: false,
|
|
247
269
|
},
|
|
248
|
-
description: 'Update newsletter publication settings.',
|
|
270
|
+
description: 'Update newsletter publication settings. Explicit sender_email must use a verified domain linked to a project site.',
|
|
249
271
|
inputSchema: {
|
|
250
272
|
newsletter_id: z.string().uuid(),
|
|
251
273
|
name: z.string().max(200).optional(),
|
|
@@ -253,8 +275,8 @@ export function registerNewsletterTools(server) {
|
|
|
253
275
|
description: z.string().nullable().optional(),
|
|
254
276
|
site_id: z.string().uuid().nullable().optional(),
|
|
255
277
|
sender_name: z.string().nullable().optional(),
|
|
256
|
-
sender_email: z.string().nullable().optional(),
|
|
257
|
-
reply_to: z.string().nullable().optional(),
|
|
278
|
+
sender_email: z.string().email().nullable().optional(),
|
|
279
|
+
reply_to: z.string().email().nullable().optional(),
|
|
258
280
|
cadence: z.string().nullable().optional(),
|
|
259
281
|
branding: z.record(z.string(), z.unknown()).optional(),
|
|
260
282
|
public_signup_enabled: z.boolean().optional(),
|
|
@@ -262,6 +284,11 @@ export function registerNewsletterTools(server) {
|
|
|
262
284
|
status: z.enum(['active', 'paused', 'archived']).optional(),
|
|
263
285
|
},
|
|
264
286
|
}, async ({ newsletter_id, ...fields }) => {
|
|
287
|
+
if (fields.sender_email) {
|
|
288
|
+
const senderError = await validateNewsletterSender(getProjectId(), fields.sender_email);
|
|
289
|
+
if (senderError)
|
|
290
|
+
return j(senderError);
|
|
291
|
+
}
|
|
265
292
|
const updates = Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
266
293
|
if (updates.slug && typeof updates.slug === 'string')
|
|
267
294
|
updates.slug = slugifyNewsletter(updates.slug);
|
|
@@ -273,8 +300,10 @@ export function registerNewsletterTools(server) {
|
|
|
273
300
|
.eq('project_id', getProjectId())
|
|
274
301
|
.select(NEWSLETTER_COLUMNS)
|
|
275
302
|
.maybeSingle();
|
|
303
|
+
if (error?.code === '23505')
|
|
304
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
276
305
|
if (error)
|
|
277
|
-
return j({ error: error.message, code:
|
|
306
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
278
307
|
if (!data)
|
|
279
308
|
return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
|
|
280
309
|
return j(data);
|
|
@@ -287,7 +316,7 @@ export function registerNewsletterTools(server) {
|
|
|
287
316
|
idempotentHint: false,
|
|
288
317
|
openWorldHint: false,
|
|
289
318
|
},
|
|
290
|
-
description: 'Create a draft issue for a newsletter.',
|
|
319
|
+
description: 'Create a draft issue for a newsletter. Refuses active HTML or executable URLs using the shared email safety filter.',
|
|
291
320
|
inputSchema: {
|
|
292
321
|
newsletter_id: z.string().uuid(),
|
|
293
322
|
subject: z.string().min(1).max(300),
|
|
@@ -295,6 +324,9 @@ export function registerNewsletterTools(server) {
|
|
|
295
324
|
preheader: z.string().optional(),
|
|
296
325
|
},
|
|
297
326
|
}, async ({ newsletter_id, subject, content, preheader }) => {
|
|
327
|
+
if (content !== undefined && stripActiveEmailContent(content).removed.length) {
|
|
328
|
+
return j({ error: 'Issue content contains active HTML or executable URLs; remove them before saving', code: 'validation_failed' });
|
|
329
|
+
}
|
|
298
330
|
const projectId = getProjectId();
|
|
299
331
|
const { data: newsletter, error: newsletterError } = await db().from('newsletters').select('id, org_id').eq('id', newsletter_id).eq('project_id', projectId).maybeSingle();
|
|
300
332
|
if (newsletterError)
|
package/build/tools/segments.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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';
|
|
@@ -80,31 +81,36 @@ export function registerSegmentTools(server) {
|
|
|
80
81
|
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
82
|
},
|
|
82
83
|
}, async ({ name, description, rules }) => {
|
|
83
|
-
let storedRules;
|
|
84
84
|
try {
|
|
85
|
-
storedRules
|
|
85
|
+
let storedRules;
|
|
86
|
+
try {
|
|
87
|
+
storedRules = prepareSegmentRulesForStorage(rules);
|
|
88
|
+
}
|
|
89
|
+
catch (rulesError) {
|
|
90
|
+
const invalid = invalidRules(rulesError);
|
|
91
|
+
if (invalid)
|
|
92
|
+
return invalid;
|
|
93
|
+
throw rulesError;
|
|
94
|
+
}
|
|
95
|
+
const { data, error } = await db()
|
|
96
|
+
.from('segments')
|
|
97
|
+
.insert({ project_id: getProjectId(), name, description, rules: storedRules })
|
|
98
|
+
.select('id, name, description, rules, created_at')
|
|
99
|
+
.single();
|
|
100
|
+
if (error)
|
|
101
|
+
return dbErrorResult(error);
|
|
102
|
+
// Count against the stored (normalized) tree, not the raw input: a tree skips
|
|
103
|
+
// applySegmentRules' SQL push-down and filters in JS, which is what list_segments
|
|
104
|
+
// does for this same segment a moment later. Pushing the flat rules down instead
|
|
105
|
+
// makes the two disagree wherever SQL and JS compare differently -- notably
|
|
106
|
+
// custom_property against a non-string JSONB value (boolean/number), where
|
|
107
|
+
// `@> {"k":"true"}` misses a stored `true` but String(actual)===String(expected) matches.
|
|
108
|
+
const ids = await getSegmentContactIds(data.rules);
|
|
109
|
+
return j({ ...data, estimated_contacts: ids.length });
|
|
86
110
|
}
|
|
87
|
-
catch (
|
|
88
|
-
|
|
89
|
-
if (invalid)
|
|
90
|
-
return invalid;
|
|
91
|
-
throw rulesError;
|
|
111
|
+
catch (error) {
|
|
112
|
+
return dbErrorResult(error);
|
|
92
113
|
}
|
|
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
114
|
});
|
|
109
115
|
server.registerTool('list_segments', {
|
|
110
116
|
title: 'List Segments',
|
|
@@ -157,32 +163,37 @@ export function registerSegmentTools(server) {
|
|
|
157
163
|
rules: z.record(z.string(), z.unknown()).optional(),
|
|
158
164
|
},
|
|
159
165
|
}, async ({ segment_id, name, description, rules }) => {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
166
|
+
try {
|
|
167
|
+
const updates = { updated_at: new Date().toISOString() };
|
|
168
|
+
if (name)
|
|
169
|
+
updates.name = name;
|
|
170
|
+
if (description !== undefined)
|
|
171
|
+
updates.description = description;
|
|
172
|
+
if (rules) {
|
|
173
|
+
try {
|
|
174
|
+
updates.rules = prepareSegmentRulesForStorage(rules);
|
|
175
|
+
}
|
|
176
|
+
catch (rulesError) {
|
|
177
|
+
const invalid = invalidRules(rulesError);
|
|
178
|
+
if (invalid)
|
|
179
|
+
return invalid;
|
|
180
|
+
throw rulesError;
|
|
181
|
+
}
|
|
174
182
|
}
|
|
183
|
+
const { data, error } = await db()
|
|
184
|
+
.from('segments')
|
|
185
|
+
.update(updates)
|
|
186
|
+
.eq('id', segment_id)
|
|
187
|
+
.eq('project_id', getProjectId())
|
|
188
|
+
.select('id, name, description, rules, updated_at')
|
|
189
|
+
.single();
|
|
190
|
+
if (error)
|
|
191
|
+
return dbErrorResult(error);
|
|
192
|
+
return j(data);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return dbErrorResult(error);
|
|
175
196
|
}
|
|
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
197
|
});
|
|
187
198
|
server.registerTool('delete_segment', {
|
|
188
199
|
title: 'Delete Segment',
|
|
@@ -2,6 +2,7 @@ import QRCode from 'qrcode';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
|
+
import { assertHttpUrl, RESERVED_LINK_SLUGS } from '../lib/url-validation.js';
|
|
5
6
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
6
7
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
7
8
|
const VANITY_SLUG_RE = /^[a-zA-Z0-9-]{3,40}$/;
|
|
@@ -9,13 +10,6 @@ const PAID_PLANS = new Set(['byod', 'managed', 'agency-plus', 'enterprise']);
|
|
|
9
10
|
function appBaseUrl() {
|
|
10
11
|
return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
11
12
|
}
|
|
12
|
-
function assertHttpUrl(raw) {
|
|
13
|
-
const url = new URL(raw);
|
|
14
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
15
|
-
throw new Error('Only http and https URLs are supported.');
|
|
16
|
-
}
|
|
17
|
-
return url.toString();
|
|
18
|
-
}
|
|
19
13
|
function normalizeColor(input, fallback) {
|
|
20
14
|
const value = input?.trim() || fallback;
|
|
21
15
|
if (!/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value))
|
|
@@ -41,21 +35,31 @@ export function registerShortLinkTools(server) {
|
|
|
41
35
|
const projectId = getProjectId();
|
|
42
36
|
const { data: project } = await db().from('projects').select('org_id, plan').eq('id', projectId).maybeSingle();
|
|
43
37
|
if (!project?.org_id)
|
|
44
|
-
return j({ error: 'Project not found', code: '
|
|
38
|
+
return j({ error: 'Project not found', code: 'not_found' });
|
|
45
39
|
let destination;
|
|
46
40
|
try {
|
|
47
41
|
destination = assertHttpUrl(destination_url);
|
|
48
42
|
}
|
|
49
43
|
catch (err) {
|
|
50
|
-
return j({ error: err instanceof Error ? err.message : 'Invalid destination URL', code: '
|
|
44
|
+
return j({ error: err instanceof Error ? err.message : 'Invalid destination URL', code: 'validation_failed' });
|
|
45
|
+
}
|
|
46
|
+
if (campaign_id) {
|
|
47
|
+
const { data: campaign, error } = await db().from('campaigns').select('id').eq('id', campaign_id).eq('project_id', projectId).maybeSingle();
|
|
48
|
+
if (error)
|
|
49
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
50
|
+
if (!campaign)
|
|
51
|
+
return j({ error: 'Campaign not found in this project', code: 'not_found' });
|
|
51
52
|
}
|
|
52
53
|
const requestedSlug = slug?.trim() || null;
|
|
53
54
|
if (requestedSlug) {
|
|
55
|
+
if (RESERVED_LINK_SLUGS.has(requestedSlug.toLowerCase())) {
|
|
56
|
+
return j({ error: 'This slug is reserved for system routes.', code: 'validation_failed' });
|
|
57
|
+
}
|
|
54
58
|
if (!VANITY_SLUG_RE.test(requestedSlug)) {
|
|
55
|
-
return j({ error: 'Custom slug must be 3-40 characters and contain only letters, numbers, or hyphens.', code: '
|
|
59
|
+
return j({ error: 'Custom slug must be 3-40 characters and contain only letters, numbers, or hyphens.', code: 'validation_failed' });
|
|
56
60
|
}
|
|
57
61
|
if (!PAID_PLANS.has(project.plan ?? 'free')) {
|
|
58
|
-
return j({ error: 'Custom slugs require vanity_slug_access on a paid plan.', code: '
|
|
62
|
+
return j({ error: 'Custom slugs require vanity_slug_access on a paid plan.', code: 'plan_limit' });
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
const finalSlug = requestedSlug ?? (await db().rpc('next_short_link_slug')).data;
|
|
@@ -74,8 +78,10 @@ export function registerShortLinkTools(server) {
|
|
|
74
78
|
})
|
|
75
79
|
.select()
|
|
76
80
|
.single();
|
|
81
|
+
if (error?.code === '23505')
|
|
82
|
+
return j({ error: 'slug already exists', code: 'conflict' });
|
|
77
83
|
if (error)
|
|
78
|
-
return j({ error: error.message });
|
|
84
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
79
85
|
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
80
86
|
});
|
|
81
87
|
server.registerTool('get_short_link_stats', {
|
|
@@ -99,7 +105,7 @@ export function registerShortLinkTools(server) {
|
|
|
99
105
|
if (linkError)
|
|
100
106
|
return j({ error: linkError.message });
|
|
101
107
|
if (!link)
|
|
102
|
-
return j({ error: 'Short link not found', code: '
|
|
108
|
+
return j({ error: 'Short link not found', code: 'not_found' });
|
|
103
109
|
const { data: clicks, error: clickError } = await db()
|
|
104
110
|
.from('short_link_clicks')
|
|
105
111
|
.select('clicked_at, ip_hash, referer')
|
|
@@ -140,7 +146,7 @@ export function registerShortLinkTools(server) {
|
|
|
140
146
|
normalizedUrl = assertHttpUrl(url);
|
|
141
147
|
}
|
|
142
148
|
catch (err) {
|
|
143
|
-
return j({ error: err instanceof Error ? err.message : 'Invalid QR URL', code: '
|
|
149
|
+
return j({ error: err instanceof Error ? err.message : 'Invalid QR URL', code: 'validation_failed' });
|
|
144
150
|
}
|
|
145
151
|
const dataUri = await QRCode.toDataURL(normalizedUrl, {
|
|
146
152
|
type: 'image/png',
|
package/build/tools/surveys.js
CHANGED
|
@@ -22,10 +22,25 @@ function normalizeQuestions(questions) {
|
|
|
22
22
|
...question,
|
|
23
23
|
id,
|
|
24
24
|
label,
|
|
25
|
-
type: question.type
|
|
25
|
+
type: question.type,
|
|
26
26
|
};
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
+
function validateQuestions(questions) {
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
for (const question of questions) {
|
|
32
|
+
if (!question.label.trim())
|
|
33
|
+
return 'Each question needs a non-empty label or text';
|
|
34
|
+
if (ids.has(question.id))
|
|
35
|
+
return `Duplicate question id: ${question.id}`;
|
|
36
|
+
ids.add(question.id);
|
|
37
|
+
if (['single_choice', 'multi_choice', 'multiple_choice'].includes(question.type) &&
|
|
38
|
+
(!Array.isArray(question.options) || question.options.length < 2 || question.options.some(option => !option.trim()))) {
|
|
39
|
+
return `Question ${question.id} needs at least two non-empty options`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
29
44
|
function tallyResponses(responses, questions) {
|
|
30
45
|
return questions.map((question) => {
|
|
31
46
|
const values = responses
|
|
@@ -69,13 +84,17 @@ export function registerSurveyTools(server) {
|
|
|
69
84
|
status: z.enum(['draft', 'active', 'closed']).optional().default('draft'),
|
|
70
85
|
},
|
|
71
86
|
}, async ({ title, questions, site_id, status }) => {
|
|
87
|
+
const normalized = normalizeQuestions(questions);
|
|
88
|
+
const validationError = validateQuestions(normalized);
|
|
89
|
+
if (validationError)
|
|
90
|
+
return j({ error: validationError, code: 'validation_failed' });
|
|
72
91
|
const projectId = getProjectId();
|
|
73
92
|
const { data: project } = await db().from('projects').select('org_id, plan').eq('id', projectId).maybeSingle();
|
|
74
93
|
if (!project?.org_id)
|
|
75
94
|
return j({ error: 'Project not found', code: 'NOT_FOUND' });
|
|
76
95
|
const { data: survey, error } = await db()
|
|
77
96
|
.from('surveys')
|
|
78
|
-
.insert({ project_id: projectId, title, questions:
|
|
97
|
+
.insert({ project_id: projectId, title, questions: normalized, status })
|
|
79
98
|
.select()
|
|
80
99
|
.single();
|
|
81
100
|
if (error)
|
|
@@ -200,8 +219,26 @@ export function registerSurveyTools(server) {
|
|
|
200
219
|
const updates = {};
|
|
201
220
|
if (title !== undefined)
|
|
202
221
|
updates.title = title;
|
|
203
|
-
if (questions !== undefined)
|
|
204
|
-
|
|
222
|
+
if (questions !== undefined || status === 'active') {
|
|
223
|
+
let candidate = questions;
|
|
224
|
+
if (candidate === undefined) {
|
|
225
|
+
const { data: existing, error } = await db().from('surveys').select('questions').eq('id', survey_id).eq('project_id', projectId).maybeSingle();
|
|
226
|
+
if (error)
|
|
227
|
+
return j({ error: error.message, code: 'INTERNAL_ERROR' });
|
|
228
|
+
if (!existing)
|
|
229
|
+
return j({ error: 'Survey not found', code: 'not_found' });
|
|
230
|
+
candidate = existing.questions;
|
|
231
|
+
}
|
|
232
|
+
const parsed = z.array(QUESTION_SCHEMA).min(1).safeParse(candidate);
|
|
233
|
+
if (!parsed.success)
|
|
234
|
+
return j({ error: 'Survey questions are invalid', code: 'validation_failed' });
|
|
235
|
+
const normalized = normalizeQuestions(parsed.data);
|
|
236
|
+
const validationError = validateQuestions(normalized);
|
|
237
|
+
if (validationError)
|
|
238
|
+
return j({ error: validationError, code: 'validation_failed' });
|
|
239
|
+
if (questions !== undefined)
|
|
240
|
+
updates.questions = normalized;
|
|
241
|
+
}
|
|
205
242
|
if (status !== undefined)
|
|
206
243
|
updates.status = status;
|
|
207
244
|
if (Object.keys(updates).length === 0)
|
|
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
|
|
|
12
12
|
* Only tools that exist in the registry belong here.
|
|
13
13
|
*/
|
|
14
14
|
export const TOOL_ALIASES = {
|
|
15
|
+
list_custom_fields: ['custom fields', 'field definitions', 'contact schema', 'unused fields'],
|
|
15
16
|
update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
|
|
16
17
|
set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'payment failed', 'trial ending', 'event trigger', 'billing'],
|
|
17
18
|
create_campaign_with_content: ['billing', 'dunning', 'trial ending', 'transactional', 'payment failed', 'product email', 'event-driven billing mail'],
|
package/build/tools/webhooks.js
CHANGED
|
@@ -29,14 +29,14 @@ export function registerWebhookTools(server) {
|
|
|
29
29
|
server.registerTool('list_sandbox_webhook_events', {
|
|
30
30
|
title: 'List Sandbox Webhook Events',
|
|
31
31
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
32
|
-
description: 'Read recent captured webhook requests for the active sandbox project (seven-day retention).
|
|
32
|
+
description: 'Read recent captured webhook requests for the active sandbox project (seven-day retention). Returns only allowlisted safe headers; never returns sink tokens.',
|
|
33
33
|
inputSchema: { limit: z.number().int().min(1).max(100).optional().default(20) },
|
|
34
34
|
}, async ({ limit }) => {
|
|
35
35
|
try {
|
|
36
36
|
return j({ events: await listSandboxWebhookEvents(adminDb(), getProjectId(), limit) });
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
|
-
return
|
|
39
|
+
return j({ error: 'Sandbox webhook events unavailable.', code: 'forbidden' });
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
server.registerTool('subscribe_webhook', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.140",
|
|
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",
|