@sendinel/mcp-server 1.0.48 → 1.0.50
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.d.ts +5 -0
- package/build/tools/campaigns.js +73 -10
- package/build/tools/data.js +5 -0
- package/package.json +1 -1
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/**
|
|
3
|
+
* Plain-language answer to "what will this campaign actually fire on?", from a saved campaign
|
|
4
|
+
* row. Includes a warning when the campaign cannot fire yet (#3855).
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildCampaignFiresOn(campaign: Record<string, unknown>): string;
|
|
2
7
|
export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
|
|
3
8
|
trigger_event_name?: string | null;
|
|
4
9
|
trigger_tag?: string | null;
|
package/build/tools/campaigns.js
CHANGED
|
@@ -51,6 +51,45 @@ function buildCampaignTriggerConfig(value) {
|
|
|
51
51
|
return null;
|
|
52
52
|
return { events, tags, ...(Object.keys(where).length ? { where } : {}) };
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Plain-language answer to "what will this campaign actually fire on?", from a saved campaign
|
|
56
|
+
* row. Includes a warning when the campaign cannot fire yet (#3855).
|
|
57
|
+
*/
|
|
58
|
+
export function buildCampaignFiresOn(campaign) {
|
|
59
|
+
const config = (campaign.trigger_config && typeof campaign.trigger_config === 'object' ? campaign.trigger_config : {});
|
|
60
|
+
const events = normalizeTriggerList([...normalizeTriggerList(config.events), campaign.trigger_event_name]);
|
|
61
|
+
const tags = normalizeTriggerList([...normalizeTriggerList(config.tags), campaign.trigger_tag]);
|
|
62
|
+
const parts = [];
|
|
63
|
+
if (campaign.trigger_type === 'date_field') {
|
|
64
|
+
const field = typeof config.field === 'string' && config.field ? config.field : null;
|
|
65
|
+
const days = Number(config.days_before ?? 0) || 0;
|
|
66
|
+
parts.push(field
|
|
67
|
+
? `${days} day(s) before contact field "${field}" (${config.recurring === false ? 'once' : 'recurring'})`
|
|
68
|
+
: 'a contact date field, but no field is set (it will never fire)');
|
|
69
|
+
}
|
|
70
|
+
else if (campaign.trigger_type === 'contact_event') {
|
|
71
|
+
const name = typeof config.event_type === 'string' ? config.event_type : null;
|
|
72
|
+
parts.push(name
|
|
73
|
+
? `${Number(config.days_after ?? 0) || 0} day(s) after event "${name}"`
|
|
74
|
+
: 'a contact event, but no event is set (it will never fire)');
|
|
75
|
+
}
|
|
76
|
+
if (events.length)
|
|
77
|
+
parts.push(`event${events.length > 1 ? 's' : ''} ${events.map((e) => `"${e}"`).join(', ')} sent to /v1/events`);
|
|
78
|
+
if (tags.length)
|
|
79
|
+
parts.push(`tag${tags.length > 1 ? 's' : ''} ${tags.map((t) => `"${t}"`).join(', ')} added`);
|
|
80
|
+
if (campaign.segment_id)
|
|
81
|
+
parts.push('contacts entering its segment');
|
|
82
|
+
let summary = parts.length ? `Fires on: ${parts.join('; ')}.` : 'Fires on: nothing (no trigger set).';
|
|
83
|
+
const warnings = [];
|
|
84
|
+
if ((events.length || tags.length) && campaign.type !== 'triggered') {
|
|
85
|
+
warnings.push(`event and tag triggers only start campaigns of type "triggered"; this one is "${String(campaign.type)}"`);
|
|
86
|
+
}
|
|
87
|
+
if (campaign.status !== 'active')
|
|
88
|
+
warnings.push(`it is ${String(campaign.status)}, so nothing starts until it is active`);
|
|
89
|
+
if (warnings.length)
|
|
90
|
+
summary += ` Warning: ${warnings.join('; ')}.`;
|
|
91
|
+
return summary;
|
|
92
|
+
}
|
|
54
93
|
function applyCampaignSendAtField(updates, sendAt, campaignType) {
|
|
55
94
|
if (sendAt === undefined)
|
|
56
95
|
return null;
|
|
@@ -71,6 +110,13 @@ export async function applyCampaignTargetingFields(updates, input, projectId) {
|
|
|
71
110
|
updates.trigger_event_name = input.trigger_event_name?.trim() || null;
|
|
72
111
|
if (input.trigger_tag !== undefined)
|
|
73
112
|
updates.trigger_tag = input.trigger_tag?.trim().toLowerCase() || null;
|
|
113
|
+
if (input.trigger_type === null || input.trigger_type === 'manual') {
|
|
114
|
+
// campaigns.trigger_type is NOT NULL DEFAULT 'manual', so "clear" means back to 'manual'.
|
|
115
|
+
// Also drops a stale date_field config unless the caller sent a new trigger_config (#3855).
|
|
116
|
+
updates.trigger_type = 'manual';
|
|
117
|
+
if (input.trigger_config === undefined)
|
|
118
|
+
updates.trigger_config = null;
|
|
119
|
+
}
|
|
74
120
|
if (input.trigger_type === 'date_field') {
|
|
75
121
|
const config = input.trigger_config ?? {};
|
|
76
122
|
const field = typeof config.field === 'string' ? config.field.trim() : '';
|
|
@@ -3143,7 +3189,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3143
3189
|
idempotentHint: true,
|
|
3144
3190
|
openWorldHint: false,
|
|
3145
3191
|
},
|
|
3146
|
-
description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
|
|
3192
|
+
description: 'Update a campaign name, site, segment, send time, topic, trigger settings, or send class (broadcast vs product). trigger_type null (or manual) clears a date_field trigger; type switches a draft between sequence and triggered. The response includes fires_on, a plain summary of what the saved campaign fires on. send_at schedules a broadcast send (type=broadcast only) and must be a future ISO 8601 instant; set null to clear a pending schedule. Only provided fields are updated.',
|
|
3147
3193
|
inputSchema: {
|
|
3148
3194
|
campaign_id: z.string().uuid(),
|
|
3149
3195
|
name: z.string().min(1).max(200).optional(),
|
|
@@ -3153,33 +3199,50 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3153
3199
|
topic: z.string().nullable().optional(),
|
|
3154
3200
|
trigger_event_name: z.string().nullable().optional(),
|
|
3155
3201
|
trigger_tag: z.string().nullable().optional(),
|
|
3156
|
-
trigger_type: z.enum(['date_field']).nullable().optional(),
|
|
3202
|
+
trigger_type: z.enum(['date_field', 'manual']).nullable().optional().describe("'date_field' fires relative to a contact date field (needs trigger_config.field). null or 'manual' clears a date_field trigger."),
|
|
3157
3203
|
trigger_config: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
3204
|
+
type: z.enum(['sequence', 'triggered']).optional().describe("Switch a DRAFT campaign between 'sequence' and 'triggered'. Event and tag triggers only start campaigns of type 'triggered'."),
|
|
3158
3205
|
sends_per_hour: z.number().int().min(1).nullable().optional().describe('Optional hourly send cap for wave sending. Set null to clear.'),
|
|
3159
3206
|
send_in_contact_timezone: z.boolean().optional(),
|
|
3160
3207
|
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)."),
|
|
3161
3208
|
},
|
|
3162
|
-
}, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, sends_per_hour, send_in_contact_timezone, send_class }) => {
|
|
3209
|
+
}, async ({ campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class }) => {
|
|
3163
3210
|
const projectId = getProjectId();
|
|
3164
3211
|
let campaignType;
|
|
3212
|
+
const loadExisting = () => db()
|
|
3213
|
+
.from('campaigns')
|
|
3214
|
+
.select('id, type, status')
|
|
3215
|
+
.eq('id', campaign_id)
|
|
3216
|
+
.eq('project_id', projectId)
|
|
3217
|
+
.maybeSingle();
|
|
3165
3218
|
if (send_at !== undefined && send_at !== null) {
|
|
3166
|
-
const { data: existing, error: fetchError } = await
|
|
3167
|
-
.from('campaigns')
|
|
3168
|
-
.select('id, type')
|
|
3169
|
-
.eq('id', campaign_id)
|
|
3170
|
-
.eq('project_id', projectId)
|
|
3171
|
-
.maybeSingle();
|
|
3219
|
+
const { data: existing, error: fetchError } = await loadExisting();
|
|
3172
3220
|
if (fetchError)
|
|
3173
3221
|
return j({ error: fetchError.message });
|
|
3174
3222
|
if (!existing)
|
|
3175
3223
|
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3176
3224
|
campaignType = existing.type;
|
|
3177
3225
|
}
|
|
3226
|
+
if (type !== undefined) {
|
|
3227
|
+
const { data: existing, error: fetchError } = await loadExisting();
|
|
3228
|
+
if (fetchError)
|
|
3229
|
+
return j({ error: fetchError.message });
|
|
3230
|
+
if (!existing)
|
|
3231
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
3232
|
+
if (type !== existing.type) {
|
|
3233
|
+
if (existing.status !== 'draft')
|
|
3234
|
+
return j({ error: `type can only be changed while the campaign is a draft (this one is ${existing.status})`, code: 'INVALID_FIELD' });
|
|
3235
|
+
if (existing.type !== 'sequence' && existing.type !== 'triggered')
|
|
3236
|
+
return j({ error: `a ${existing.type} campaign cannot become ${type}`, code: 'INVALID_FIELD' });
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3178
3239
|
const updates = { updated_at: new Date().toISOString() };
|
|
3179
3240
|
if (name !== undefined)
|
|
3180
3241
|
updates.name = name;
|
|
3181
3242
|
if (site_id !== undefined)
|
|
3182
3243
|
updates.site_id = site_id;
|
|
3244
|
+
if (type !== undefined)
|
|
3245
|
+
updates.type = type;
|
|
3183
3246
|
const scheduleError = applyCampaignSendAtField(updates, send_at, campaignType);
|
|
3184
3247
|
if (scheduleError)
|
|
3185
3248
|
return j({ error: scheduleError, code: 'INVALID_FIELD' });
|
|
@@ -3203,7 +3266,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3203
3266
|
.single();
|
|
3204
3267
|
if (error)
|
|
3205
3268
|
return j({ error: error.message });
|
|
3206
|
-
return j(data);
|
|
3269
|
+
return j({ ...data, fires_on: buildCampaignFiresOn(data) });
|
|
3207
3270
|
});
|
|
3208
3271
|
server.registerTool('get_campaign', {
|
|
3209
3272
|
title: 'Get Campaign',
|
package/build/tools/data.js
CHANGED
|
@@ -983,6 +983,11 @@ export function registerDataTools(server) {
|
|
|
983
983
|
scopes: z.array(z.string()).optional().default(['read']),
|
|
984
984
|
},
|
|
985
985
|
}, async ({ name, slug, scopes = ['read'] }) => {
|
|
986
|
+
// MCP API-key sessions can be unbound to a user, so they cannot prove an
|
|
987
|
+
// acting org role. Admin keys must be created through the dashboard.
|
|
988
|
+
if (scopes.includes('admin')) {
|
|
989
|
+
return j({ error: 'Admin-scope API keys must be created in the dashboard by an org owner or admin.' });
|
|
990
|
+
}
|
|
986
991
|
const { secret, hash, prefix } = generateApiKey();
|
|
987
992
|
const cleanSlug = slug?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || null;
|
|
988
993
|
const { data, error } = await db().from('api_keys')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.50",
|
|
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",
|