@sendinel/mcp-server 1.0.138 → 1.0.139

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 CHANGED
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
18
18
  [event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
19
19
  inputs, idempotency, subscription requirements, and conditional step examples.
20
20
 
21
- Sendinel currently exposes 35 tool groups and 343 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
21
+ Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
22
22
 
23
23
  | Group | Count | Purpose |
24
24
  |-------|-------|---------|
@@ -34,7 +34,7 @@ Sendinel currently exposes 35 tool groups and 343 tools (gateway mode exposes 4
34
34
  | `cloud-import` | 6 | Cloud storage import |
35
35
  | `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
36
36
  | `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
37
- | `contacts` | 39 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
37
+ | `contacts` | 40 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
38
38
  | `content` | 14 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
39
39
  | `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
40
40
  | `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
@@ -1,3 +1,6 @@
1
+ /** Redirect destinations may use HTTP or HTTPS, but never executable URL schemes. */
2
+ export declare function assertHttpUrl(raw: string): string;
3
+ export declare const RESERVED_LINK_SLUGS: Set<string>;
1
4
  export declare function isSafeRedirectUrl(url: unknown): url is string;
2
5
  export declare function isAllowedHttpsHostUrl(url: unknown, allowedDomains: readonly string[]): url is string;
3
6
  export declare function normalizeActiveCampaignAccountUrl(accountUrl: string): string;
@@ -1,3 +1,18 @@
1
+ /** Redirect destinations may use HTTP or HTTPS, but never executable URL schemes. */
2
+ export function assertHttpUrl(raw) {
3
+ const url = new URL(raw);
4
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
5
+ throw new Error('Only http and https URLs are supported.');
6
+ }
7
+ return url.toString();
8
+ }
9
+ // Shared /s namespace: system routes cannot be claimed by a tenant.
10
+ export const RESERVED_LINK_SLUGS = new Set([
11
+ 'admin', 'api', 'app', 'login', 'dashboard', 's', 'a', 'www', 'support', 'billing',
12
+ 'auth', 'static', 'assets', 'aup', 'ccpa', 'changelog', 'cloud-storage', 'demo-video',
13
+ 'developer', 'embed', 'gdpr', 'getting-started', 'invite', 'legal', 'lp', 'oauth',
14
+ 'pricing', 'privacy', 'reset-password', 'sign-in', 'sign-up', 'terms',
15
+ ]);
1
16
  export function isSafeRedirectUrl(url) {
2
17
  if (typeof url !== 'string' || !url)
3
18
  return false;
@@ -42,7 +42,7 @@ export function registerApprovalTools(server, options) {
42
42
  idempotentHint: true,
43
43
  openWorldHint: false,
44
44
  },
45
- description: 'List all pending approval requests for this project. Shows what actions are waiting for human approval.',
45
+ description: 'List all pending approval requests for this project. Excludes expired requests without mutating them. Shows what actions are waiting for human approval.',
46
46
  inputSchema: {},
47
47
  }, async () => {
48
48
  const { data, error } = await db()
@@ -50,10 +50,11 @@ export function registerApprovalTools(server, options) {
50
50
  .select('id, action, tier, channel, summary, status, requested_at, expires_at')
51
51
  .eq('project_id', getProjectId())
52
52
  .eq('status', 'pending')
53
+ .gt('expires_at', new Date().toISOString())
53
54
  .order('requested_at', { ascending: false })
54
55
  .limit(25);
55
56
  if (error)
56
- return txt(`Error: ${error.message}`);
57
+ return j({ error: error.message, code: 'INTERNAL_ERROR' });
57
58
  return j({ pending: data ?? [], count: data?.length ?? 0 });
58
59
  });
59
60
  }
@@ -7,7 +7,8 @@ export declare function buildCampaignFiresOn(campaign: Record<string, unknown>):
7
7
  /**
8
8
  * Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
9
9
  */
10
- export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>): string[];
10
+ export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>, seenEvents?: ReadonlySet<string> | null): string[];
11
+ export declare function campaignTriggerKind(campaign: Record<string, unknown>): 'event' | 'tag' | 'date_field' | 'contact_event' | 'manual';
11
12
  export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
12
13
  trigger_event_name?: string | null;
13
14
  trigger_tag?: string | null;
