@sendinel/mcp-server 1.0.169 → 1.0.171

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,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)
@@ -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({
@@ -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}` }] };
@@ -2035,10 +2037,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2035
2037
  else if (email)
2036
2038
  q = q.eq('email', email);
2037
2039
  else
2038
- return txt('Provide either contact_id or email.');
2040
+ return toolError({ error: 'Provide either contact_id or email.', code: 'validation_failed' });
2039
2041
  const { data, error } = await q.maybeSingle();
2040
2042
  if (error)
2041
2043
  return txt(`Error: ${dbToolError(error).error}`);
2044
+ if (!data)
2045
+ return toolError({ error: 'Subscriber not found', code: 'not_found' });
2042
2046
  return j(data);
2043
2047
  });
2044
2048
  server.registerTool('enrich_subscriber', {
@@ -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.169",
3
+ "version": "1.0.171",
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",