@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.
@@ -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
- if (Number.isNaN(new Date(`${date}T00:00:00Z`).getTime())) {
101
- return j({ error: 'date must be a valid YYYY-MM-DD date' });
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')
@@ -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: error.code === '23505' ? 'CONFLICT' : undefined });
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: error.code === '23505' ? 'CONFLICT' : undefined });
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)
@@ -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 = prepareSegmentRulesForStorage(rules);
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 (rulesError) {
88
- const invalid = invalidRules(rulesError);
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
- const updates = { updated_at: new Date().toISOString() };
161
- if (name)
162
- updates.name = name;
163
- if (description !== undefined)
164
- updates.description = description;
165
- if (rules) {
166
- try {
167
- updates.rules = prepareSegmentRulesForStorage(rules);
168
- }
169
- catch (rulesError) {
170
- const invalid = invalidRules(rulesError);
171
- if (invalid)
172
- return invalid;
173
- throw rulesError;
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: 'NOT_FOUND' });
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: 'INVALID_URL' });
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: 'INVALID_SLUG' });
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: 'VANITY_SLUG_NOT_ALLOWED' });
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: 'NOT_FOUND' });
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: 'INVALID_URL' });
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',
@@ -22,10 +22,25 @@ function normalizeQuestions(questions) {
22
22
  ...question,
23
23
  id,
24
24
  label,
25
- type: question.type === 'single_choice' ? 'multiple_choice' : 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: normalizeQuestions(questions), status })
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
- updates.questions = normalizeQuestions(questions);
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'],
@@ -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). Never returns sink tokens.',
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 txt('Sandbox webhook events unavailable.');
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.138",
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",