@@ -105,11 +105,18 @@ export function buildCampaignFiresOn(campaign) {
105
105
  /**
106
106
  * Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
107
107
  */
108
- export function buildCampaignTriggerDiagnostics(campaign) {
108
+ export function buildCampaignTriggerDiagnostics(campaign, seenEvents) {
109
109
  const config = (campaign.trigger_config && typeof campaign.trigger_config === 'object' ? campaign.trigger_config : {});
110
110
  const events = normalizeTriggerList([...normalizeTriggerList(config.events), campaign.trigger_event_name]);
111
111
  const tags = normalizeTriggerList([...normalizeTriggerList(config.tags), campaign.trigger_tag]);
112
112
  const diagnostics = [];
113
+ if (events.length && seenEvents === null)
114
+ diagnostics.push('Event history unavailable; prior receipt of these events is unknown.');
115
+ if (seenEvents)
116
+ for (const event of events) {
117
+ if (!seenEvents.has(event))
118
+ diagnostics.push(`Event "${event}" has never been seen in this project; check the event name or send a test event.`);
119
+ }
113
120
  if ((events.length || tags.length) && campaign.type !== 'triggered') {
114
121
  diagnostics.push(`campaign type is "${String(campaign.type)}"; event and tag triggers only start campaigns of type "triggered" (it will never fire)`);
115
122
  }
@@ -133,6 +140,37 @@ export function buildCampaignTriggerDiagnostics(campaign) {
133
140
  }
134
141
  return diagnostics;
135
142
  }
143
+ export function campaignTriggerKind(campaign) {
144
+ if (campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event')
145
+ return campaign.trigger_type;
146
+ const config = normalizeTriggerConfig(campaign.trigger_config);
147
+ if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length)
148
+ return 'event';
149
+ if (normalizeTriggerValue(campaign.trigger_tag) || config.tags.length)
150
+ return 'tag';
151
+ return 'manual';
152
+ }
153
+ async function campaignTriggerOutput(campaign) {
154
+ let seenEvents;
155
+ const config = normalizeTriggerConfig(campaign.trigger_config);
156
+ if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length) {
157
+ try {
158
+ // /v1/events -> intakeContactEvent stores event_type=custom and title=event.
159
+ // Read all pages: a capped sample cannot establish that an event was never seen.
160
+ const rows = await readAllRows(db().from('contact_events').select('id, title')
161
+ .eq('project_id', getProjectId()).eq('event_type', 'custom'));
162
+ seenEvents = new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
163
+ }
164
+ catch {
165
+ seenEvents = null;
166
+ }
167
+ }
168
+ return {
169
+ trigger_kind: campaignTriggerKind(campaign),
170
+ fires_on: buildCampaignFiresOn(campaign),
171
+ trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign, seenEvents),
172
+ };
173
+ }
136
174
  function applyCampaignSendAtField(updates, sendAt, campaignType) {
137
175
  if (sendAt === undefined)
138
176
  return null;
@@ -165,13 +203,20 @@ export async function applyCampaignTargetingFields(updates, input, projectId, ex
165
203
  if (hasTriggerEvent)
166
204
  return 'cannot set trigger_event_name on a date_field campaign';
167
205
  const config = input.trigger_config ?? {};
206
+ const unknown = Object.keys(config).filter(key => !['field', 'days_before', 'recurring'].includes(key));
207
+ if (unknown.length)
208
+ return `Unknown date_field keys: ${unknown.join(', ')}; allowed: field, days_before, recurring; use days_before, not offset_days`;
209
+ if (config.days_before !== undefined && (typeof config.days_before !== 'number' || !Number.isInteger(config.days_before) || config.days_before < 0 || config.days_before > 30))
210
+ return 'days_before must be an integer from 0 to 30; use days_before, not offset_days';
211
+ if (config.recurring !== undefined && typeof config.recurring !== 'boolean')
212
+ return 'recurring must be a boolean';
168
213
  const field = typeof config.field === 'string' ? config.field.trim() : '';
169
214
  if (!/^[A-Za-z0-9_.-]{1,80}$/.test(field))
170
215
  return 'date field is required';
171
216
  updates.trigger_type = 'date_field';
172
217
  updates.trigger_config = {
173
218
  field,
174
- days_before: Math.max(0, Math.min(30, Number(config.days_before ?? 0) || 0)),
219
+ days_before: config.days_before ?? 0,
175
220
  recurring: config.recurring !== false,
176
221
  };
177
222
  }
@@ -197,6 +242,24 @@ export async function applyCampaignTargetingFields(updates, input, projectId, ex
197
242
  updates.trigger_config = null;
198
243
  }
199
244
  else {
245
+ const unknown = Object.keys(input.trigger_config).filter(key => !['events', 'tags', 'where'].includes(key));
246
+ if (unknown.length)
247
+ return `Unknown trigger_config keys: ${unknown.join(', ')}; allowed: events, tags, where`;
248
+ if ('where' in input.trigger_config) {
249
+ const where = input.trigger_config.where;
250
+ if (!where || typeof where !== 'object' || Array.isArray(where))
251
+ return 'where must be an object of string, number or boolean values';
252
+ const keys = new Set();
253
+ for (const [key, value] of Object.entries(where)) {
254
+ const normalizedKey = key.trim().replace(/^ctx\./, '');
255
+ if (!normalizedKey || normalizedKey === '__proto__' || keys.has(normalizedKey))
256
+ return `where.${key} has an empty or duplicate normalized key`;
257
+ keys.add(normalizedKey);
258
+ if (!['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isFinite(value))) {
259
+ return `where.${key} must be a string, number or boolean; operators like $gt are not supported`;
260
+ }
261
+ }
262
+ }
200
263
  const config = buildCampaignTriggerConfig(input.trigger_config);
201
264
  if (!config)
202
265
  return 'trigger_config must include events or tags';
@@ -3188,8 +3251,7 @@ Return ONLY valid JSON with this exact shape:
3188
3251
  send_class: campaign.send_class,
3189
3252
  unsub_footer_template: campaign.unsub_footer_template,
3190
3253
  ...targeting,
3191
- fires_on: buildCampaignFiresOn({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3192
- trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3254
+ ...await campaignTriggerOutput({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3193
3255
  steps_created: steps.length,
3194
3256
  contacts_enrolled: enrolled,
3195
3257
  status: willActivate ? 'active' : 'draft',
@@ -3381,7 +3443,7 @@ Return ONLY valid JSON with this exact shape:
3381
3443
  server.registerTool('set_campaign_trigger', {
3382
3444
  title: 'Set Campaign Trigger',
3383
3445
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3384
- description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
3446
+ description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, derived trigger_kind, fires_on and trigger_diagnostics (including unseen-event warnings).',
3385
3447
  inputSchema: {
3386
3448
  ...reentryFields,
3387
3449
  campaign_id: z.string().uuid().optional(),
@@ -3395,7 +3457,7 @@ Return ONLY valid JSON with this exact shape:
3395
3457
  }, async ({ approval_id, ...args }) => {
3396
3458
  if (approval_id) {
3397
3459
  const result = await resumeCampaignEdit('set_campaign_trigger', approval_id, args);
3398
- return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3460
+ return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
3399
3461
  }
3400
3462
  if (!args.campaign_id)
3401
3463
  return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
@@ -3435,7 +3497,7 @@ Return ONLY valid JSON with this exact shape:
3435
3497
  if (reentry.period_key !== undefined)
3436
3498
  updates.period_key = reentry.period_key;
3437
3499
  const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
3438
- return j(result.error || result.requires_approval ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3500
+ return j(result.error || result.requires_approval ? result : { ...result, ...await campaignTriggerOutput(result) });
3439
3501
  });
3440
3502
  server.registerTool('update_campaign', {
3441
3503
  title: 'Update Campaign',
@@ -3468,7 +3530,7 @@ Return ONLY valid JSON with this exact shape:
3468
3530
  }, async ({ approval_id, ...input }) => {
3469
3531
  if (approval_id) {
3470
3532
  const result = await resumeCampaignEdit('update_campaign', approval_id, input);
3471
- return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3533
+ return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
3472
3534
  }
3473
3535
  const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class, unsub_footer_template, reentry_mode, period_key } = input;
3474
3536
  if (!campaign_id)
@@ -3599,7 +3661,7 @@ Return ONLY valid JSON with this exact shape:
3599
3661
  idempotentHint: true,
3600
3662
  openWorldHint: false,
3601
3663
  },
3602
- description: 'Get full campaign details including all steps, enrollment counts and saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. ' + ACTIVATION_APPROVAL_DESCRIPTION,
3664
+ description: 'Get full campaign details including all steps, enrollment counts and derived trigger_kind (event/tag/date_field/contact_event/manual), saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. ' + ACTIVATION_APPROVAL_DESCRIPTION,
3603
3665
  inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
3604
3666
  }, async ({ campaign_id, period }) => {
3605
3667
  const projectId = getProjectId();
@@ -3672,8 +3734,7 @@ Return ONLY valid JSON with this exact shape:
3672
3734
  ...campaign,
3673
3735
  reentry_mode: campaign.period_key ? "per_event_period" : "once_ever",
3674
3736
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
3675
- fires_on: buildCampaignFiresOn(campaign),
3676
- trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
3737
+ ...await campaignTriggerOutput(campaign),
3677
3738
  total_sent: periods?.totals?.sent ?? sentCount ?? 0,
3678
3739
  ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
3679
3740
  ...campaignPeriodFields(periods, period),
@@ -24,6 +24,40 @@ const CUSTOM_FIELD_TYPES = [
24
24
  'multi_select', 'contact_reference',
25
25
  ];
26
26
  const CUSTOM_FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,49}$/;
27
+ // Core contact columns from 001_core_schema.sql and subsequent subscriber migrations.
28
+ const RESERVED_CUSTOM_FIELD_KEYS = new Set([
29
+ 'email', 'first_name', 'last_name', 'id', 'phone', 'status', 'tags', 'project_id', 'org_id', 'user_id',
30
+ 'created_at', 'updated_at', 'clerk_user_id', 'global_unsubscribed', 'global_unsubscribed_at',
31
+ 'source', 'source_site', 'ip_address', 'opted_in_at', 'last_emailed_at', 'last_opened_at',
32
+ 'last_clicked_at', 'total_emails_sent', 'total_opens', 'total_clicks', 'engagement_score',
33
+ 'lifecycle_stage', 'score_updated_at', 'custom_properties', 'timezone', 'last_engaged_at',
34
+ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'best_send_hour',
35
+ 'best_send_hour_confidence', 'best_send_hour_updated_at', 'notes', 'birthday', 'anniversary',
36
+ 'external_id', 'imported_from', 'phone_e164', 'sms_opt_in_status', 'sms_opt_in_at',
37
+ 'sms_opt_out_at', 'is_test', 'company_id', 'source_deleted_at', 'cooled_at', 'cooled_reason',
38
+ 'introduced_by_subscriber_id', 'ccpa_opted_out', 'last_transactional_at',
39
+ 'total_transactional_sent', 'last_activity_at',
40
+ ]);
41
+ const CUSTOM_FIELD_COLUMNS = 'id, key, label, type, required, options, display_order';
42
+ async function registeredCustomFields(projectId) {
43
+ return readAllRows(db().from('custom_field_definitions').select(CUSTOM_FIELD_COLUMNS).eq('project_id', projectId));
44
+ }
45
+ function keyDistance(left, right) {
46
+ let row = Array.from({ length: right.length + 1 }, (_, index) => index);
47
+ for (let i = 0; i < left.length; i++) {
48
+ const next = [i + 1];
49
+ for (let j = 0; j < right.length; j++)
50
+ next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (left[i] === right[j] ? 0 : 1)));
51
+ row = next;
52
+ }
53
+ return row[right.length];
54
+ }
55
+ function unknownCustomPropertyWarnings(patch, fields) {
56
+ return Object.keys(patch).filter(key => !Object.hasOwn(fields, key)).map(key => {
57
+ const near = Object.keys(fields).filter(known => keyDistance(key, known) <= 2);
58
+ return `Unknown custom property "${key}"${near.length ? `; did you mean ${near.map(value => `"${value}"`).join(' or ')}?` : '; no registered definition'}`;
59
+ });
60
+ }
27
61
  const SUBSCRIBER_VIEW_MAX_NAME_LENGTH = 100;
28
62
  const SUBSCRIBER_VIEW_MAX_COLUMNS = 150;
29
63
  const SUBSCRIBER_VIEW_KEY_RE = /^[a-zA-Z0-9_.:-]{1,160}$/;
@@ -1670,7 +1704,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1670
1704
  idempotentHint: true,
1671
1705
  openWorldHint: false,
1672
1706
  },
1673
- description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags.',
1707
+ description: 'Update a contact\'s email, name, phone, timezone, tags, or custom properties. tags replaces the whole tag set; use set_subscriber_tags to add tags. Registered custom properties must match their type/options; unknown keys remain allowed with warnings and near-miss suggestions.',
1674
1708
  inputSchema: {
1675
1709
  contact_id: z.string().uuid(),
1676
1710
  email: z.string().email().optional(),
@@ -1686,6 +1720,33 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1686
1720
  },
1687
1721
  }, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
1688
1722
  const projectId = getProjectId();
1723
+ let propertyWarnings = [];
1724
+ let fields;
1725
+ if (custom_properties !== undefined) {
1726
+ let definitions;
1727
+ try {
1728
+ definitions = await registeredCustomFields(projectId);
1729
+ }
1730
+ catch {
1731
+ return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
1732
+ }
1733
+ fields = Object.fromEntries(definitions.map(field => [field.key, field]));
1734
+ const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
1735
+ const invalidShape = Object.entries(values).find(([key, value]) => {
1736
+ if (!Object.hasOwn(fields, key))
1737
+ return false;
1738
+ const type = fields[key].type;
1739
+ if (type === 'multi_select')
1740
+ return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
1741
+ return typeof value === 'object' && type !== 'contact_reference';
1742
+ });
1743
+ const { anomalies } = normalizeContactProperties(values, { fields });
1744
+ if (invalidShape || anomalies.length) {
1745
+ const key = invalidShape?.[0] ?? anomalies[0].key;
1746
+ return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
1747
+ }
1748
+ propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
1749
+ }
1689
1750
  const embedOk = await hasCompaniesEmbed(projectId);
1690
1751
  const updates = {};
1691
1752
  let customPropertyChanges = [];
@@ -1707,18 +1768,23 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1707
1768
  if (tags !== undefined)
1708
1769
  updates.tags = tags;
1709
1770
  if (custom_properties !== undefined) {
1710
- const { data: existing } = await db()
1771
+ const { data: existing, error: existingError } = await db()
1711
1772
  .from('subscribers')
1712
1773
  .select('custom_properties')
1713
1774
  .eq('id', contact_id)
1714
1775
  .eq('project_id', projectId)
1715
1776
  .maybeSingle();
1777
+ if (existingError)
1778
+ return j({ error: 'Contact properties unavailable; update not performed', code: 'INTERNAL_ERROR' });
1779
+ if (!existing)
1780
+ return contactInputError('not_found', 'Contact not found');
1716
1781
  const orgId = await getProjectOrgId(projectId);
1717
1782
  const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
1718
1783
  orgId,
1719
1784
  projectId,
1720
1785
  subscriberId: contact_id,
1721
1786
  preserveNullDeletions: true,
1787
+ fields,
1722
1788
  });
1723
1789
  customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
1724
1790
  updates.custom_properties = {
@@ -1780,7 +1846,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1780
1846
  }
1781
1847
  }
1782
1848
  if (!Object.keys(updates).length)
1783
- return txt('No fields to update.');
1849
+ return contactInputError('validation_failed', 'No fields to update.');
1784
1850
  const tagResult = {};
1785
1851
  if (tags !== undefined) {
1786
1852
  let previousTags;
@@ -1820,10 +1886,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1820
1886
  .select(embedOk ? UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY : UPDATE_SUBSCRIBER_SELECT_NO_COMPANY)
1821
1887
  .single();
1822
1888
  if (error?.code === '23505' && updates.phone_e164 !== undefined) {
1823
- return contactInputError('validation_failed', 'Another contact in this project already has this phone number.');
1889
+ return contactInputError('conflict', 'Another contact in this project already has this phone number.');
1824
1890
  }
1825
1891
  if (error)
1826
- return txt(`Error: ${error.message}`);
1892
+ return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
1827
1893
  if (customPropertyChanges.length > 0) {
1828
1894
  logAudit({
1829
1895
  action: 'contact.custom_properties_changed',
@@ -1839,7 +1905,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1839
1905
  ...(updates.tags !== undefined ? { tags: updates.tags } : {}),
1840
1906
  ...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
1841
1907
  });
1842
- return j({ ...data, ...tagResult });
1908
+ const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
1909
+ return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
1843
1910
  });
1844
1911
  server.registerTool('get_subscriber', {
1845
1912
  title: 'Get Subscriber',
@@ -2515,7 +2582,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2515
2582
  idempotentHint: true,
2516
2583
  openWorldHint: false,
2517
2584
  },
2518
- description: 'Discover all custom_properties keys used across contacts for a site. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
2585
+ description: 'Discover registered custom field definitions and all custom_properties keys used across contacts for a site, including unused definitions. Returns the list of keys, example values, and merge tag syntax so you can use them in campaigns. Call this before generating emails to know what personalisation data is available.',
2519
2586
  inputSchema: {
2520
2587
  site_id: z.string().uuid().describe('Site to inspect — only contacts belonging to this site are sampled'),
2521
2588
  },
@@ -2539,20 +2606,34 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2539
2606
  // site-scoped; project scope is only for a site with no members at all.
2540
2607
  let loaded = await loadSchemaKeys(site_id);
2541
2608
  if ('error' in loaded)
2542
- return txt(loaded.error);
2609
+ return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
2543
2610
  let scope = 'site';
2544
2611
  if (!loaded.has_membership) {
2545
2612
  loaded = await loadSchemaKeys(null);
2546
2613
  if ('error' in loaded)
2547
- return txt(loaded.error);
2614
+ return j({ error: loaded.error, code: 'INTERNAL_ERROR' });
2548
2615
  scope = 'project';
2549
2616
  }
2550
- const schema = loaded.keys.map(({ key, contacts_with_this_key, example_values }) => ({
2617
+ let definitions;
2618
+ try {
2619
+ definitions = await registeredCustomFields(projectId);
2620
+ }
2621
+ catch {
2622
+ return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
2623
+ }
2624
+ const byKey = new Map(definitions.map(field => [field.key, field]));
2625
+ const keys = [...loaded.keys];
2626
+ const observed = new Set(keys.map(row => row.key));
2627
+ for (const field of definitions)
2628
+ if (!observed.has(field.key))
2629
+ keys.push({ key: field.key, contacts_with_this_key: 0, example_values: [] });
2630
+ const schema = keys.map(({ key, contacts_with_this_key, example_values }) => ({
2551
2631
  key,
2552
2632
  merge_tag: `{{custom.${key}}}`,
2553
2633
  merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
2554
2634
  example_values,
2555
2635
  contacts_with_this_key,
2636
+ ...(byKey.has(key) ? { definition: byKey.get(key) } : {}),
2556
2637
  }));
2557
2638
  return j({
2558
2639
  site_id,
@@ -2564,6 +2645,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2564
2645
  : 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
2565
2646
  });
2566
2647
  });
2648
+ server.registerTool('list_custom_fields', {
2649
+ title: 'List Custom Fields',
2650
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2651
+ description: 'List all registered custom field definitions for the current project, including fields with no contact values yet.',
2652
+ inputSchema: {},
2653
+ }, async () => {
2654
+ try {
2655
+ const fields = await registeredCustomFields(getProjectId());
2656
+ return j({ fields, count: fields.length });
2657
+ }
2658
+ catch {
2659
+ return j({ error: 'Custom field definitions unavailable', code: 'INTERNAL_ERROR' });
2660
+ }
2661
+ });
2567
2662
  server.registerTool('create_custom_field', {
2568
2663
  title: 'Create Custom Field',
2569
2664
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
@@ -2577,9 +2672,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2577
2672
  display_order: z.number().int().optional().default(0),
2578
2673
  },
2579
2674
  }, async ({ key, label, type, required, options, display_order }) => {
2675
+ if (RESERVED_CUSTOM_FIELD_KEYS.has(key))
2676
+ return contactInputError('validation_failed', `Custom field key "${key}" is reserved for a core contact column`);
2580
2677
  const optionsError = validateCustomFieldOptions(type, options);
2581
2678
  if (optionsError)
2582
- return txt(`Error: ${optionsError}`);
2679
+ return contactInputError('validation_failed', `Error: ${optionsError}`);
2583
2680
  const normalizedOptions = type === 'select' || type === 'multi_select'
2584
2681
  ? normalizeCustomFieldOptions(options)
2585
2682
  : null;
@@ -2601,8 +2698,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2601
2698
  .single();
2602
2699
  if (error) {
2603
2700
  if (error.code === '23505')
2604
- return txt(`Error: A custom field with key "${key}" already exists.`);
2605
- return txt(`Error: ${error.message}`);
2701
+ return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
2702
+ return j({ error: `Error: ${error.message}`, code: 'INTERNAL_ERROR' });
2606
2703
  }
2607
2704
  await logAudit({
2608
2705
  action: 'custom_field.created',
@@ -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)
@@ -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.139",
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",