@sendinel/mcp-server 1.0.230 → 1.0.231
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/analytics.js +32 -8
- package/build/tools/campaigns.js +9 -3
- package/package.json +1 -1
package/build/tools/analytics.js
CHANGED
|
@@ -198,6 +198,27 @@ function buildTrueConversionAnalytics(params) {
|
|
|
198
198
|
.sort((a, b) => b.count - a.count || b.revenue - a.revenue),
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* #4838: send allowlist visibility without exposing addresses. NULL = no restriction (inactive);
|
|
203
|
+
* an array (even empty) is active and recipients must match an exact address on it. Anything else
|
|
204
|
+
* (row or column not loaded) is unknown, reported as null, never as "inactive": the send gate fails
|
|
205
|
+
* closed on an unreadable list, so this must not claim there is none.
|
|
206
|
+
*/
|
|
207
|
+
function sendAllowlistSummary(list) {
|
|
208
|
+
if (Array.isArray(list))
|
|
209
|
+
return { active: true, count: list.length };
|
|
210
|
+
return list === null ? { active: false, count: null } : { active: null, count: null };
|
|
211
|
+
}
|
|
212
|
+
function sendAllowlistFields(projectList, orgList) {
|
|
213
|
+
const project = sendAllowlistSummary(projectList);
|
|
214
|
+
const org = sendAllowlistSummary(orgList);
|
|
215
|
+
return {
|
|
216
|
+
send_allowlist_active: project.active,
|
|
217
|
+
send_allowlist_count: project.count,
|
|
218
|
+
org_send_allowlist_active: org.active,
|
|
219
|
+
org_send_allowlist_count: org.count,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
201
222
|
export function registerAnalyticsTools(server) {
|
|
202
223
|
server.registerTool('list_event_log', {
|
|
203
224
|
title: 'List Event Log',
|
|
@@ -273,7 +294,7 @@ export function registerAnalyticsTools(server) {
|
|
|
273
294
|
idempotentHint: true,
|
|
274
295
|
openWorldHint: false,
|
|
275
296
|
},
|
|
276
|
-
description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account.',
|
|
297
|
+
description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account. Each project also shows its send limits: sandbox_allowed_domains (recipient domains allowed while sandbox_mode is on), send_allowlist_active/send_allowlist_count (project list of exact addresses) and org_send_allowlist_active/org_send_allowlist_count (organization list of exact addresses). A recipient must pass every active list; addresses are not shown. An active value of null means that list could not be read.',
|
|
277
298
|
inputSchema: {},
|
|
278
299
|
}, async () => {
|
|
279
300
|
const ctx = requestContext();
|
|
@@ -297,10 +318,10 @@ export function registerAnalyticsTools(server) {
|
|
|
297
318
|
const ownedOrgIds = Array.from(new Set((ownedOrgs ?? []).map((row) => row.org_id).filter(Boolean)));
|
|
298
319
|
const [{ data: directProjects, error: directProjectsError }, { data: ownedProjects, error: ownedProjectsError }] = await Promise.all([
|
|
299
320
|
directProjectIds.length
|
|
300
|
-
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('id', directProjectIds)
|
|
321
|
+
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist').in('id', directProjectIds)
|
|
301
322
|
: Promise.resolve({ data: [], error: null }),
|
|
302
323
|
ownedOrgIds.length
|
|
303
|
-
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains').in('org_id', ownedOrgIds)
|
|
324
|
+
? adminDb().from('projects').select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist').in('org_id', ownedOrgIds)
|
|
304
325
|
: Promise.resolve({ data: [], error: null }),
|
|
305
326
|
]);
|
|
306
327
|
if (directProjectsError)
|
|
@@ -317,11 +338,12 @@ export function registerAnalyticsTools(server) {
|
|
|
317
338
|
}
|
|
318
339
|
const orgIds = Array.from(new Set(Array.from(byId.values()).map((project) => project.org_id).filter((id) => Boolean(id))));
|
|
319
340
|
const { data: organizations, error: organizationsError } = orgIds.length
|
|
320
|
-
? await adminDb().from('organizations').select('id, name').in('id', orgIds)
|
|
341
|
+
? await adminDb().from('organizations').select('id, name, send_allowlist').in('id', orgIds)
|
|
321
342
|
: { data: [], error: null };
|
|
322
343
|
if (organizationsError)
|
|
323
344
|
return txt(`Error: ${dbToolError(organizationsError).error}`);
|
|
324
345
|
const orgNames = new Map((organizations ?? []).map((org) => [org.id, org.name]));
|
|
346
|
+
const orgAllowlists = new Map((organizations ?? []).map((org) => [org.id, org.send_allowlist]));
|
|
325
347
|
return j({
|
|
326
348
|
projects: Array.from(byId.values())
|
|
327
349
|
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
|
|
@@ -332,6 +354,7 @@ export function registerAnalyticsTools(server) {
|
|
|
332
354
|
org_name: project.org_id ? orgNames.get(project.org_id) ?? null : null,
|
|
333
355
|
sandbox_mode: project.sandbox_mode,
|
|
334
356
|
sandbox_allowed_domains: project.sandbox_allowed_domains,
|
|
357
|
+
...sendAllowlistFields(project.send_allowlist, project.org_id ? orgAllowlists.get(project.org_id) : null),
|
|
335
358
|
active: project.id === ctx.projectId,
|
|
336
359
|
})),
|
|
337
360
|
});
|
|
@@ -341,19 +364,19 @@ export function registerAnalyticsTools(server) {
|
|
|
341
364
|
// Look up the org for this project, then list all projects in that org.
|
|
342
365
|
const { data: project, error: projErr } = await adminDb()
|
|
343
366
|
.from('projects')
|
|
344
|
-
.select('id, name, org_id, sandbox_mode, sandbox_allowed_domains')
|
|
367
|
+
.select('id, name, org_id, sandbox_mode, sandbox_allowed_domains, send_allowlist')
|
|
345
368
|
.eq('id', projectId)
|
|
346
369
|
.maybeSingle();
|
|
347
370
|
if (projErr || !project)
|
|
348
371
|
return txt(`Error: could not find project ${projectId}`);
|
|
349
372
|
if (!project.org_id) {
|
|
350
373
|
// No org — return just this project
|
|
351
|
-
return j({ projects: [{ id: project.id, name: project.name, sandbox_mode: project.sandbox_mode, sandbox_allowed_domains: project.sandbox_allowed_domains, current: true }] });
|
|
374
|
+
return j({ projects: [{ id: project.id, name: project.name, sandbox_mode: project.sandbox_mode, sandbox_allowed_domains: project.sandbox_allowed_domains, ...sendAllowlistFields(project.send_allowlist, null), current: true }] });
|
|
352
375
|
}
|
|
353
376
|
// List all projects in the same organization
|
|
354
377
|
const { data: projects, error: listErr } = await adminDb()
|
|
355
378
|
.from('projects')
|
|
356
|
-
.select('id, name, sandbox_mode, sandbox_allowed_domains')
|
|
379
|
+
.select('id, name, sandbox_mode, sandbox_allowed_domains, send_allowlist')
|
|
357
380
|
.eq('org_id', project.org_id)
|
|
358
381
|
.order('name');
|
|
359
382
|
if (listErr)
|
|
@@ -361,7 +384,7 @@ export function registerAnalyticsTools(server) {
|
|
|
361
384
|
// Get org info
|
|
362
385
|
const { data: org } = await adminDb()
|
|
363
386
|
.from('organizations')
|
|
364
|
-
.select('id, name, plan')
|
|
387
|
+
.select('id, name, plan, send_allowlist')
|
|
365
388
|
.eq('id', project.org_id)
|
|
366
389
|
.maybeSingle();
|
|
367
390
|
return j({
|
|
@@ -371,6 +394,7 @@ export function registerAnalyticsTools(server) {
|
|
|
371
394
|
name: p.name,
|
|
372
395
|
sandbox_mode: p.sandbox_mode,
|
|
373
396
|
sandbox_allowed_domains: p.sandbox_allowed_domains,
|
|
397
|
+
...sendAllowlistFields(p.send_allowlist, org?.send_allowlist),
|
|
374
398
|
current: p.id === projectId,
|
|
375
399
|
})),
|
|
376
400
|
});
|
package/build/tools/campaigns.js
CHANGED
|
@@ -957,7 +957,7 @@ export function registerCampaignTools(server) {
|
|
|
957
957
|
idempotentHint: false,
|
|
958
958
|
openWorldHint: false,
|
|
959
959
|
},
|
|
960
|
-
description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting. For type=broadcast, send_at schedules the send for a future instant; the draft still needs activation via activate_campaigns (human approval), which enrolls nobody. Use create_campaign plus steps and activate_campaigns for saved sequences/journeys; for a one-off segment broadcast use send_broadcast with segment_id and optional send_at, which creates, enrolls and activates after approval.',
|
|
960
|
+
description: 'Create a new email campaign. For triggered campaigns, provide one trigger shape: trigger_event_name, trigger_tag, trigger_type=date_field with trigger_config, or segment_id for ongoing audience targeting. For type=broadcast, send_at schedules the send for a future instant; the draft still needs activation via activate_campaigns (human approval), which enrolls nobody. Use create_campaign plus steps and activate_campaigns for saved sequences/journeys; for a one-off segment broadcast use send_broadcast with segment_id and optional send_at, which creates, enrolls and activates after approval. Optional timezone (IANA name, e.g. America/New_York) sets the campaign time zone at create time; when omitted the database column default applies, unchanged from before.',
|
|
961
961
|
inputSchema: {
|
|
962
962
|
...reentryFields,
|
|
963
963
|
site_id: z.string().uuid(),
|
|
@@ -973,8 +973,12 @@ export function registerCampaignTools(server) {
|
|
|
973
973
|
segment_id: z.string().uuid().nullable().optional().describe('Segment UUID for ongoing audience targeting; membership is re-evaluated on sends'),
|
|
974
974
|
send_at: z.string().datetime().nullable().optional().describe('For type=broadcast only: ISO 8601 instant to schedule the send. Must be in the future. Set null for no schedule. Activation via activate_campaigns requires human approval and enrolls nobody. To schedule a one-off segment broadcast use send_broadcast with segment_id and send_at.'),
|
|
975
975
|
send_class: z.enum(['broadcast', 'product']).optional().describe("Who may receive it when the project's consent gate is on: 'broadcast' (default) = only contacts who opted in to marketing; 'product' = also the customer's signed-up users (onboarding, trial, account mail)."),
|
|
976
|
+
timezone: z.string().optional().describe('Optional IANA time zone for this campaign, e.g. America/New_York. Must be a valid IANA name. When omitted, the database column default applies (no change from earlier behavior).'),
|
|
976
977
|
},
|
|
977
|
-
}, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class, reentry_mode, period_key }) => {
|
|
978
|
+
}, async ({ site_id, name, type, description, sends_per_hour, requires_approval, trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id, send_at, send_class, reentry_mode, period_key, timezone }) => {
|
|
979
|
+
if (timezone !== undefined && !validTimezone(timezone)) {
|
|
980
|
+
return j({ error: `timezone must be a valid IANA time zone name (for example America/New_York); got "${timezone}"`, code: 'validation_failed' });
|
|
981
|
+
}
|
|
978
982
|
const limit = await checkPlanLimit('active_campaigns');
|
|
979
983
|
if (!limit.allowed) {
|
|
980
984
|
return { content: [{ type: 'text', text: `Active campaign limit reached (${limit.current}/${limit.limit}). Upgrade to Pro for unlimited campaigns.` }] };
|
|
@@ -989,6 +993,8 @@ export function registerCampaignTools(server) {
|
|
|
989
993
|
sends_per_hour: sends_per_hour ?? null,
|
|
990
994
|
requires_approval,
|
|
991
995
|
...(send_class ? { send_class } : {}),
|
|
996
|
+
// Omitted: no key at all, so the column default applies exactly as before (#4838 item 9).
|
|
997
|
+
...(timezone !== undefined ? { timezone } : {}),
|
|
992
998
|
};
|
|
993
999
|
const reentry = resolveCampaignReentry({ reentry_mode, period_key }, { type, trigger_type });
|
|
994
1000
|
if (!reentry.ok)
|
|
@@ -2723,7 +2729,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2723
2729
|
idempotentHint: false,
|
|
2724
2730
|
openWorldHint: true,
|
|
2725
2731
|
},
|
|
2726
|
-
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending and includes unresolved_merge_tags (tags with no recipient value and no fallback, with subject/html/text locations); suppressed recipients are refused. Logged as test, not counted in campaign metrics.',
|
|
2732
|
+
description: 'Send a test email to a specified address using the site\'s configured provider. Content from step_id, draft_id, or raw subject/body_html uses the real send renderer. Optional dry_run previews without sending and includes unresolved_merge_tags (tags with no recipient value and no fallback, with subject/html/text locations); suppressed recipients are refused. Logged as test, not counted in campaign metrics. The recipient must pass every active send limit: while sandbox_mode is on, its domain must be in sandbox_allowed_domains; if the project send allowlist is active, the exact address must be on it; if the organization send allowlist is active, the exact address must be on that too. Being on sandbox_allowed_domains alone is not enough when an exact-address list is active. list_projects shows which lists are active (send_allowlist_active, org_send_allowlist_active). A refusal names the list that refused (send_allowlist_refused or sandbox_refused).',
|
|
2727
2733
|
inputSchema: {
|
|
2728
2734
|
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
|
|
2729
2735
|
to_email: z.string().email().describe('Email address to send the test to'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.231",
|
|
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",
|