@sendinel/mcp-server 1.0.81 → 1.0.83

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.
@@ -1140,7 +1140,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1140
1140
  idempotentHint: false,
1141
1141
  openWorldHint: false,
1142
1142
  },
1143
- description: 'Add a contact and subscribe them to a site',
1143
+ description: 'Add a contact and subscribe them to a site. Existing contacts keep their source and stored fields; the response includes existing: true.',
1144
1144
  inputSchema: {
1145
1145
  email: z.string().email(),
1146
1146
  site_id: z.string().uuid(),
@@ -1157,6 +1157,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1157
1157
  utm_term: z.string().optional(),
1158
1158
  },
1159
1159
  }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
1160
+ email = email.trim().toLowerCase();
1160
1161
  const projectId = getProjectId();
1161
1162
  const embedOk = await hasCompaniesEmbed(projectId);
1162
1163
  const resolvedCompanyId = await resolveCompanyId(projectId, { company_id, company_name, company_domain });
@@ -1174,23 +1175,30 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1174
1175
  // Check if site requires double opt-in
1175
1176
  const { data: siteInfo } = await db().from('sites').select('double_opt_in').eq('id', site_id).maybeSingle();
1176
1177
  const needsConfirm = siteInfo?.double_opt_in === true;
1177
- const { data: existingContact } = await db()
1178
+ let { data: existingContact, error: lookupError } = await db()
1178
1179
  .from('subscribers')
1179
- .select('id')
1180
+ .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1180
1181
  .eq('project_id', projectId)
1181
1182
  .eq('email', email)
1182
1183
  .maybeSingle();
1183
- const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1184
+ if (lookupError)
1185
+ return txt(`Error looking up contact: ${lookupError.message}`);
1186
+ let customProperties;
1184
1187
  if (company_name || company_domain) {
1185
1188
  const orgId = await getProjectOrgId(projectId);
1186
- row.custom_properties = await normalizeMcpContactPropertyPatch({
1189
+ customProperties = await normalizeMcpContactPropertyPatch({
1187
1190
  ...(company_name ? { company_name } : {}),
1188
1191
  ...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
1189
1192
  }, { orgId, projectId });
1190
1193
  }
1191
- if (!needsConfirm)
1192
- row.opted_in_at = new Date().toISOString();
1194
+ const selection = embedOk ? '*, company:companies(id, name, domain)' : '*';
1195
+ let contact;
1193
1196
  if (!existingContact) {
1197
+ const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1198
+ if (customProperties)
1199
+ row.custom_properties = customProperties;
1200
+ if (!needsConfirm)
1201
+ row.opted_in_at = new Date().toISOString();
1194
1202
  if (utm_source)
1195
1203
  row.utm_source = utm_source;
1196
1204
  if (utm_medium)
@@ -1201,30 +1209,76 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1201
1209
  row.utm_content = utm_content;
1202
1210
  if (utm_term)
1203
1211
  row.utm_term = utm_term;
1212
+ const { data, error } = await db().from('subscribers')
1213
+ .upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
1214
+ .select(selection).maybeSingle();
1215
+ if (error)
1216
+ return txt(`Error creating contact: ${error.message}`);
1217
+ contact = data;
1218
+ if (!contact) {
1219
+ // A concurrent add won the unique key; its provenance belongs to that insert.
1220
+ const result = await db().from('subscribers')
1221
+ .select('id, source, first_name, last_name, company_id, custom_properties, opted_in_at')
1222
+ .eq('project_id', projectId).eq('email', email).single();
1223
+ if (result.error)
1224
+ return txt(`Error looking up contact: ${result.error.message}`);
1225
+ existingContact = result.data;
1226
+ }
1204
1227
  }
1205
- const { data: contact, error: cErr } = await db()
1206
- .from('subscribers')
1207
- .upsert(row, { onConflict: 'project_id,email' })
1208
- .select(embedOk ? '*, company:companies(id, name, domain)' : '*')
1209
- .single();
1210
- if (cErr)
1211
- return { content: [{ type: 'text', text: `Error creating contact: ${cErr.message}` }] };
1228
+ const existing = !!existingContact;
1229
+ if (existingContact) {
1230
+ const empty = (value) => value == null || value === '';
1231
+ const row = {};
1232
+ for (const [key, value] of Object.entries({ first_name, last_name, company_id: resolvedCompanyId })) {
1233
+ if (empty(existingContact[key]) && !empty(value))
1234
+ row[key] = value;
1235
+ }
1236
+ if (!needsConfirm && existingContact.opted_in_at == null)
1237
+ row.opted_in_at = new Date().toISOString();
1238
+ const missingProperties = Object.fromEntries(Object.entries(customProperties ?? {})
1239
+ .filter(([key]) => !Object.hasOwn(existingContact.custom_properties ?? {}, key)));
1240
+ if (Object.keys(missingProperties).length) {
1241
+ row.custom_properties = { ...(existingContact.custom_properties ?? {}), ...missingProperties };
1242
+ }
1243
+ if (Object.keys(row).length) {
1244
+ let update = db().from('subscribers').update(row)
1245
+ .eq('id', existingContact.id).eq('project_id', projectId);
1246
+ // The stored values must still match: a concurrent edit always wins over a re-add.
1247
+ for (const key of Object.keys(row)) {
1248
+ const previous = existingContact[key];
1249
+ update = previous == null ? update.is(key, null)
1250
+ : update.eq(key, key === 'custom_properties' ? JSON.stringify(previous) : previous);
1251
+ }
1252
+ const { error } = await update;
1253
+ if (error)
1254
+ return txt(`Error updating contact: ${error.message}`);
1255
+ }
1256
+ const result = await db().from('subscribers').select(selection)
1257
+ .eq('id', existingContact.id).eq('project_id', projectId).single();
1258
+ if (result.error)
1259
+ return txt(`Error reading contact: ${result.error.message}`);
1260
+ contact = result.data;
1261
+ }
1262
+ const existingMessage = existing ? { message: 'Contact already existed; source and existing fields were not changed.' } : {};
1212
1263
  const { error: subErr } = await db()
1213
1264
  .from('site_subscriptions')
1214
1265
  .upsert({ contact_id: contact.id, site_id, subscribed: !needsConfirm }, { onConflict: 'contact_id,site_id' });
1215
1266
  if (subErr)
1216
1267
  return { content: [{ type: 'text', text: `Contact created but subscription failed: ${subErr.message}` }] };
1268
+ if (existing)
1269
+ logAudit({ action: 'contact.readded', resourceType: 'contact', resourceId: contact.id, details: { email, requested_source: source, existing: true } });
1217
1270
  if (needsConfirm) {
1218
1271
  // Create opt-in token (confirmation email must be sent externally in MCP context)
1219
1272
  await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1220
1273
  await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1221
1274
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1222
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.' }, null, 2) }] };
1275
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1223
1276
  }
1224
1277
  await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1225
1278
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1226
- logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1227
- return { content: [{ type: 'text', text: JSON.stringify(contact, null, 2) }] };
1279
+ if (!existing)
1280
+ logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1281
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
1228
1282
  });
