@sendinel/mcp-server 1.0.144 → 1.0.146

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.
@@ -0,0 +1,10 @@
1
+ /** Normalize input only: preserve first-seen spelling for exact-match consumers. */
2
+ export declare function normalizeTagInput(input: string[]): {
3
+ tags: string[];
4
+ dropped_empty: number;
5
+ case_duplicates: {
6
+ kept: string;
7
+ dropped: string;
8
+ }[];
9
+ };
10
+ export declare function tagInputWarnings(result: ReturnType<typeof normalizeTagInput>): string[];
@@ -0,0 +1,29 @@
1
+ /** Normalize input only: preserve first-seen spelling for exact-match consumers. */
2
+ export function normalizeTagInput(input) {
3
+ const tags = [];
4
+ const seen = new Map();
5
+ let dropped_empty = 0;
6
+ const case_duplicates = [];
7
+ for (const value of input) {
8
+ const tag = value.trim();
9
+ if (!tag) {
10
+ dropped_empty++;
11
+ continue;
12
+ }
13
+ const key = tag.toLowerCase();
14
+ const kept = seen.get(key);
15
+ if (kept !== undefined) {
16
+ case_duplicates.push({ kept, dropped: tag });
17
+ continue;
18
+ }
19
+ seen.set(key, tag);
20
+ tags.push(tag);
21
+ }
22
+ return { tags, dropped_empty, case_duplicates };
23
+ }
24
+ export function tagInputWarnings(result) {
25
+ return [
26
+ ...(result.dropped_empty ? [`Dropped ${result.dropped_empty} empty tag(s).`] : []),
27
+ ...result.case_duplicates.map(({ kept, dropped }) => `Tag "${dropped}" duplicates "${kept}" (case-insensitive); kept "${kept}".`),
28
+ ];
29
+ }
@@ -92,7 +92,11 @@ export function buildCampaignFiresOn(campaign) {
92
92
  parts.push(`event${events.length > 1 ? 's' : ''} ${events.map((e) => `"${e}"`).join(', ')} sent to /v1/events`);
93
93
  if (tags.length)
94
94
  parts.push(`tag${tags.length > 1 ? 's' : ''} ${tags.map((t) => `"${t}"`).join(', ')} added`);
95
- if (campaign.segment_id)
95
+ if (campaign.type === 'broadcast') {
96
+ parts.length = 0;
97
+ parts.push(campaign.send_at ? `the scheduled send time (${campaign.send_at})` : 'nothing scheduled (no send_at set)');
98
+ }
99
+ else if (campaign.segment_id)
96
100
  parts.push('contacts entering its segment');
97
101
  let summary = parts.length ? `Fires on: ${parts.join('; ')}.` : 'Fires on: nothing (no trigger set).';
98
102
  const warnings = [];
@@ -731,7 +735,7 @@ export function registerCampaignTools(server) {
731
735
  }, async ({ site_id, status, limit, period }) => {
732
736
  let q = db()
733
737
  .from('campaigns')
734
- .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, unsub_footer_template, requires_approval, approved_by, approved_at')
738
+ .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, unsub_footer_template, requires_approval, approved_by, approved_at, send_at')
735
739
  .eq('project_id', getProjectId())
736
740
  .order('created_at', { ascending: false })
737
741
  .limit(limit ?? 25);
@@ -2956,14 +2960,16 @@ Do not include body_html in your response; Sendinel renders body_html from block
2956
2960
  }, async ({ html, site_id, custom_variables, subject, body_text, trigger_event_name, event_keys }) => {
2957
2961
  const projectId = getProjectId();
2958
2962
  let voiceConfig = null;
2963
+ let autoUnsubscribeFooter = true;
2959
2964
  if (site_id) {
2960
2965
  const { data: site } = await db()
2961
2966
  .from('sites')
2962
- .select('brand_voice')
2967
+ .select('brand_voice, unsub_link_enabled')
2963
2968
  .eq('id', site_id)
2964
2969
  .eq('project_id', projectId)
2965
2970
  .maybeSingle();
2966
2971
  voiceConfig = site?.brand_voice ?? null;
2972
+ autoUnsubscribeFooter = site?.unsub_link_enabled !== false;
2967
2973
  }
2968
2974
  // The brand kit's sign-off name applies to every site in the project (#3854).
2969
2975
  const { data: projectRow } = await db().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
@@ -2973,16 +2979,19 @@ Do not include body_html in your response; Sendinel renders body_html from block
2973
2979
  .filter((row) => !site_id || row.id === site_id)
2974
2980
  .flatMap((row) => [sendingHostOf(row.sending_domain), sendingHostOf(row.from_email)])
2975
2981
  .filter((host) => Boolean(host));
2982
+ const contacts = await contactPropertyKeys(projectId);
2983
+ const { data: definitions } = await db().from('custom_field_definitions').select('key').eq('project_id', projectId);
2984
+ const contactVariables = [...(contacts?.keys ?? []), ...(definitions ?? []).map((field) => field.key)].map((key) => `custom.${key}`);
2976
2985
  let sourcedKeys;
2977
2986
  if (trigger_event_name || event_keys) {
2978
2987
  const fromHistory = trigger_event_name ? await eventKeysFromHistory(projectId, trigger_event_name) : [];
2979
- const contacts = await contactPropertyKeys(projectId);
2980
2988
  sourcedKeys = fromHistory === null || contacts === null
2981
2989
  ? null
2982
- : [...(event_keys ?? []), ...fromHistory, ...contacts.keys.map((key) => `custom.${key}`)];
2990
+ : [...(event_keys ?? []), ...fromHistory, ...contactVariables];
2983
2991
  }
2984
2992
  const result = validateTemplate(html, voiceConfig, {
2985
- customVariables: [...(custom_variables ?? []), ...(sourcedKeys ?? [])],
2993
+ customVariables: [...(custom_variables ?? []), ...contactVariables, ...(sourcedKeys ?? [])],
2994
+ autoUnsubscribeFooter,
2986
2995
  subject,
2987
2996
  bodyText: body_text,
2988
2997
  brandSettings: projectRow?.brand_settings ?? null,
@@ -3004,7 +3013,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
3004
3013
  else if (!result.errors.some((error) => error.includes(issue.excerpt)))
3005
3014
  result.warnings.push(issue.message);
3006
3015
  }
3007
- return j({ ...result, valid: result.errors.length === 0 });
3016
+ return j({ ...result, valid: result.errors.length === 0, ...(result.errors.length ? { code: 'validation_failed' } : {}) });
3008
3017
  });
3009
3018
  server.registerTool('check_content_consistency', {
3010
3019
  title: 'Check Content Consistency',
@@ -1,3 +1,4 @@
1
+ import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
1
2
  import { dbErrorResult } from '../lib/db-errors.js';
2
3
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
@@ -493,8 +494,8 @@ function primaryContactValue(value) {
493
494
  }
494
495
  // #3623: last_transactional_at / total_transactional_sent are service (transactional) mail, tracked apart
495
496
  // from the marketing last_emailed_at / total_emails_sent. NULL means none recorded, not "never".
496
- const SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage';
497
- const SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage';
497
+ const SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage, tags';
498
+ const SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage, tags';
498
499
  const SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
499
500
  const SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
500
501
  const UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), custom_properties, tags, phone_e164, timezone';
@@ -962,7 +963,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
962
963
  idempotentHint: true,
963
964
  openWorldHint: false,
964
965
  },
965
- description: 'List contacts, optionally filtered by site, search term, and/or a saved subscriber view. Explicit arguments and saved-view filters are combined to narrow the result.',
966
+ description: 'List contacts including tags, optionally filtered by site, search term, and/or a saved subscriber view. Explicit arguments and saved-view filters are combined to narrow the result.',
966
967
  inputSchema: {
967
968
  site_id: z.string().uuid().optional(),
968
969
  company_id: z.string().uuid().optional(),
@@ -1299,6 +1300,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1299
1300
  company_id: z.string().uuid().optional(),
1300
1301
  company_name: z.string().optional(),
1301
1302
  company_domain: z.string().optional(),
1303
+ tags: z.array(z.string()).optional().describe('Initial tags for new subscribers; existing contacts keep their stored tags.'),
1302
1304
  source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
1303
1305
  utm_source: z.string().optional(),
1304
1306
  utm_medium: z.string().optional(),
@@ -1306,8 +1308,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1306
1308
  utm_content: z.string().optional(),
1307
1309
  utm_term: z.string().optional(),
1308
1310
  },
1309
- }, 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 }) => {
1311
+ }, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
1310
1312
  try {
1313
+ const normalizedTags = normalizeTagInput(tags ?? []);
1314
+ const warnings = tagInputWarnings(normalizedTags);
1315
+ const tagWarnings = warnings.length ? { warnings } : {};
1311
1316
  email = email.trim().toLowerCase();
1312
1317
  const projectId = getProjectId();
1313
1318
  // Block distribution list / system addresses
@@ -1351,6 +1356,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1351
1356
  let contact;
1352
1357
  if (!existingContact) {
1353
1358
  const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
1359
+ if (tags !== undefined)
1360
+ row.tags = normalizedTags.tags;
1354
1361
  if (customProperties)
1355
1362
  row.custom_properties = customProperties;
1356
1363
  if (!needsConfirm)
@@ -1426,15 +1433,15 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1426
1433
  if (needsConfirm) {
1427
1434
  // Create opt-in token (confirmation email must be sent externally in MCP context)
1428
1435
  await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
1429
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1436
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
1430
1437
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
1431
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1438
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
1432
1439
  }
1433
- await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null });
1440
+ await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
1434
1441
  logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
