@sendinel/mcp-server 1.0.170 → 1.0.172

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.
@@ -1,5 +1,9 @@
1
1
  import type Anthropic from '@anthropic-ai/sdk';
2
2
  import { type ComposerBlock } from './composer-blocks.js';
3
+ /** Known unsupported targeting must fail before a model can invent a property rule. */
4
+ export declare function unsupportedAudienceReason(description: string): string | null;
5
+ export declare class AudienceNotUnderstoodError extends Error {
6
+ }
3
7
  export declare function parseAudienceRules(anthropic: Anthropic, description: string): Promise<Record<string, unknown>>;
4
8
  export type GeneratedEmail = {
5
9
  subject: string;
@@ -11,24 +11,39 @@ import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerB
11
11
  function parseJsonResponse(raw) {
12
12
  return JSON.parse(raw.replace(/```json?\n?|```/g, '').trim());
13
13
  }
14
+ const EMAIL_DOMAIN_AUDIENCE_MESSAGE = 'Audiences by email address or domain are not supported. Create a segment using a tag or property rule, or pass segment_id for a saved segment.';
15
+ /** Known unsupported targeting must fail before a model can invent a property rule. */
16
+ export function unsupportedAudienceReason(description) {
17
+ return /\be-?mail[\s_-]+domains?\b|\be-?mail(?:\s+addresses?)?\s+(?:ends?\s+with|contains?|matches?|is\b|at\b)|@[a-z0-9](?:[a-z0-9.-]*\.)[a-z]{2,}\b/i.test(description)
18
+ ? EMAIL_DOMAIN_AUDIENCE_MESSAGE : null;
19
+ }
20
+ export class AudienceNotUnderstoodError extends Error {
21
+ }
14
22
  export async function parseAudienceRules(anthropic, description) {
23
+ const unsupported = unsupportedAudienceReason(description);
24
+ if (unsupported)
25
+ throw new AudienceNotUnderstoodError(unsupported);
15
26
  const resp = await anthropic.messages.create({
16
27
  model: getAnthropicModelId(),
17
28
  max_tokens: AI_MAX_TOKENS,
18
29
  ...outputConfig("low"),
19
- system: 'You translate natural language audience descriptions into a JSON filter rules object. Return ONLY valid JSON. Available keys: lifecycle_stage (lead|trial|customer|churned|win-back), payment (object with plan, mrr_cents, ltv_cents, subscription_status, trial_ends_at, churned_at, paid_at), min_score, max_score, source, subscribed_after, subscribed_before, has_opened, has_clicked, tag, custom_property, exclude_suppressed. Example: {"payment":{"plan":"pro","trial_ends_at":{"within_days":7}},"lifecycle_stage":"trial"}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
30
+ system: 'You translate natural language audience descriptions into a JSON filter rules object. Return ONLY valid JSON. Available keys: match_all, lifecycle_stage (lead|trial|customer|churned|win-back), payment (object with plan, mrr_cents, ltv_cents, subscription_status, trial_ends_at, churned_at, paid_at), min_score, max_score, source, subscribed_after, subscribed_before, has_opened, has_clicked, tag, custom_property (a string like "plan=pro"), exclude_suppressed. Everyone, all contacts, all subscribers or all means {"match_all":true}. There is no email address or email-domain rule. Never invent keys or use custom_property for email targeting; return {"error":"' + EMAIL_DOMAIN_AUDIENCE_MESSAGE + '"} instead. For any other unsupported audience return an error object explaining why. Example: {"payment":{"plan":"pro","trial_ends_at":{"within_days":7}},"lifecycle_stage":"trial"}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
20
31
  messages: [{ role: 'user', content: description }],
21
32
  });
22
33
  const text = anthropicCheckedText(resp);
34
+ let rules;
23
35
  try {
24
- const rules = parseJsonResponse(text);
36
+ rules = parseJsonResponse(text);
25
37
  if (!rules || typeof rules !== 'object' || Array.isArray(rules))
26
38
  throw new Error('not an object');
27
- return rules;
28
39
  }
29
40
  catch {
30
- throw new Error(`Could not understand the audience "${description}". Try a saved segment instead.`);
41
+ throw new AudienceNotUnderstoodError(`Could not understand the audience "${description}". Try a saved segment instead.`);
31
42
  }
43
+ if ('error' in rules)
44
+ throw new AudienceNotUnderstoodError(typeof rules.error === 'string'
45
+ ? rules.error : 'This audience is not supported. Try a saved segment instead.');
46
+ return rules;
32
47
  }
33
48
  export async function generateEmailFromBrief(anthropic, projectId, siteId, brief) {
34
49
  const [{ data: site, error: siteError }, { data: project, error: projectError }] = await Promise.all([
@@ -1,3 +1,4 @@
1
+ import { literalIlikePattern } from '@sendinel/domain';
1
2
  import { isSentRow } from "@sendinel/domain";
2
3
  import { readPeriodCampaigns, campaignPeriodFields, filterPlanContacts } from "@sendinel/domain";
3
4
  import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
@@ -221,9 +222,9 @@ export function registerAnalyticsTools(server) {
221
222
  .order('created_at', { ascending: false })
222
223
  .limit(limit);
223
224
  if (event_name)
224
- query = query.ilike('event_name', `%${event_name}%`);
225
+ query = query.ilike('event_name', literalIlikePattern(event_name));
225
226
  if (email)
226
- query = query.ilike('email', `%${email}%`);
227
+ query = query.ilike('email', literalIlikePattern(email));
227
228
  if (source)
228
229
  query = query.eq('source', source);
229
230
  if (site_id)
@@ -4,7 +4,7 @@ import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } fro
4
4
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
5
5
  import { z } from 'zod';
6
6
  import Anthropic from '@anthropic-ai/sdk';
7
- import { stripActiveEmailContent, assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, } from '@sendinel/domain';
7
+ import { stripActiveEmailContent, assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, isSegmentRulesError, scopeSegmentRulesToSite, SegmentRulesError, validateSegmentRules, validateUnsubFooterTemplate, SEGMENT_MATCH_ALL, } from '@sendinel/domain';
8
8
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
9
9
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
10
10
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -22,7 +22,7 @@ import { fireWebhookEvent } from '../lib/webhook-events.js';
22
22
  import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
23
23
  import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
24
24
  import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
25
- import { generateEmailFromBrief, parseAudienceRules } from '../lib/email-generation.js';
25
+ import { AudienceNotUnderstoodError, generateEmailFromBrief, parseAudienceRules, unsupportedAudienceReason } from '../lib/email-generation.js';
26
26
  import { getAnthropicModelId } from '../anthropic-model.js';
27
27
  import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/domain';
28
28
  import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } from '../lib/schedule.js';
@@ -813,6 +813,16 @@ export function registerCampaignTools(server) {
813
813
  window: z.enum(['today', 'tomorrow', '7d', '30d']).optional().default('today'),
814
814
  },
815
815
  }, async ({ site_id, window }) => {
816
+ const { data: site, error: siteError } = await db()
817
+ .from('sites')
818
+ .select('id')
819
+ .eq('id', site_id)
820
+ .eq('project_id', getProjectId())
821
+ .maybeSingle();
822
+ if (siteError)
823
+ return dbErrorResult(siteError);
824
+ if (!site)
825
+ return toolError({ error: 'Site not found', code: 'not_found' });
816
826
  const { start, end } = getScheduleWindowBounds(window ?? 'today');
817
827
  const { data, error } = await db()
818
828
  .from('campaign_enrollments')
@@ -824,7 +834,7 @@ export function registerCampaignTools(server) {
824
834
  .eq('campaigns.site_id', site_id)
825
835
  .order('next_send_at', { ascending: true });
826
836
  if (error)
827
- return txt(`Error: ${dbToolError(error).error}`);
837
+ return dbErrorResult(error);
828
838
  const campaigns = groupScheduleByCampaign((data ?? []));
829
839
  const total_sends_scheduled = campaigns.reduce((sum, campaign) => sum + campaign.total_sends_scheduled, 0);
830
840
  return j({
@@ -1570,8 +1580,12 @@ export function registerCampaignTools(server) {
1570
1580
  sites: (sites ?? []).map((s) => ({ id: s.id, name: s.name, from_email: s.from_email })),
1571
1581
  });
1572
1582
  }
1583
+ const everyone = audience !== undefined && ['everyone', 'all contacts', 'all subscribers', 'all'].includes(audience.trim().toLowerCase().replace(/[.!?,;:]+$/, '').trim());
1584
+ const unsupported = audience ? unsupportedAudienceReason(audience) : null;
1585
+ if (unsupported)
1586
+ return j({ error: unsupported, code: 'AUDIENCE_NOT_UNDERSTOOD' });
1573
1587
  const apiKey = process.env.ANTHROPIC_API_KEY;
1574
- if ((audience || brief) && !apiKey)
1588
+ if (((audience && !everyone) || brief) && !apiKey)
1575
1589
  return j({ error: 'ANTHROPIC_API_KEY not configured', code: 'AI_NOT_CONFIGURED' });
1576
1590
  const anthropic = apiKey ? new Anthropic({ apiKey }) : null;
1577
1591
  let rules;
@@ -1590,12 +1604,17 @@ export function registerCampaignTools(server) {
1590
1604
  rules = scopeSegmentRulesToSite(segment.rules, segment.site_id);
1591
1605
  segmentName = segment.name;
1592
1606
  }
1607
+ else if (everyone) {
1608
+ rules = { ...SEGMENT_MATCH_ALL };
1609
+ segmentName = audience;
1610
+ }
1593
1611
  else {
1594
1612
  try {
1595
1613
  rules = await parseAudienceRules(anthropic, audience);
1596
1614
  }
1597
1615
  catch (error) {
1598
- return j({ error: error instanceof Error ? dbToolError(error).error : String(error), code: 'AUDIENCE_NOT_UNDERSTOOD' });
1616
+ return j({ error: error instanceof AudienceNotUnderstoodError ? error.message
1617
+ : error instanceof Error ? dbToolError(error).error : String(error), code: 'AUDIENCE_NOT_UNDERSTOOD' });
1599
1618
  }
1600
1619
  segmentName = audience;
1601
1620
  }
@@ -2,7 +2,7 @@ import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
2
2
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
4
4
  import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
5
- import { buildPreviewAttentionEnvelope, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, validateContactPropertyPatch, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
5
+ import { buildPreviewAttentionEnvelope, buildSafeIlikeOrFilter, literalIlikePattern, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, validateContactPropertyPatch, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
6
6
  import { z } from 'zod';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { db, projectClientForWriteback } from '../db.js';
@@ -988,9 +988,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
988
988
  .eq('project_id', projectId)
989
989
  .maybeSingle();
990
990
  if (viewError)
991
- return txt(`Error: ${dbToolError(viewError).error}`);
991
+ return dbErrorResult(viewError);
992
992
  if (!view)
993
- return txt('Error: View not found');
993
+ return toolError({ error: 'View not found', code: 'not_found' });
994
994
  viewFilters = normalizeSubscriberViewFilters(view.filters);
995
995
  }
996
996
  const viewSearch = viewFilters ? subscriberViewFilterString(viewFilters, 'search') : '';
@@ -1008,7 +1008,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1008
1008
  if (viewSiteParam && viewSiteParam !== 'all') {
1009
1009
  const resolved = await resolveSubscriberViewSiteId(projectId, viewSiteParam);
1010
1010
  if (resolved.error)
1011
- return txt(`Error: ${resolved.error}`);
1011
+ return toolError(resolved);
1012
1012
  viewSiteId = resolved.siteId;
1013
1013
  }
1014
1014
  const embedOk = await hasCompaniesEmbed(projectId);
@@ -1021,16 +1021,18 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1021
1021
  .limit(limit ?? 50);
1022
1022
  if (!include_unsubscribed)
1023
1023
  q = q.eq('global_unsubscribed', false);
1024
- if (search)
1025
- q = q.or(`email.ilike.%${search}%,first_name.ilike.%${search}%,last_name.ilike.%${search}%`);
1026
- if (viewSearch)
1027
- q = q.or(`email.ilike.%${viewSearch}%,first_name.ilike.%${viewSearch}%,last_name.ilike.%${viewSearch}%`);
1024
+ const searchFilter = buildSafeIlikeOrFilter(['email', 'first_name', 'last_name'], search);
1025
+ const viewSearchFilter = buildSafeIlikeOrFilter(['email', 'first_name', 'last_name'], viewSearch);
1026
+ if (searchFilter)
1027
+ q = q.or(searchFilter);
1028
+ if (viewSearchFilter)
1029
+ q = q.or(viewSearchFilter);
1028
1030
  if (viewTags.length > 0)
1029
1031
  q = q.contains('tags', viewTags);
1030
1032
  if (viewStage)
1031
1033
  q = q.eq('lifecycle_stage', viewStage);
1032
1034
  if (viewCustomPropertyKey && viewCustomPropertyValue && SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(viewCustomPropertyKey)) {
1033
- q = q.filter(subscriberViewCustomPropertyPath(viewCustomPropertyKey), 'ilike', `%${viewCustomPropertyValue}%`);
1035
+ q = q.filter(subscriberViewCustomPropertyPath(viewCustomPropertyKey), 'ilike', literalIlikePattern(viewCustomPropertyValue));
1034
1036
  }
1035
1037
  if (viewFilters && viewFilters.show_test !== true)
1036
1038
  q = q.not('is_test', 'is', true);
@@ -1054,7 +1056,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1054
1056
  .select('contact_id, subscribed')
1055
1057
  .eq('site_id', statusSiteId);
1056
1058
  if (siteSubscriptionsError)
1057
- return txt(`Error: ${dbToolError(siteSubscriptionsError).error}`);
1059
+ return dbErrorResult(siteSubscriptionsError);
1058
1060
  if (viewStatus === 'not_subscribed') {
1059
1061
  const subscribedIds = (siteSubscriptions ?? [])
1060
1062
  .map((subscription) => subscription.contact_id)
@@ -1086,7 +1088,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1086
1088
  }
1087
1089
  const { data, error } = await q;
1088
1090
  if (error)
1089
- return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
1091
+ return dbErrorResult(error);
1090
1092
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1091
1093
  });
1092
1094
  server.registerTool('list_subscriber_views', {
@@ -1250,12 +1252,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1250
1252
  .eq('project_id', projectId)
1251
1253
  .order('name', { ascending: true })
1252
1254
  .limit(limit ?? 50);
1253
- if (search) {
1254
- query = query.or(`name.ilike.%${search}%,domain.ilike.%${search}%`);
1255
- }
1255
+ const searchFilter = buildSafeIlikeOrFilter(['name', 'domain'], search);
1256
+ if (searchFilter)
1257
+ query = query.or(searchFilter);
1256
1258
  const { data: companies, error } = await query;
1257
1259
  if (error)
1258
- return txt(`Error: ${dbToolError(error).error}`);
1260
+ return dbErrorResult(error);
1259
1261
  const rows = companies ?? [];
1260
1262
  if (!rows.length)
1261
1263
  return j([]);
@@ -1270,7 +1272,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1270
1272
  .order('id')
1271
1273
  .range(offset, offset + 999);
1272
1274
  if (subscriberError)
1273
- return txt(`Error: ${dbToolError(subscriberError).error}`);
1275
+ return dbErrorResult(subscriberError);
1274
1276
  for (const subscriber of subscribers ?? []) {
1275
1277
  const companyId = typeof subscriber.company_id === 'string' ? subscriber.company_id : null;
1276
1278
  if (!companyId)
@@ -1638,7 +1640,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1638
1640
  if (before)
1639
1641
  q = q.lte('sent_at', before);
1640
1642
  if (subject_contains)
1641
- q = q.ilike('subject', `%${subject_contains.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
1643
+ q = q.ilike('subject', literalIlikePattern(subject_contains));
1642
1644
  const { data, error } = await q;
1643
1645
  if (error)
1644
1646
  return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
@@ -3,7 +3,22 @@ import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  import { checkPlanLimit } from '../plan-limits.js';
5
5
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
6
- import { dbToolError } from '../lib/db-error.js';
6
+ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
7
+ // Called only after the conditional write matched no pending draft. Keep the
8
+ // status predicate on the write so concurrent decisions cannot overwrite one another.
9
+ async function draftDecisionMiss(draftId) {
10
+ const { data, error } = await db()
11
+ .from('ai_drafts')
12
+ .select('id')
13
+ .eq('id', draftId)
14
+ .eq('project_id', getProjectId())
15
+ .maybeSingle();
16
+ if (error)
17
+ return dbErrorResult(error);
18
+ return data
19
+ ? toolError({ error: 'Draft is no longer pending', code: 'conflict' })
20
+ : toolError({ error: 'Draft not found', code: 'not_found' });
21
+ }
7
22
  export function registerDraftTools(server) {
8
23
  server.registerTool('create_draft', {
9
24
  title: 'Create Draft',
@@ -230,11 +245,11 @@ export function registerDraftTools(server) {
230
245
  .eq('project_id', getProjectId())
231
246
  .eq('status', 'pending')
232
247
  .select()
233
- .single();
248
+ .maybeSingle();
234
249
  if (error)
235
- return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
250
+ return dbErrorResult(error);
236
251
  if (!data)
237
- return { content: [{ type: 'text', text: 'Draft not found or not in pending status.' }] };
252
+ return draftDecisionMiss(draft_id);
238
253
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
239
254
  });
240
255
  server.registerTool('reject_draft', {
@@ -258,11 +273,11 @@ export function registerDraftTools(server) {
258
273
  .eq('project_id', getProjectId())
259
274
  .eq('status', 'pending')
260
275
  .select()
261
- .single();
276
+ .maybeSingle();
262
277
  if (error)
263
- return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
278
+ return dbErrorResult(error);
264
279
  if (!data)
265
- return { content: [{ type: 'text', text: 'Draft not found or not in pending status.' }] };
280
+ return draftDecisionMiss(draft_id);
266
281
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
267
282
  });
268
283
  }
@@ -1,3 +1,4 @@
1
+ import { literalIlikePattern } from '@sendinel/domain';
1
2
  import { z } from 'zod';
2
3
  import { db } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
@@ -104,7 +105,7 @@ export function registerGdprTools(server) {
104
105
  if (request_type)
105
106
  query = query.eq('request_type', request_type);
106
107
  if (search)
107
- query = query.ilike('email', `%${search}%`);
108
+ query = query.ilike('email', literalIlikePattern(search));
108
109
  const { data, error } = await query
109
110
  .order('created_at', { ascending: false })
110
111
  .limit(limit ?? 50);
@@ -1,3 +1,4 @@
1
+ import { literalIlikePattern } from '@sendinel/domain';
1
2
  import { z } from 'zod';
2
3
  import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
3
4
  import { db } from '../db.js';
@@ -93,7 +94,7 @@ export function registerListTools(server) {
93
94
  .eq('project_id', projectId);
94
95
  const trimmedSearch = search?.trim();
95
96
  if (trimmedSearch)
96
- query = query.ilike('name', `%${trimmedSearch}%`);
97
+ query = query.ilike('name', literalIlikePattern(trimmedSearch));
97
98
  const { data, error } = await query
98
99
  .order('created_at', { ascending: false })
99
100
  .limit(limit);
@@ -1,3 +1,4 @@
1
+ import { literalIlikePattern } from '@sendinel/domain';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { snapshotTemplateVersion, versionHasBodySnapshot } from '@sendinel/domain';
3
4
  import { z } from 'zod';
@@ -89,7 +90,7 @@ export function registerTemplateTools(server) {
89
90
  if (category)
90
91
  query = query.eq('category', category);
91
92
  if (search)
92
- query = query.ilike('name', `%${search}%`);
93
+ query = query.ilike('name', literalIlikePattern(search));
93
94
  const { data, error } = await query;
94
95
  if (error)
95
96
  return j(dbToolError(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.170",
3
+ "version": "1.0.172",
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",