1229
1283
  server.registerTool('import_subscribers', {
1230
1284
  title: 'Import Subscribers',
@@ -4,11 +4,11 @@ export interface GatewayOptions {
4
4
  toolGroups?: string[];
5
5
  }
6
6
  /**
7
- * Plain words people use for a tool, so search_tools finds "send email", "welcome" or
8
- * "last campaign" (#3669). Matched alongside name, group and description.
7
+ * Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
8
+ * share it; re-exported under its original name for compatibility.
9
9
  */
10
10
  export declare const INTENT_ALIASES: Record<string, string[]>;
11
- /** Every word of the query must appear somewhere in the tool's text (name, group, description, aliases). */
11
+ /** True when the tool scores above zero for the query (OR semantics; empty query matches everything). */
12
12
  export declare function matchesToolQuery(entry: {
13
13
  name: string;
14
14
  group: string;
@@ -3,6 +3,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { z } from 'zod';
5
5
  import { getRegisteredToolGroups, registerAllTools } from '../server.js';
6
+ import { rankTools, scoreTool, TOOL_ALIASES, tokenize } from './tool-search.js';
6
7
  const textResult = (text) => ({ content: [{ type: 'text', text }] });
7
8
  const jsonResult = (data) => textResult(JSON.stringify(data, null, 2));
8
9
  const notFound = (message) => ({
@@ -10,28 +11,17 @@ const notFound = (message) => ({
10
11
  content: [{ type: 'text', text: message }],
11
12
  });
12
13
  /**
13
- * Plain words people use for a tool, so search_tools finds "send email", "welcome" or
14
- * "last campaign" (#3669). Matched alongside name, group and description.
14
+ * Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
15
+ * share it; re-exported under its original name for compatibility.
15
16
  */
16
- export const INTENT_ALIASES = {
17
- send_broadcast: ['send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email'],
18
- list_campaigns: ['last campaign', 'latest campaign', 'recent campaign', 'my campaigns'],
19
- get_campaign: ['last campaign', 'campaign details', 'campaign results'],
20
- create_campaign: ['welcome', 'welcome email', 'onboarding', 'drip', 'sequence', 'triggered email'],
21
- add_campaign_step: ['welcome', 'onboarding', 'follow up email', 'next email in sequence'],
22
- };
23
- /** Every word of the query must appear somewhere in the tool's text (name, group, description, aliases). */
17
+ export const INTENT_ALIASES = TOOL_ALIASES;
18
+ /** True when the tool scores above zero for the query (OR semantics; empty query matches everything). */
24
19
  export function matchesToolQuery(entry, query) {
25
- const q = query.trim().toLowerCase();
26
- if (!q)
20
+ if (!query.trim())
27
21
  return true;
28
- const haystack = [entry.name, entry.name.replace(/_/g, ' '), entry.group, entry.description, ...(INTENT_ALIASES[entry.name] ?? [])]
29
- .join(' \n ')
30
- .toLowerCase();
31
- if (haystack.includes(q))
32
- return true;
33
- return q.split(/\s+/).filter(Boolean).every((token) => haystack.includes(token));
22
+ return scoreTool(entry, tokenize(query), query) > 0;
34
23
  }
24
+ const SEARCH_LIMIT = 10;
35
25
  async function buildCatalog(options) {
36
26
  const innerServer = new McpServer({ name: 'sendinel-gateway-inner', version: '1.0.0' });
37
27
  registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups });
@@ -101,14 +91,27 @@ export function registerGatewayTools(server, options) {
101
91
  const catalog = await getCatalog();
102
92
  const q = query?.trim().toLowerCase() ?? '';
103
93
  const requestedGroup = group?.trim();
104
- const results = catalog.entries.filter((tool) => {
105
- if (requestedGroup && tool.group !== requestedGroup)
106
- return false;
107
- return matchesToolQuery(tool, q);
108
- });
94
+ const scoped = catalog.entries.filter((tool) => !requestedGroup || tool.group === requestedGroup);
95
+ const summarize = ({ name, group, description }) => ({ name, group, description });
96
+ if (!q) {
97
+ return jsonResult({ tools: scoped.map(summarize), total: scoped.length });
98
+ }
99
+ const { matches, nearest } = rankTools(scoped, q);
100
+ if (matches.length === 0) {
101
+ return jsonResult({
102
+ tools: [],
103
+ total: 0,
104
+ none_matched: true,
105
+ nearest: nearest.map(({ entry }) => summarize(entry)),
106
+ hint: 'No tool matched that phrasing. The nearest tools are listed; try a shorter keyword, or browse with the group filter.',
107
+ });
108
+ }
109
+ const top = matches.slice(0, SEARCH_LIMIT);
109
110
  return jsonResult({
110
- tools: results.map(({ name, group, description }) => ({ name, group, description })),
111
- total: results.length,
111
+ tools: top.map(({ entry }) => summarize(entry)),
112
+ total: matches.length,
113
+ ranked: true,
114
+ ...(matches.length > top.length ? { truncated: true } : {}),
112
115
  });
113
116
  });
114
117
  server.registerTool('describe_tool', {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Ranked plain-language tool search (#4059). Shared by search_tools today and by how_to later:
3
+ * tokenize -> drop stopwords -> stem simple plurals -> score (OR semantics) -> rank.
4
+ */
5
+ export interface SearchableTool {
6
+ name: string;
7
+ group: string;
8
+ description: string;
9
+ }
10
+ export interface ScoredTool<T extends SearchableTool = SearchableTool> {
11
+ entry: T;
12
+ score: number;
13
+ }
14
+ export interface RankResult<T extends SearchableTool = SearchableTool> {
15
+ /** Every tool with score > 0, best first (not truncated). */
16
+ matches: ScoredTool<T>[];
17
+ /** Closest tools when nothing matched; empty only when `entries` is empty. */
18
+ nearest: ScoredTool<T>[];
19
+ }
20
+ /** Exactly the list from #4059. */
21
+ export declare const STOPWORDS: ReadonlySet<string>;
22
+ /**
23
+ * Plain words people use for a tool (#3669, #4059). One map, shared with how_to's recipe synonyms.
24
+ * Only tools that exist in the registry belong here.
25
+ */
26
+ export declare const TOOL_ALIASES: Record<string, string[]>;
27
+ /** Simple plurals only; leaves the word alone when stripping would leave fewer than 4 characters. */
28
+ export declare function stem(token: string): string;
29
+ /** Lowercase, split, drop stopwords, stem. A query made only of stopwords keeps them so it still searches. */
30
+ export declare function tokenize(query: string): string[];
31
+ /**
32
+ * Tiers are strictly ordered: exact name > alias phrase > per-token hits (name > alias > group >
33
+ * description, best tier per token, summed: OR semantics). 0 means no match.
34
+ */
35
+ export declare function scoreTool(entry: SearchableTool, queryTokens: string[], rawQuery: string, aliases?: Record<string, string[]>): number;
36
+ export declare function rankTools<T extends SearchableTool>(entries: T[], query: string, options?: {
37
+ limit?: number;
38
+ aliases?: Record<string, string[]>;
39
+ nearestLimit?: number;
40
+ }): RankResult<T>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Ranked plain-language tool search (#4059). Shared by search_tools today and by how_to later:
3
+ * tokenize -> drop stopwords -> stem simple plurals -> score (OR semantics) -> rank.
4
+ */
5
+ /** Exactly the list from #4059. */
6
+ export const STOPWORDS = new Set([
7
+ 'how', 'do', 'does', 'i', 'my', 'me', 'a', 'an', 'the', 'to', 'of', 'for',
8
+ 'why', 'can', 'what', 'is', 'did', 'get', 'please',
9
+ ]);
10
+ /**
11
+ * Plain words people use for a tool (#3669, #4059). One map, shared with how_to's recipe synonyms.
12
+ * Only tools that exist in the registry belong here.
13
+ */
14
+ export const TOOL_ALIASES = {
15
+ send_broadcast: [
16
+ 'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
17
+ 'email my list', 'send to my list', 'email to my list', 'email my subscribers',
18
+ ],
19
+ list_campaigns: ['last campaign', 'latest campaign', 'recent campaign', 'my campaigns'],
20
+ get_campaign: [
21
+ 'last campaign', 'campaign details', 'campaign results',
22
+ 'opened', 'opens', 'open rate', 'results', 'stats',
23
+ ],
24
+ get_stats: ['opened', 'opens', 'open rate', 'click rate', 'email stats', 'how many opened', 'performance', 'results'],
25
+ create_campaign: [
26
+ 'welcome', 'welcome email', 'onboarding', 'drip', 'sequence', 'triggered email',
27
+ 'launch', 'welcome sequence', 'new signups', 'signup email',
28
+ ],
29
+ add_campaign_step: [
30
+ 'welcome', 'onboarding', 'follow up email', 'next email in sequence',
31
+ 'welcome sequence', 'new signups', 'launch',
32
+ ],
33
+ diagnose_delivery_issue: [
34
+ 'bounce', 'bounced', 'bounces', 'why did my email bounce', 'not delivered', 'delivery problem',
35
+ 'delivery issue', 'spam folder', 'email not arriving',
36
+ ],
37
+ list_email_log: ['bounce', 'bounced', 'email log', 'delivery status', 'delivery events', 'sent emails'],
38
+ add_subscriber: ['add a contact', 'add contact', 'new contact', 'add person', 'add a person', 'new subscriber'],
39
+ import_subscribers: ['import contacts', 'upload list', 'upload contacts', 'csv', 'import list'],
40
+ };
41
+ /** Simple plurals only; leaves the word alone when stripping would leave fewer than 4 characters. */
42
+ export function stem(token) {
43
+ let out = token;
44
+ if (token.endsWith('ies'))
45
+ out = `${token.slice(0, -3)}y`;
46
+ else if (token.endsWith('sses'))
47
+ out = token.slice(0, -2);
48
+ else if (token.endsWith('s') && !/(ss|us|is)$/.test(token))
49
+ out = token.slice(0, -1);
50
+ return out.length >= 4 ? out : token;
51
+ }
52
+ function splitWords(text) {
53
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
54
+ }
55
+ /** Lowercase, split, drop stopwords, stem. A query made only of stopwords keeps them so it still searches. */
56
+ export function tokenize(query) {
57
+ const words = splitWords(query);
58
+ const kept = words.filter((w) => !STOPWORDS.has(w));
59
+ return (kept.length > 0 ? kept : words).map(stem);
60
+ }
61
+ const normalizePhrase = (text) => ` ${splitWords(text).join(' ')} `;
62
+ const SCORE_EXACT_NAME = 100;
63
+ const SCORE_ALIAS_PHRASE = 60;
64
+ const SCORE_NAME_TOKEN = 10;
65
+ const SCORE_ALIAS_TOKEN = 8;
66
+ const SCORE_GROUP_TOKEN = 3;
67
+ const SCORE_DESCRIPTION_TOKEN = 2;
68
+ function toolTokens(entry, aliases) {
69
+ return {
70
+ name: new Set(splitWords(entry.name).map(stem)),
71
+ alias: new Set((aliases[entry.name] ?? []).flatMap((a) => splitWords(a)).map(stem)),
72
+ group: new Set(splitWords(entry.group).map(stem)),
73
+ description: new Set(splitWords(entry.description).filter((w) => !STOPWORDS.has(w)).map(stem)),
74
+ };
75
+ }
76
+ /**
77
+ * Tiers are strictly ordered: exact name > alias phrase > per-token hits (name > alias > group >
78
+ * description, best tier per token, summed: OR semantics). 0 means no match.
79
+ */
80
+ export function scoreTool(entry, queryTokens, rawQuery, aliases = TOOL_ALIASES) {
81
+ const normalized = normalizePhrase(rawQuery);
82
+ if (normalized.trim() === '')
83
+ return 0;
84
+ if (normalized.trim().replace(/ /g, '_') === entry.name)
85
+ return SCORE_EXACT_NAME;
86
+ let score = 0;
87
+ if ((aliases[entry.name] ?? []).some((phrase) => normalized.includes(normalizePhrase(phrase)))) {
88
+ score += SCORE_ALIAS_PHRASE;
89
+ }
90
+ const tokens = toolTokens(entry, aliases);
91
+ for (const token of new Set(queryTokens)) {
92
+ if (tokens.name.has(token))
93
+ score += SCORE_NAME_TOKEN;
94
+ else if (tokens.alias.has(token))
95
+ score += SCORE_ALIAS_TOKEN;
96
+ else if (tokens.group.has(token))
97
+ score += SCORE_GROUP_TOKEN;
98
+ else if (tokens.description.has(token))
99
+ score += SCORE_DESCRIPTION_TOKEN;
100
+ }
101
+ return score;
102
+ }
103
+ /** Prefix-match fallback (>= 3 chars) used only to pick "nearest" tools when nothing scored. */
104
+ function fuzzyScore(entry, queryTokens, aliases) {
105
+ const tokens = toolTokens(entry, aliases);
106
+ const pools = [tokens.name, tokens.alias, tokens.group, tokens.description];
107
+ let score = 0;
108
+ for (const token of new Set(queryTokens)) {
109
+ if (token.length < 3)
110
+ continue;
111
+ for (const pool of pools) {
112
+ if ([...pool].some((w) => w.startsWith(token) || (w.length >= 3 && token.startsWith(w)))) {
113
+ score += 1;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ return score;
119
+ }
120
+ const byScoreThenName = (a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name);
121
+ export function rankTools(entries, query, options = {}) {
122
+ const aliases = options.aliases ?? TOOL_ALIASES;
123
+ const nearestLimit = options.nearestLimit ?? 5;
124
+ const queryTokens = tokenize(query);
125
+ const matches = entries
126
+ .map((entry) => ({ entry, score: scoreTool(entry, queryTokens, query, aliases) }))
127
+ .filter((s) => s.score > 0)
128
+ .sort(byScoreThenName);
129
+ if (matches.length > 0) {
130
+ return { matches: options.limit ? matches.slice(0, options.limit) : matches, nearest: [] };
131
+ }
132
+ const fuzzy = entries
133
+ .map((entry) => ({ entry, score: fuzzyScore(entry, queryTokens, aliases) }))
134
+ .filter((s) => s.score > 0)
135
+ .sort(byScoreThenName);
136
+ const nearest = fuzzy.length > 0
137
+ ? fuzzy
138
+ : entries.slice(0, nearestLimit).map((entry) => ({ entry, score: 0 }));
139
+ return { matches: [], nearest: nearest.slice(0, nearestLimit) };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.81",
3
+ "version": "1.0.83",
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",