@sendinel/mcp-server 1.0.11 → 1.0.13
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/lib/survey-link-resolver.d.ts +0 -1
- package/build/lib/survey-link-resolver.js +0 -3
- package/build/tools/advisor.js +3 -3
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +8 -8
- package/build/tools/campaigns.js +30 -15
- package/build/tools/data-proposals.js +1 -1
- package/build/tools/data.js +7 -4
- package/build/tools/delivery-ops.js +17 -6
- package/build/tools/org.js +16 -2
- package/package.json +1 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
type ProjectDbClient = {
|
|
2
2
|
from: (table: string) => any;
|
|
3
3
|
};
|
|
4
|
-
export declare function renderTemplateString(input: string, vars?: Record<string, string>): string;
|
|
5
4
|
export declare function resolveSurveyShortLink(sb: ProjectDbClient, projectId: string, surveyId: string): Promise<string | null>;
|
|
6
5
|
export {};
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
export function renderTemplateString(input, vars = {}) {
|
|
2
|
-
return String(input ?? '').replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
|
3
|
-
}
|
|
4
1
|
function appBaseUrl() {
|
|
5
2
|
return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
6
3
|
}
|
package/build/tools/advisor.js
CHANGED
|
@@ -316,7 +316,7 @@ export function registerAdvisorTools(server) {
|
|
|
316
316
|
// 1. Get current subject (variant A)
|
|
317
317
|
const { data: currentStep } = await db()
|
|
318
318
|
.from('campaign_steps')
|
|
319
|
-
.select('id, subject, html_body, text_body, delay_days, delay_hours')
|
|
319
|
+
.select('id, subject, html_body:body_html, text_body:body_text, delay_days, delay_hours')
|
|
320
320
|
.eq('campaign_id', campaign_id)
|
|
321
321
|
.eq('step_order', step_order ?? 0)
|
|
322
322
|
.eq('variant_label', 'A')
|
|
@@ -403,8 +403,8 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
403
403
|
step_order: step_order ?? 0,
|
|
404
404
|
variant_label: labels[i],
|
|
405
405
|
subject: suggestions[i].subject,
|
|
406
|
-
|
|
407
|
-
|
|
406
|
+
body_html: currentStep.html_body,
|
|
407
|
+
body_text: currentStep.text_body,
|
|
408
408
|
delay_days: currentStep.delay_days,
|
|
409
409
|
delay_hours: currentStep.delay_hours,
|
|
410
410
|
});
|
package/build/tools/analytics.js
CHANGED
|
@@ -198,10 +198,10 @@ function buildRevenueAnalytics(params) {
|
|
|
198
198
|
failed_payments_note: 'Best-effort count of unpaid invoices in the period; stripe_invoices does not store a dedicated payment_failed flag.',
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
-
function distinctEventCount(events, eventType,
|
|
201
|
+
function distinctEventCount(events, eventType, messageIds) {
|
|
202
202
|
return new Set(events
|
|
203
|
-
.filter(event => event.event_type === eventType && event.
|
|
204
|
-
.map(event => event.
|
|
203
|
+
.filter(event => event.event_type === eventType && event.provider_message_id && messageIds.has(event.provider_message_id))
|
|
204
|
+
.map(event => event.provider_message_id)).size;
|
|
205
205
|
}
|
|
206
206
|
function isTrueConversionEvent(event) {
|
|
207
207
|
const normalized = (event ?? '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
@@ -433,7 +433,7 @@ export function registerAnalyticsTools(server) {
|
|
|
433
433
|
const trackingEnabled = site?.tracking_enabled ?? false;
|
|
434
434
|
const { data, error } = await db()
|
|
435
435
|
.from('email_log')
|
|
436
|
-
.select('id, status, sent_at')
|
|
436
|
+
.select('id, provider_message_id, status, sent_at')
|
|
437
437
|
.eq('project_id', projectId)
|
|
438
438
|
.eq('site_id', site_id)
|
|
439
439
|
.gte('sent_at', `${start_date}T00:00:00Z`)
|
|
@@ -445,7 +445,7 @@ export function registerAnalyticsTools(server) {
|
|
|
445
445
|
if (trackingEnabled) {
|
|
446
446
|
const { data: events, error: eventsError } = await db()
|
|
447
447
|
.from('email_events')
|
|
448
|
-
.select('
|
|
448
|
+
.select('provider_message_id, event_type')
|
|
449
449
|
.eq('project_id', projectId)
|
|
450
450
|
.eq('site_id', site_id)
|
|
451
451
|
.in('event_type', ['open', 'click'])
|
|
@@ -453,10 +453,10 @@ export function registerAnalyticsTools(server) {
|
|
|
453
453
|
.lte('occurred_at', `${end}T23:59:59Z`);
|
|
454
454
|
if (eventsError)
|
|
455
455
|
return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
|
|
456
|
-
const
|
|
456
|
+
const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
|
|
457
457
|
eventCounts = {
|
|
458
|
-
opened: distinctEventCount(events ?? [], 'open',
|
|
459
|
-
clicked: distinctEventCount(events ?? [], 'click',
|
|
458
|
+
opened: distinctEventCount(events ?? [], 'open', messageIds),
|
|
459
|
+
clicked: distinctEventCount(events ?? [], 'click', messageIds),
|
|
460
460
|
};
|
|
461
461
|
}
|
|
462
462
|
const metrics = computeAnalyticsMetrics(rows, trackingEnabled ? eventCounts : undefined);
|
package/build/tools/campaigns.js
CHANGED
|
@@ -10,7 +10,7 @@ import { assertChannelCanExecute, currentChannelContext, requestApproval, checkA
|
|
|
10
10
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
11
11
|
import { getScheduleWindowBounds, groupScheduleByCampaign, parseFutureDate } from '../lib/schedule.js';
|
|
12
12
|
import { sendEmailForSite } from '../lib/email-provider.js';
|
|
13
|
-
import {
|
|
13
|
+
import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
|
|
14
14
|
import { logAudit } from '../audit.js';
|
|
15
15
|
import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
|
|
16
16
|
import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
|
|
@@ -472,11 +472,11 @@ export function registerCampaignTools(server) {
|
|
|
472
472
|
return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
|
|
473
473
|
renderVars.survey_link = surveyLink;
|
|
474
474
|
}
|
|
475
|
-
const renderedSubject = survey_id ?
|
|
475
|
+
const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
|
|
476
476
|
const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
|
|
477
477
|
const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
|
|
478
|
-
const renderedHtml = body_html !== undefined && survey_id ?
|
|
479
|
-
const renderedText = body_text !== undefined && survey_id ?
|
|
478
|
+
const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
|
|
479
|
+
const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
|
|
480
480
|
const { data, error } = await db()
|
|
481
481
|
.from('campaign_steps')
|
|
482
482
|
.insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A' })
|
|
@@ -630,7 +630,7 @@ export function registerCampaignTools(server) {
|
|
|
630
630
|
return j({ error: 'Campaign or contact not found', code: 'NOT_FOUND' });
|
|
631
631
|
const { data: enrollment, error } = await db()
|
|
632
632
|
.from('campaign_enrollments')
|
|
633
|
-
.select('id, status, current_step,
|
|
633
|
+
.select('id, status, current_step, enrolled_at, completed_at, next_send_at, exit_reason:exited_reason')
|
|
634
634
|
.eq('campaign_id', campaign_id)
|
|
635
635
|
.eq('contact_id', contact_id)
|
|
636
636
|
.maybeSingle();
|
|
@@ -639,19 +639,34 @@ export function registerCampaignTools(server) {
|
|
|
639
639
|
if (!enrollment) {
|
|
640
640
|
return j({ enrolled: false, enrollment: null, campaign: campaignRes.data, contact: contactRes.data });
|
|
641
641
|
}
|
|
642
|
-
const { count: stepsSent } = await
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
642
|
+
const [{ count: stepsSent, error: stepsSentError }, currentStepResult] = await Promise.all([
|
|
643
|
+
db()
|
|
644
|
+
.from('email_log')
|
|
645
|
+
.select('id', { count: 'exact', head: true })
|
|
646
|
+
.eq('project_id', projectId)
|
|
647
|
+
.eq('campaign_id', campaign_id)
|
|
648
|
+
.eq('contact_id', contact_id)
|
|
649
|
+
.not('status', 'eq', 'failed'),
|
|
650
|
+
enrollment.current_step == null
|
|
651
|
+
? Promise.resolve({ data: null, error: null })
|
|
652
|
+
: db()
|
|
653
|
+
.from('campaign_steps')
|
|
654
|
+
.select('id')
|
|
655
|
+
.eq('campaign_id', campaign_id)
|
|
656
|
+
.eq('step_order', enrollment.current_step)
|
|
657
|
+
.maybeSingle(),
|
|
658
|
+
]);
|
|
659
|
+
if (stepsSentError)
|
|
660
|
+
return txt(`Error: ${stepsSentError.message}`);
|
|
661
|
+
if (currentStepResult.error)
|
|
662
|
+
return txt(`Error: ${currentStepResult.error.message}`);
|
|
649
663
|
return j({
|
|
650
664
|
enrolled: true,
|
|
651
665
|
campaign: campaignRes.data,
|
|
652
666
|
contact: contactRes.data,
|
|
653
667
|
enrollment: {
|
|
654
668
|
...enrollment,
|
|
669
|
+
current_step_id: currentStepResult.data?.id ?? null,
|
|
655
670
|
current_step_order: enrollment.current_step ?? 0,
|
|
656
671
|
steps_sent: stepsSent ?? 0,
|
|
657
672
|
},
|
|
@@ -2236,9 +2251,9 @@ Return ONLY valid JSON with this exact shape:
|
|
|
2236
2251
|
...(normalizedBlocks !== undefined ? { blocks: normalizedBlocks.length ? normalizedBlocks : null } : {}),
|
|
2237
2252
|
...(normalizedBlocks?.length && stepUpdates.body_html === undefined ? { body_html: blocksToHtml(normalizedBlocks) } : {}),
|
|
2238
2253
|
...(normalizedBlocks?.length && stepUpdates.body_text === undefined ? { body_text: blocksToText(normalizedBlocks) } : {}),
|
|
2239
|
-
...(survey_id && stepUpdates.subject !== undefined ? { subject:
|
|
2240
|
-
...(survey_id && stepUpdates.body_html !== undefined ? { body_html:
|
|
2241
|
-
...(survey_id && stepUpdates.body_text !== undefined ? { body_text:
|
|
2254
|
+
...(survey_id && stepUpdates.subject !== undefined ? { subject: renderTemplateVars(stepUpdates.subject, renderVars) } : {}),
|
|
2255
|
+
...(survey_id && stepUpdates.body_html !== undefined ? { body_html: renderTemplateVars(stepUpdates.body_html, renderVars) } : {}),
|
|
2256
|
+
...(survey_id && stepUpdates.body_text !== undefined ? { body_text: renderTemplateVars(stepUpdates.body_text, renderVars) } : {}),
|
|
2242
2257
|
};
|
|
2243
2258
|
const { data, error } = await db()
|
|
2244
2259
|
.from('campaign_steps')
|
|
@@ -9,7 +9,7 @@ async function engagementSnapshot(projectId) {
|
|
|
9
9
|
const [{ data: logs }, { count: audienceSize }, { data: segments }] = await Promise.all([
|
|
10
10
|
db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since).limit(10000),
|
|
11
11
|
db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId).eq('is_test', false).eq('global_unsubscribed', false),
|
|
12
|
-
db().from('segments').select('id, name, subscriber_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
|
|
12
|
+
db().from('segments').select('id, name, subscriber_count:cached_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
|
|
13
13
|
]);
|
|
14
14
|
const rows = logs ?? [];
|
|
15
15
|
const sent = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
|
package/build/tools/data.js
CHANGED
|
@@ -1150,13 +1150,16 @@ export function registerDataTools(server) {
|
|
|
1150
1150
|
description: 'List in-app notifications.',
|
|
1151
1151
|
inputSchema: { unread_only: z.boolean().optional().default(false) },
|
|
1152
1152
|
}, async ({ unread_only = false }) => {
|
|
1153
|
-
|
|
1153
|
+
// notifications has a boolean `read`, not a read_at timestamp (#3323). Kevin,
|
|
1154
|
+
// 2026-09-15: "Option 1: use read". read_at stays in the response as null
|
|
1155
|
+
// (read time is unknown), and `read` is added; tool name and input unchanged.
|
|
1156
|
+
let query = db().from('notifications').select('id, type, title, body, severity, read, created_at, action_url').eq('project_id', getProjectId()).order('created_at', { ascending: false }).limit(50);
|
|
1154
1157
|
if (unread_only)
|
|
1155
|
-
query = query.
|
|
1158
|
+
query = query.eq('read', false);
|
|
1156
1159
|
const { data, error } = await query;
|
|
1157
1160
|
if (error)
|
|
1158
1161
|
return j({ error: error.message });
|
|
1159
|
-
return j({ notifications: data ?? [] });
|
|
1162
|
+
return j({ notifications: (data ?? []).map((row) => ({ ...row, read: row.read === true, read_at: null })) });
|
|
1160
1163
|
});
|
|
1161
1164
|
server.registerTool('mark_notifications_read', {
|
|
1162
1165
|
title: 'Mark Notifications Read',
|
|
@@ -1169,7 +1172,7 @@ export function registerDataTools(server) {
|
|
|
1169
1172
|
description: 'Mark selected notifications read, or all unread notifications if ids is omitted.',
|
|
1170
1173
|
inputSchema: { ids: z.array(z.string().uuid()).optional() },
|
|
1171
1174
|
}, async ({ ids }) => {
|
|
1172
|
-
let query = db().from('notifications').update({
|
|
1175
|
+
let query = db().from('notifications').update({ read: true }).eq('project_id', getProjectId()).eq('read', false);
|
|
1173
1176
|
if (ids?.length)
|
|
1174
1177
|
query = query.in('id', ids);
|
|
1175
1178
|
const { data, error } = await query.select('id');
|
|
@@ -145,7 +145,7 @@ export function registerDeliveryOpsTools(server) {
|
|
|
145
145
|
inputSchema: { domain_id: z.string().uuid() },
|
|
146
146
|
}, async ({ domain_id }) => {
|
|
147
147
|
const { data, error } = await db().from('domains')
|
|
148
|
-
.select('id
|
|
148
|
+
.select('id')
|
|
149
149
|
.eq('project_id', getProjectId())
|
|
150
150
|
.eq('id', domain_id)
|
|
151
151
|
.maybeSingle();
|
|
@@ -153,11 +153,22 @@ export function registerDeliveryOpsTools(server) {
|
|
|
153
153
|
return j({ error: error.message });
|
|
154
154
|
if (!data)
|
|
155
155
|
return j({ error: 'Domain not found', code: 'NOT_FOUND' });
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
const { data: health, error: healthError } = await db().from('domain_health')
|
|
157
|
+
.select('spf_valid, dkim_valid, dmarc_valid, sites!inner(domain_id)')
|
|
158
|
+
.eq('project_id', getProjectId())
|
|
159
|
+
.eq('sites.domain_id', domain_id)
|
|
160
|
+
.order('check_date', { ascending: false })
|
|
161
|
+
.limit(1)
|
|
162
|
+
.maybeSingle();
|
|
163
|
+
if (healthError)
|
|
164
|
+
return j({ error: healthError.message });
|
|
165
|
+
const spf = health?.spf_valid ?? null;
|
|
166
|
+
const dkim = health?.dkim_valid ?? null;
|
|
167
|
+
const dmarc = health?.dmarc_valid ?? null;
|
|
168
|
+
const allVerified = [spf, dkim, dmarc].every((value) => typeof value === 'boolean')
|
|
169
|
+
? Boolean(spf && dkim && dmarc)
|
|
170
|
+
: null;
|
|
171
|
+
return j({ spf, dkim, dmarc, all_verified: allVerified });
|
|
161
172
|
});
|
|
162
173
|
server.registerTool('dnsbl_check', {
|
|
163
174
|
title: 'DNSBL Check',
|
package/build/tools/org.js
CHANGED
|
@@ -146,10 +146,24 @@ export function registerOrgTools(server) {
|
|
|
146
146
|
}
|
|
147
147
|
if (!orgId)
|
|
148
148
|
return j({ members: [] });
|
|
149
|
-
const { data, error } = await db().from('org_members').select('user_id,
|
|
149
|
+
const { data, error } = await db().from('org_members').select('user_id, role, created_at').eq('org_id', orgId);
|
|
150
150
|
if (error)
|
|
151
151
|
return j({ error: error.message });
|
|
152
|
-
|
|
152
|
+
const membersWithUsers = await Promise.all((data ?? []).map(async (member) => {
|
|
153
|
+
const { data: userData, error: userError } = await adminDb().auth.admin.getUserById(member.user_id);
|
|
154
|
+
return { member, user: userData?.user ?? null, error: userError };
|
|
155
|
+
}));
|
|
156
|
+
const userError = membersWithUsers.find((result) => result.error)?.error;
|
|
157
|
+
if (userError)
|
|
158
|
+
return j({ error: userError.message });
|
|
159
|
+
return j({
|
|
160
|
+
members: membersWithUsers.map(({ member, user }) => ({
|
|
161
|
+
user_id: member.user_id,
|
|
162
|
+
email: user?.email ?? null,
|
|
163
|
+
role: member.role,
|
|
164
|
+
joined_at: member.created_at,
|
|
165
|
+
})),
|
|
166
|
+
});
|
|
153
167
|
});
|
|
154
168
|
server.registerTool('invite_team_member', {
|
|
155
169
|
title: 'Invite Team Member',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
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",
|