1435
1442
  if (!existing)
1436
1443
  logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
1437
- return { content: [{ type: 'text', text: JSON.stringify({ ...contact, existing, ...existingMessage }, null, 2) }] };
1444
+ return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, ...existingMessage }, null, 2) }] };
1438
1445
  }
1439
1446
  catch (error) {
1440
1447
  return dbErrorResult(error);
@@ -1815,6 +1822,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1815
1822
  }, async ({ contact_id, email, first_name, last_name, company_id, company_name, company_domain, tags, custom_properties, phone_e164, timezone }) => {
1816
1823
  try {
1817
1824
  const projectId = getProjectId();
1825
+ const normalizedTags = tags !== undefined ? normalizeTagInput(tags) : undefined;
1826
+ if (normalizedTags)
1827
+ tags = normalizedTags.tags;
1818
1828
  let propertyWarnings = [];
1819
1829
  let fields;
1820
1830
  if (custom_properties !== undefined) {
@@ -2000,7 +2010,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2000
2010
  ...(updates.tags !== undefined ? { tags: updates.tags } : {}),
2001
2011
  ...(updates.custom_properties !== undefined ? { custom_properties: updates.custom_properties } : {}),
2002
2012
  });
2003
- const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings];
2013
+ const mergedWarnings = [...(tagResult.warnings ?? []), ...propertyWarnings, ...(normalizedTags ? tagInputWarnings(normalizedTags) : [])];
2004
2014
  return j({ ...data, ...tagResult, ...(mergedWarnings.length ? { warnings: mergedWarnings } : {}) });
