@sendinel/mcp-server 1.0.144 → 1.0.145
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/build/tools/campaigns.js +16 -7
- package/build/tools/contacts.js +3 -3
- package/build/tools/gateway.js +23 -12
- package/build/tools/segments.js +3 -3
- package/package.json +1 -1
package/build/tools/campaigns.js
CHANGED
|
@@ -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.
|
|
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, ...
|
|
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',
|
package/build/tools/contacts.js
CHANGED
|
@@ -493,8 +493,8 @@ function primaryContactValue(value) {
|
|
|
493
493
|
}
|
|
494
494
|
// #3623: last_transactional_at / total_transactional_sent are service (transactional) mail, tracked apart
|
|
495
495
|
// 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';
|
|
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, tags';
|
|
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, tags';
|
|
498
498
|
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
499
|
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
500
|
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 +962,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
962
962
|
idempotentHint: true,
|
|
963
963
|
openWorldHint: false,
|
|
964
964
|
},
|
|
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.',
|
|
965
|
+
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
966
|
inputSchema: {
|
|
967
967
|
site_id: z.string().uuid().optional(),
|
|
968
968
|
company_id: z.string().uuid().optional(),
|
package/build/tools/gateway.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
});
|
package/build/tools/segments.js
CHANGED
|
@@ -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
|
|
312
|
-
return
|
|
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.
|
|
3
|
+
"version": "1.0.145",
|
|
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",
|