2005
2015
  }
2006
2016
  catch (error) {
@@ -2136,57 +2146,78 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2136
2146
  idempotentHint: true,
2137
2147
  openWorldHint: false,
2138
2148
  },
2139
- description: 'Add or remove tags from one or more contacts',
2149
+ description: 'Add or remove tags from one or more contacts. Tags are trimmed; empty values are ignored; case variants of an existing tag are not added again. Removal is case-insensitive. Ids not found are returned in not_found.',
2140
2150
  inputSchema: {
2141
2151
  contact_ids: z.array(z.string().uuid()).min(1),
2142
2152
  add_tags: z.array(z.string()).optional(),
2143
2153
  remove_tags: z.array(z.string()).optional(),
2144
2154
  },
2145
2155
  }, async ({ contact_ids, add_tags = [], remove_tags = [] }) => {
2156
+ const normalized = normalizeTagInput(add_tags);
2157
+ add_tags = normalized.tags;
2158
+ remove_tags = remove_tags.map(tag => tag.trim()).filter(Boolean);
2159
+ const warnings = new Set(tagInputWarnings(normalized));
2146
2160
  if (!add_tags.length && !remove_tags.length)
2147
2161
  return toolError({ error: 'No tags to add or remove.', code: 'validation_failed' });
2148
2162
  const projectId = getProjectId();
2149
2163
  let updated = 0;
2150
2164
  const missing_contact_ids = [];
2165
+ const failed = [];
2151
2166
  for (const cid of contact_ids) {
2152
- const { data: contact, error: readError } = await db()
2153
- .from('subscribers')
2154
- .select('id, email, first_name, last_name, custom_properties, tags')
2155
- .eq('id', cid)
2156
- .eq('project_id', projectId)
2157
- .maybeSingle();
2158
- if (readError)
2159
- return toolError({ ...dbToolError(readError), updated });
2160
- if (!contact) {
2161
- missing_contact_ids.push(cid);
2162
- continue;
2163
- }
2164
- const currentTags = contact.tags ?? [];
2165
- const removeSet = new Set(remove_tags);
2166
- const merged = currentTags.filter((t) => !removeSet.has(t));
2167
- for (const t of add_tags) {
2168
- if (!merged.includes(t))
2169
- merged.push(t);
2167
+ try {
2168
+ const { data: contact, error: readError } = await db()
2169
+ .from('subscribers')
2170
+ .select('id, email, first_name, last_name, custom_properties, tags')
2171
+ .eq('id', cid)
2172
+ .eq('project_id', projectId)
2173
+ .maybeSingle();
2174
+ if (readError) {
2175
+ failed.push({ id: cid, ...dbToolError(readError) });
2176
+ continue;
2177
+ }
2178
+ if (!contact) {
2179
+ missing_contact_ids.push(cid);
2180
+ continue;
2181
+ }
2182
+ const currentTags = contact.tags ?? [];
2183
+ const removeSet = new Set(remove_tags.map(tag => tag.toLowerCase()));
2184
+ const merged = currentTags.filter((t) => !removeSet.has(t.trim().toLowerCase()));
2185
+ for (const tag of add_tags) {
2186
+ const existing = merged.find(value => value.trim().toLowerCase() === tag.toLowerCase());
2187
+ if (existing !== undefined)
2188
+ warnings.add(`${tag} already exists as ${existing}`);
2189
+ else
2190
+ merged.push(tag);
2191
+ }
2192
+ const { data: saved, error } = await db()
2193
+ .from('subscribers')
2194
+ .update({ tags: merged })
2195
+ .eq('id', cid)
2196
+ .eq('project_id', projectId)
2197
+ .select('id').maybeSingle();
2198
+ if (error) {
2199
+ failed.push({ id: cid, ...dbToolError(error) });
2200
+ continue;
2201
+ }
2202
+ if (!saved) {
2203
+ missing_contact_ids.push(cid);
2204
+ continue;
2205
+ }
2206
+ updated++;
2207
+ await writeBackMcpContact(projectId, contact, { tags: merged });
2208
+ await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
2170
2209
  }
2171
- const { data: saved, error } = await db()
2172
- .from('subscribers')
2173
- .update({ tags: merged })
2174
- .eq('id', cid)
2175
- .eq('project_id', projectId)
2176
- .select('id').maybeSingle();
2177
- if (error)
2178
- return toolError({ ...dbToolError(error), updated });
2179
- if (!saved) {
2180
- missing_contact_ids.push(cid);
2181
- continue;
2210
+ catch {
2211
+ failed.push({ id: cid, ...dbToolError({}) });
2182
2212
  }
2183
- updated++;
2184
- await writeBackMcpContact(projectId, contact, { tags: merged });
2185
- await logAudit({ action: 'subscriber.tags_updated', resourceType: 'subscriber', resourceId: cid, details: { add_tags, remove_tags } });
2186
2213
  }
2214
+ const result = { missing_contact_ids, not_found: missing_contact_ids, failed, updated, total: contact_ids.length, add_tags, remove_tags, ...(warnings.size ? { warnings: [...warnings] } : {}) };
2215
+ // Preserve the existing coded-error convention when no requested write succeeded.
2216
+ if (!updated && failed.length)
2217
+ return toolError({ ...dbToolError({}), ...result, code: failed[0].code, error: failed[0].error });
2187
2218
  if (!updated)
2188
- return toolError({ error: 'Subscriber not found', code: 'not_found', updated, missing_contact_ids });
2189
- return j({ missing_contact_ids, updated, total: contact_ids.length, add_tags, remove_tags });
2219
+ return toolError({ error: 'Subscriber not found', code: 'not_found', ...result });
2220
+ return j(result);
2190
2221
  });
2191
2222
  server.registerTool('bulk_update_subscribers', {
2192
2223
  title: 'Bulk Update Subscribers',
@@ -7,9 +7,9 @@ import { getRegisteredToolGroups, registerAllTools } from '../server.js';
7
7
  import { rankTools, scoreTool, TOOL_ALIASES, tokenize } from './tool-search.js';
8
8
  const textResult = (text) => ({ content: [{ type: 'text', text }] });
9
9
  const jsonResult = (data) => textResult(JSON.stringify(data, null, 2));
10
- const notFound = (message) => ({
10
+ const notFound = (message, code = 'not_found') => ({
11
11
  isError: true,
12
- content: [{ type: 'text', text: message }],
12
+ ...jsonResult({ error: message, code }),
13
13
  });
14
14
  /**
15
15
  * Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
@@ -49,7 +49,7 @@ async function buildCatalog(options) {
49
49
  function unknownKeyMessage(keys) {
50
50
  const list = keys.map((k) => `'${k}'`).join(', ');
51
51
  const hinted = keys.some((k) => ARGS_LOOKALIKES.has(k.toLowerCase()));
52
- return `Unknown key(s) on invoke_tool: ${list}${hinted ? "; did you mean 'args'?" : ''} Valid keys: name, args (alias: arguments).`;
52
+ return `Unknown key(s) on invoke_tool: ${list}${hinted ? "; did you mean 'args'?" : ''} Valid keys: name, args (alias: arguments), project_id.`;
53
53
  }
54
54
  const ARGS_LOOKALIKES = new Set(['args', 'argument', 'params', 'input', 'parameters']);
55
55
  function declaredProperties(inputSchema) {
@@ -83,20 +83,28 @@ export function registerGatewayTools(server, options) {
83
83
  const getCatalog = () => (catalogPromise ??= buildCatalog(options));
84
84
  server.registerTool('search_tools', {
85
85
  title: 'Search Tools',
86
- description: 'Search the permitted Sendinel MCP tool catalog by keyword and/or group. Returns name, group, and description. Call describe_tool for the full input schema before invoke_tool.',
86
+ description: 'Search the permitted Sendinel MCP tool catalog by keyword and/or group. Returns name, group, and description. Optional limit/offset page ranked search results or explicit browsing. Call describe_tool for the full input schema before invoke_tool.',
87
87
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
88
88
  inputSchema: {
89
89
  query: z.string().optional().describe('Words to match against tool name, group, description, or common phrasing (for example "send email")'),
90
90
  group: z.string().optional().describe('Restrict results to one tool group, for example campaigns'),
91
+ limit: z.number().int().min(1).max(50).optional().describe('Page size (1–50); ranked search defaults to 10, browsing stays uncapped unless paging is supplied'),
92
+ offset: z.number().int().min(0).optional().describe('Zero-based result offset (default 0)'),
91
93
  },
92
- }, async ({ query, group }) => {
94
+ }, async ({ query, group, limit, offset }) => {
93
95
  const catalog = await getCatalog();
94
96
  const q = query?.trim().toLowerCase() ?? '';
95
97
  const requestedGroup = group?.trim();
96
98
  const scoped = catalog.entries.filter((tool) => !requestedGroup || tool.group === requestedGroup);
97
99
  const summarize = ({ name, group, description }) => ({ name, group, description });
100
+ const start = offset ?? 0;
101
+ const pageSize = limit ?? SEARCH_LIMIT;
98
102
  if (!q) {
99
- return jsonResult({ tools: scoped.map(summarize), total: scoped.length });
103
+ if (limit === undefined && offset === undefined)
104
+ return jsonResult({ tools: scoped.map(summarize), total: scoped.length });
105
+ const page = scoped.slice(start, start + pageSize);
106
+ const truncated = start + page.length < scoped.length;
107
+ return jsonResult({ tools: page.map(summarize), total: scoped.length, truncated, ...(truncated ? { next_offset: start + page.length } : {}) });
100
108
  }
101
109
  const { matches, nearest } = rankTools(scoped, q);
102
110
  if (matches.length === 0) {
@@ -108,12 +116,14 @@ export function registerGatewayTools(server, options) {
108
116
  hint: 'No tool matched that phrasing. The nearest tools are listed; try a shorter keyword, or browse with the group filter.',
109
117
  });
110
118
  }
111
- const top = matches.slice(0, SEARCH_LIMIT);
119
+ const top = matches.slice(start, start + pageSize);
120
+ const truncated = start + top.length < matches.length;
112
121
  return jsonResult({
113
122
  tools: top.map(({ entry }) => summarize(entry)),
114
123
  total: matches.length,
115
124
  ranked: true,
116
- ...(matches.length > top.length ? { truncated: true } : {}),
125
+ truncated,
126
+ ...(truncated ? { next_offset: start + top.length } : {}),
117
127
  });
118
128
  });
119
129
  server.registerTool('describe_tool', {
@@ -139,15 +149,16 @@ export function registerGatewayTools(server, options) {
139
149
  name: z.string().min(1).describe('Tool name to invoke'),
140
150
  args: z.record(z.string(), z.unknown()).optional().describe('Arguments for the target tool'),
141
151
  arguments: z.record(z.string(), z.unknown()).optional().describe('Alias for args (the MCP-standard field name). Provide args or arguments, not both.'),
152
+ project_id: z.string().uuid().optional().describe('Per-call project override, same as on any tool. The hosted transport selects and strips it before invocation; direct stdio calls use the configured project.'),
142
153
  }),
143
154
  }, async (input) => {
144
- const { name, args, arguments: argumentsAlias, ...rest } = input;
155
+ const { name, args, arguments: argumentsAlias, project_id: _projectId, ...rest } = input;
145
156
  const unknownKeys = Object.keys(rest);
146
157
  if (unknownKeys.length > 0) {
147
- return notFound(unknownKeyMessage(unknownKeys));
158
+ return notFound(unknownKeyMessage(unknownKeys), 'validation_failed');
148
159
  }
149
160
  if (args !== undefined && argumentsAlias !== undefined && JSON.stringify(args) !== JSON.stringify(argumentsAlias)) {
150
- return notFound("Both 'args' and 'arguments' were provided with different values. Provide either 'args' or 'arguments', not both.");
161
+ return notFound("Both 'args' and 'arguments' were provided with different values. Provide either 'args' or 'arguments', not both.", 'validation_failed');
151
162
  }
152
163
  const effective = args ?? argumentsAlias ?? {};
153
164
  const catalog = await getCatalog();
@@ -157,7 +168,7 @@ export function registerGatewayTools(server, options) {
157
168
  }
158
169
  const unknownArgs = findUnknownArgKeys(entry.inputSchema, effective);
159
170
  if (unknownArgs.length > 0) {
160
- return notFound(unknownArgsMessage(name, entry.inputSchema, unknownArgs));
171
+ return notFound(unknownArgsMessage(name, entry.inputSchema, unknownArgs), 'validation_failed');
161
172
  }
162
173
  return await catalog.client.callTool({ name, arguments: effective });
163
174
  });
@@ -297,7 +297,7 @@ export function registerSegmentTools(server) {
297
297
  model: getAnthropicModelId(),
298
298
  max_tokens: AI_MAX_TOKENS,
299
299
  ...outputConfig("low"),
300
- system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at, email_sends. email_sends uses eq only and value {"min_count":4,"within_days":30}; both required, at most one per segment. It counts email_log rows with non-null sent_at in the inclusive elapsed-day window through now, regardless of current status. No observed sends = zero; failed history reads are errors. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
300
+ system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at, email_sends. email_sends uses eq only and value {"min_count":4,"within_days":30}; both required, at most one per segment. It counts email_log rows with non-null sent_at in the inclusive elapsed-day window through now, regardless of current status. No observed sends = zero; failed history reads are errors. "tagged A and B AND never opened" means {"op":"AND","conditions":[{"field":"tags","operator":"contains","value":"A"},{"field":"tags","operator":"contains","value":"B"},{"field":"last_opened_at","operator":"not_exists"}],"exclude_suppressed":true}. Use one tags contains condition per required tag; never opened means last_opened_at not_exists (no value), not an unsupported activity field. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
301
301
  messages: [{ role: 'user', content: description }],
302
302
  });
303
303
  const raw = anthropicCheckedText(resp);
@@ -308,8 +308,8 @@ export function registerSegmentTools(server) {
308
308
  catch (parseError) {
309
309
  // #4270: parsed rules that are missing or malformed are an error, never "everyone".
310
310
  if (isSegmentRulesError(parseError))
311
- return txt(`Error: Could not use the rules parsed from "${description}". ${parseError.message}`);
312
- return txt(`Error: Failed to parse rules from "${description}"`);
311
+ return toolError({ error: `Could not use the rules parsed from "${description}". ${parseError.message}`, code: 'validation_failed' });
312
+ return toolError({ error: `Failed to parse rules from "${description}"`, code: 'validation_failed' });
313
313
  }
314
314
  const ids = await getSegmentContactIds(parsedRules);
315
315
  const sample = ids.slice(0, 5);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.144",
3
+ "version": "1.0.146",
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",