@sendinel/mcp-server 1.0.44 → 1.0.46
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/README.md +2 -2
- package/build/server.js +1 -1
- package/build/tools/advisor.js +21 -21
- package/build/tools/analytics.js +9 -5
- package/build/tools/campaigns.js +15 -12
- package/build/tools/contacts.js +59 -45
- package/build/tools/data-proposals.js +4 -3
- package/build/tools/org.js +48 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ AI-controlled email operations control plane for campaigns, contacts, segments,
|
|
|
12
12
|
|
|
13
13
|
## Tool groups
|
|
14
14
|
|
|
15
|
-
Sendinel currently exposes 34 tool groups and
|
|
15
|
+
Sendinel currently exposes 34 tool groups and 320 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
16
16
|
|
|
17
17
|
| Group | Count | Purpose |
|
|
18
18
|
|-------|-------|---------|
|
|
@@ -38,7 +38,7 @@ Sendinel currently exposes 34 tool groups and 319 tools (gateway mode adds 3 met
|
|
|
38
38
|
| `landing-pages` | 5 | Landing page CRUD |
|
|
39
39
|
| `lists` | 7 | Static contact lists and membership management |
|
|
40
40
|
| `newsletters` | 12 | Newsletter publications, issues, subscribers, scheduling, sending, stats |
|
|
41
|
-
| `org` |
|
|
41
|
+
| `org` | 21 | Team members, plan usage/limits, audit log, model registry, white-label, support tickets, provider switching |
|
|
42
42
|
| `segments` | 6 | Audience builder: create, edit, natural-language generation, preview |
|
|
43
43
|
| `short-links` | 3 | Short links, click stats, QR codes |
|
|
44
44
|
| `signatures` | 4 | Sending signature blocks |
|
package/build/server.js
CHANGED
|
@@ -109,7 +109,7 @@ const WRITE_TOOLS = new Set([
|
|
|
109
109
|
'set_active_project',
|
|
110
110
|
'resend_invitation', 'revoke_invitation',
|
|
111
111
|
'create_landing_page', 'update_landing_page', 'delete_landing_page',
|
|
112
|
-
'start_provider_switch', 'get_provider_switch_status', 'rollback_provider_switch',
|
|
112
|
+
'submit_support_ticket', 'start_provider_switch', 'get_provider_switch_status', 'rollback_provider_switch',
|
|
113
113
|
'create_api_key', 'revoke_api_key', 'mark_notifications_read',
|
|
114
114
|
'migrate_campaigns_from', 'start_platform_migration',
|
|
115
115
|
'create_content_block', 'delete_content_block', 'trigger_content_pipeline',
|
package/build/tools/advisor.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getProjectId } from '../project.js';
|
|
|
5
5
|
import { buildVoicePrompt } from '../lib/voice.js';
|
|
6
6
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
7
7
|
import { dkimRemediationText } from '../lib/email-provider.js';
|
|
8
|
+
import { readAllRows } from '@sendinel/domain';
|
|
8
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
9
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
10
11
|
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
@@ -77,8 +78,8 @@ export function registerAdvisorTools(server) {
|
|
|
77
78
|
const recommendations = [];
|
|
78
79
|
let skipped = 0;
|
|
79
80
|
for (const campaign of campaigns ?? []) {
|
|
80
|
-
const
|
|
81
|
-
|
|
81
|
+
const rows = await readAllRows(db().from('email_log').select('status')
|
|
82
|
+
.eq('project_id', projectId).eq('campaign_id', campaign.id).gte('created_at', since));
|
|
82
83
|
const sends = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
|
|
83
84
|
if (sends < 100) {
|
|
84
85
|
skipped++;
|
|
@@ -129,22 +130,20 @@ export function registerAdvisorTools(server) {
|
|
|
129
130
|
.from('email_log')
|
|
130
131
|
.select('status, created_at')
|
|
131
132
|
.eq('project_id', projectId)
|
|
132
|
-
.gte('created_at', periodStart.toISOString())
|
|
133
|
-
.limit(10000);
|
|
133
|
+
.gte('created_at', periodStart.toISOString());
|
|
134
134
|
if (site_id)
|
|
135
135
|
currentQuery = currentQuery.eq('site_id', site_id);
|
|
136
|
-
const
|
|
136
|
+
const currentRows = await readAllRows(currentQuery);
|
|
137
137
|
// 2. Prior period
|
|
138
138
|
let priorQuery = db()
|
|
139
139
|
.from('email_log')
|
|
140
140
|
.select('status, created_at')
|
|
141
141
|
.eq('project_id', projectId)
|
|
142
142
|
.gte('created_at', priorStart.toISOString())
|
|
143
|
-
.lt('created_at', periodStart.toISOString())
|
|
144
|
-
.limit(10000);
|
|
143
|
+
.lt('created_at', periodStart.toISOString());
|
|
145
144
|
if (site_id)
|
|
146
145
|
priorQuery = priorQuery.eq('site_id', site_id);
|
|
147
|
-
const
|
|
146
|
+
const priorRows = await readAllRows(priorQuery);
|
|
148
147
|
const current = computeMetrics(currentRows ?? []);
|
|
149
148
|
const prior = computeMetrics(priorRows ?? []);
|
|
150
149
|
// 3. Domain health (latest per site)
|
|
@@ -328,11 +327,10 @@ export function registerAdvisorTools(server) {
|
|
|
328
327
|
.from('email_log')
|
|
329
328
|
.select('subject, status')
|
|
330
329
|
.eq('project_id', projectId)
|
|
331
|
-
.in('status', ['delivered', 'opened', 'clicked'])
|
|
332
|
-
.limit(5000);
|
|
330
|
+
.in('status', ['delivered', 'opened', 'clicked']);
|
|
333
331
|
if (site_id)
|
|
334
332
|
logQuery = logQuery.eq('site_id', site_id);
|
|
335
|
-
const
|
|
333
|
+
const logRows = await readAllRows(logQuery);
|
|
336
334
|
// Aggregate by subject
|
|
337
335
|
const subjectStats = new Map();
|
|
338
336
|
for (const row of logRows ?? []) {
|
|
@@ -444,14 +442,13 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
|
|
|
444
442
|
const days = daysParam ?? 90;
|
|
445
443
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
446
444
|
// 1. Query email_log with campaign step subjects
|
|
447
|
-
const
|
|
445
|
+
const logRows = await readAllRows(db()
|
|
448
446
|
.from('email_log')
|
|
449
447
|
.select('campaign_id, subject, status')
|
|
450
448
|
.eq('project_id', projectId)
|
|
451
449
|
.eq('site_id', site_id)
|
|
452
450
|
.gte('created_at', since)
|
|
453
|
-
.not('campaign_id', 'is', null)
|
|
454
|
-
.limit(10000);
|
|
451
|
+
.not('campaign_id', 'is', null));
|
|
455
452
|
if (!logRows || logRows.length < 20) {
|
|
456
453
|
return j({
|
|
457
454
|
error: 'insufficient_data',
|
|
@@ -565,16 +562,18 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
565
562
|
// 1. Get contact IDs for segment filter
|
|
566
563
|
let contactFilter = null;
|
|
567
564
|
if (segment_id) {
|
|
568
|
-
const
|
|
565
|
+
const segContacts = await readAllRows(db()
|
|
569
566
|
.from('subscribers')
|
|
570
567
|
.select('id')
|
|
571
|
-
.eq('project_id', projectId)
|
|
572
|
-
.limit(5000);
|
|
568
|
+
.eq('project_id', projectId));
|
|
573
569
|
// For now, we pass all contacts and filter at query time
|
|
574
570
|
// A proper segment query would use segment-query.ts, but MCP server
|
|
575
571
|
// doesn't have that module. We filter by site instead.
|
|
576
|
-
contactFilter =
|
|
572
|
+
contactFilter = segContacts.map((c) => c.id);
|
|
577
573
|
}
|
|
574
|
+
const coverage = contactFilter
|
|
575
|
+
? { contacts_matching: contactFilter.length, contacts_used: Math.min(contactFilter.length, 500), complete: contactFilter.length <= 500 }
|
|
576
|
+
: { contacts_matching: null, contacts_used: null, complete: true };
|
|
578
577
|
// 2. Query email_log
|
|
579
578
|
let query = db()
|
|
580
579
|
.from('email_log')
|
|
@@ -582,16 +581,16 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
582
581
|
.eq('project_id', projectId)
|
|
583
582
|
.eq('site_id', site_id)
|
|
584
583
|
.gte('created_at', since)
|
|
585
|
-
.in('status', ['delivered', 'opened', 'clicked'])
|
|
586
|
-
.limit(10000);
|
|
584
|
+
.in('status', ['delivered', 'opened', 'clicked']);
|
|
587
585
|
if (contactFilter) {
|
|
588
586
|
query = query.in('contact_id', contactFilter.slice(0, 500));
|
|
589
587
|
}
|
|
590
|
-
const
|
|
588
|
+
const rows = await readAllRows(query);
|
|
591
589
|
if (!rows || rows.length < 30) {
|
|
592
590
|
return j({
|
|
593
591
|
error: 'insufficient_data',
|
|
594
592
|
message: `Only ${rows?.length ?? 0} qualifying emails found in the last ${days} days. Need at least 30 for meaningful analysis.`,
|
|
593
|
+
coverage,
|
|
595
594
|
});
|
|
596
595
|
}
|
|
597
596
|
// 3. Build buckets
|
|
@@ -610,6 +609,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
|
|
|
610
609
|
metric: metric ?? 'open_rate',
|
|
611
610
|
period_days: days,
|
|
612
611
|
total_emails_analyzed: rows.length,
|
|
612
|
+
coverage,
|
|
613
613
|
best_hours: best3Hours,
|
|
614
614
|
worst_hours: worst3Hours,
|
|
615
615
|
best_days: best3Days,
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchAllRows } from '@sendinel/domain';
|
|
1
|
+
import { fetchAllRows, readAllRows } from '@sendinel/domain';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { db, adminDb, requestContext } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
@@ -323,10 +323,14 @@ export function registerAnalyticsTools(server) {
|
|
|
323
323
|
inputSchema: { period_days: z.number().int().min(1).max(365).optional().default(30) },
|
|
324
324
|
}, async ({ period_days = 30 }) => {
|
|
325
325
|
const since = new Date(Date.now() - period_days * 86400000).toISOString();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
326
|
+
let rows;
|
|
327
|
+
try {
|
|
328
|
+
rows = await readAllRows(db().from('email_log').select('site_id, status, sent_at, created_at')
|
|
329
|
+
.eq('project_id', getProjectId()).gte('created_at', since));
|
|
330
|
+
}
|
|
331
|
+
catch (readError) {
|
|
332
|
+
return j({ error: String(readError) });
|
|
333
|
+
}
|
|
330
334
|
const totals = computeAnalyticsMetrics(rows);
|
|
331
335
|
const bySite = new Map();
|
|
332
336
|
for (const row of rows) {
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import Anthropic from '@anthropic-ai/sdk';
|
|
3
|
-
import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
|
|
3
|
+
import { computeSendContentKey, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, readAllRows, } from '@sendinel/domain';
|
|
4
4
|
// Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
|
|
5
5
|
// and MCP-server-side that's fine (this never runs in an Edge Function), but
|
|
6
6
|
// keeping it off the shared barrel avoids re-creating the Edge-Function
|
|
@@ -336,21 +336,24 @@ async function attachBroadcastToExisting(input, match) {
|
|
|
336
336
|
.select('contact_id')
|
|
337
337
|
.eq('campaign_id', match.id)
|
|
338
338
|
.in('contact_id', ids)
|
|
339
|
-
.
|
|
339
|
+
// One enrollment per campaign/contact, and ids has at most 200 entries.
|
|
340
|
+
.limit(200);
|
|
340
341
|
if (enrolledErr)
|
|
341
342
|
return { ok: false, error: enrolledErr.message, code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
|
|
342
343
|
for (const row of enrolledRows ?? [])
|
|
343
344
|
skip.add(row.contact_id);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
345
|
+
let logRows;
|
|
346
|
+
try {
|
|
347
|
+
logRows = await readAllRows(db().from('email_log')
|
|
348
|
+
.select('contact_id, status')
|
|
349
|
+
.eq('project_id', input.projectId)
|
|
350
|
+
.eq('campaign_id', match.id)
|
|
351
|
+
.in('contact_id', ids));
|
|
352
|
+
}
|
|
353
|
+
catch (readError) {
|
|
354
|
+
return { ok: false, error: String(readError), code: 'ONE_SEND_LOOKUP_FAILED', campaign_id: match.id };
|
|
355
|
+
}
|
|
356
|
+
for (const row of logRows)
|
|
354
357
|
if (sentStatuses.has(row.status))
|
|
355
358
|
skip.add(row.contact_id);
|
|
356
359
|
}
|
package/build/tools/contacts.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
|
|
3
3
|
import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
|
|
4
|
-
import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, } from '@sendinel/domain';
|
|
4
|
+
import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { db, projectClientForWriteback } from '../db.js';
|
|
7
7
|
import { getProjectId } from '../project.js';
|
|
@@ -625,6 +625,9 @@ export function registerContactTools(server) {
|
|
|
625
625
|
}, async () => {
|
|
626
626
|
const projectId = getProjectId();
|
|
627
627
|
const cutoff = daysAgo(90);
|
|
628
|
+
// Deliberately NOT paged in #3755: the update below REPLACES each contact's tags with ['cold']
|
|
629
|
+
// instead of adding it. Paging would widen that data loss to every inactive contact. Fix the
|
|
630
|
+
// overwrite first (#3834), then page this read.
|
|
628
631
|
const { data: contacts } = await db().from('subscribers').select('id, email, tags, last_engaged_at').eq('project_id', projectId).lt('last_engaged_at', cutoff).limit(1000);
|
|
629
632
|
const ids = (contacts ?? []).map((contact) => contact.id);
|
|
630
633
|
if (ids.length) {
|
|
@@ -647,12 +650,15 @@ export function registerContactTools(server) {
|
|
|
647
650
|
},
|
|
648
651
|
}, async ({ siteId, lookbackDays = 180 }) => {
|
|
649
652
|
const projectId = getProjectId();
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
653
|
+
let subs;
|
|
654
|
+
try {
|
|
655
|
+
subs = await readAllRows(db().from('site_subscriptions')
|
|
656
|
+
.select('contact_id').eq('site_id', siteId).eq('subscribed', true));
|
|
657
|
+
}
|
|
658
|
+
catch (readError) {
|
|
659
|
+
return txt(`Error: ${String(readError)}`);
|
|
660
|
+
}
|
|
661
|
+
const ids = subs.map((s) => s.contact_id);
|
|
656
662
|
if (!ids.length)
|
|
657
663
|
return j({
|
|
658
664
|
summary: { total: 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 },
|
|
@@ -661,15 +667,17 @@ export function registerContactTools(server) {
|
|
|
661
667
|
recommendation: 'No subscribed contacts found for this site.',
|
|
662
668
|
lookback_days: lookbackDays,
|
|
663
669
|
});
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
670
|
+
let contacts = [];
|
|
671
|
+
try {
|
|
672
|
+
for (const batch of chunkIds(ids)) {
|
|
673
|
+
contacts.push(...await readAllRows(db().from('subscribers')
|
|
674
|
+
.select('id, email, last_engaged_at, total_opens, total_clicks, total_emails_sent, custom_properties')
|
|
675
|
+
.eq('project_id', projectId).eq('global_unsubscribed', false).in('id', batch)));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
catch (readError) {
|
|
679
|
+
return txt(`Error: ${String(readError)}`);
|
|
680
|
+
}
|
|
673
681
|
const summary = { total: contacts?.length ?? 0, active: 0, cooling: 0, dormant: 0, sunset_candidates: 0, active_paying_low_engagement: 0 };
|
|
674
682
|
const sunset = [];
|
|
675
683
|
const payingLowEngagement = [];
|
|
@@ -2117,15 +2125,17 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2117
2125
|
const CONTACT_SCAN_LIMIT = 2000;
|
|
2118
2126
|
// #3604: PostgREST returns at most 1,000 rows per request, so a single
|
|
2119
2127
|
// .limit(2000) query only ever read 1,000. Read in pages up to the limit.
|
|
2120
|
-
const SCAN_PAGE = 1000;
|
|
2121
2128
|
let siteIds = null;
|
|
2122
2129
|
if (site_id) {
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
.
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2130
|
+
let subs;
|
|
2131
|
+
try {
|
|
2132
|
+
subs = await readAllRows(db().from('site_subscriptions')
|
|
2133
|
+
.select('contact_id').eq('site_id', site_id).eq('subscribed', true));
|
|
2134
|
+
}
|
|
2135
|
+
catch (readError) {
|
|
2136
|
+
return txt(`Error: ${String(readError)}`);
|
|
2137
|
+
}
|
|
2138
|
+
const ids = subs.map((s) => s.contact_id);
|
|
2129
2139
|
if (!ids.length)
|
|
2130
2140
|
return j({ duplicate_groups: [], total_groups: 0, coverage: { contacts_scanned: 0, contacts_matching: 0, contact_limit: CONTACT_SCAN_LIMIT, complete: true } });
|
|
2131
2141
|
siteIds = ids;
|
|
@@ -2146,7 +2156,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2146
2156
|
catch {
|
|
2147
2157
|
byodCutoff = null;
|
|
2148
2158
|
}
|
|
2149
|
-
const pageQuery = (from) => {
|
|
2159
|
+
const pageQuery = (from, to, ids) => {
|
|
2150
2160
|
let q = db()
|
|
2151
2161
|
.from('subscribers')
|
|
2152
2162
|
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source', { count: 'exact' })
|
|
@@ -2159,33 +2169,37 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2159
2169
|
// no longer saw are hidden there.
|
|
2160
2170
|
if (byodCutoff)
|
|
2161
2171
|
q = q.or(`source.neq.database_import,updated_at.gte.${byodCutoff}`);
|
|
2162
|
-
if (
|
|
2163
|
-
q = q.in('id',
|
|
2172
|
+
if (ids)
|
|
2173
|
+
q = q.in('id', ids);
|
|
2164
2174
|
return q.order('created_at', { ascending: true }).order('id', { ascending: true })
|
|
2165
|
-
.range(from,
|
|
2175
|
+
.range(from, to);
|
|
2166
2176
|
};
|
|
2167
|
-
const
|
|
2168
|
-
let
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2177
|
+
const scanChunk = (ids) => readAllPages((from, to) => pageQuery(from, to, ids), { cap: CONTACT_SCAN_LIMIT });
|
|
2178
|
+
let scan;
|
|
2179
|
+
if (siteIds && siteIds.length > 200) {
|
|
2180
|
+
const all = [];
|
|
2181
|
+
let total = 0;
|
|
2182
|
+
for (const ids of chunkIds(siteIds)) {
|
|
2183
|
+
const part = await scanChunk(ids);
|
|
2184
|
+
if (!part.ok)
|
|
2185
|
+
return txt(`Error: ${part.error.message}`);
|
|
2186
|
+
all.push(...part.rows);
|
|
2187
|
+
total = total === null || part.total === null ? null : total + part.total;
|
|
2175
2188
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
contacts.push(...rows);
|
|
2180
|
-
if (rows.length < SCAN_PAGE)
|
|
2181
|
-
break;
|
|
2189
|
+
all.sort((a, b) => String(a.created_at ?? '').localeCompare(String(b.created_at ?? '')) || String(a.id).localeCompare(String(b.id)));
|
|
2190
|
+
const rows = all.slice(0, CONTACT_SCAN_LIMIT);
|
|
2191
|
+
scan = { ok: true, rows, cap: CONTACT_SCAN_LIMIT, total, complete: total !== null && total <= rows.length };
|
|
2182
2192
|
}
|
|
2183
|
-
|
|
2184
|
-
|
|
2193
|
+
else {
|
|
2194
|
+
scan = await scanChunk(siteIds);
|
|
2195
|
+
}
|
|
2196
|
+
if (!scan.ok)
|
|
2197
|
+
return txt(`Error: ${scan.error.message}`);
|
|
2198
|
+
const contacts = scan.rows;
|
|
2185
2199
|
// #3604: say when the scan stopped at the limit instead of presenting a partial scan as complete.
|
|
2186
2200
|
const scanned = contacts?.length ?? 0;
|
|
2187
|
-
const matching =
|
|
2188
|
-
const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null :
|
|
2201
|
+
const matching = scan.total;
|
|
2202
|
+
const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null : scan.complete };
|
|
2189
2203
|
if (!contacts?.length)
|
|
2190
2204
|
return j({ duplicate_groups: [], total_groups: 0, coverage });
|
|
2191
2205
|
// The matching rule is shared with the dashboard screen (@sendinel/domain, #3743/#3750).
|
|
@@ -2,16 +2,17 @@ import { z } from 'zod';
|
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
5
|
+
import { readAllRows } from '@sendinel/domain';
|
|
5
6
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
6
7
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
7
8
|
async function engagementSnapshot(projectId) {
|
|
8
9
|
const since = new Date(Date.now() - 7 * 86400000).toISOString();
|
|
9
|
-
const [
|
|
10
|
-
db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since)
|
|
10
|
+
const [logs, { count: audienceSize }, { data: segments }] = await Promise.all([
|
|
11
|
+
readAllRows(db().from('email_log').select('status, created_at').eq('project_id', projectId).gte('created_at', since)),
|
|
11
12
|
db().from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId).eq('is_test', false).eq('global_unsubscribed', false),
|
|
12
13
|
db().from('segments').select('id, name, subscriber_count:cached_count, updated_at').eq('project_id', projectId).order('updated_at', { ascending: false }).limit(5),
|
|
13
14
|
]);
|
|
14
|
-
const rows = logs
|
|
15
|
+
const rows = logs;
|
|
15
16
|
const sent = rows.filter((row) => ['sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained'].includes(row.status)).length;
|
|
16
17
|
const opened = rows.filter((row) => row.status === 'opened' || row.status === 'clicked').length;
|
|
17
18
|
const clicked = rows.filter((row) => row.status === 'clicked').length;
|
package/build/tools/org.js
CHANGED
|
@@ -5,6 +5,7 @@ import { adminDb, db } from '../db.js';
|
|
|
5
5
|
import { getProjectId } from '../project.js';
|
|
6
6
|
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
|
+
import { readAllRows } from '@sendinel/domain';
|
|
8
9
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
9
10
|
const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
|
|
10
11
|
async function callProviderSwitchApi(path, init) {
|
|
@@ -636,41 +637,40 @@ export function registerOrgTools(server) {
|
|
|
636
637
|
const fetchWindow = async (fromDate, toDate) => {
|
|
637
638
|
let query = db()
|
|
638
639
|
.from('audit_log')
|
|
639
|
-
.select('action, actor_id, actor_type, created_at'
|
|
640
|
+
.select('action, actor_id, actor_type, created_at')
|
|
640
641
|
.eq('project_id', projectId)
|
|
641
642
|
.gte('created_at', `${fromDate}T00:00:00.000Z`)
|
|
642
643
|
.lte('created_at', `${toDate}T23:59:59.999Z`)
|
|
643
|
-
.order('created_at', { ascending: false })
|
|
644
|
-
.range(0, 9999);
|
|
644
|
+
.order('created_at', { ascending: false });
|
|
645
645
|
if (action)
|
|
646
646
|
query = query.eq('action', action);
|
|
647
647
|
if (resource_type)
|
|
648
648
|
query = query.eq('resource_type', resource_type);
|
|
649
649
|
if (actor_id)
|
|
650
650
|
query = query.eq('actor_id', actor_id);
|
|
651
|
-
return query;
|
|
651
|
+
return readAllRows(query);
|
|
652
652
|
};
|
|
653
653
|
const spanDays = dateSpanDays(dateFrom, dateTo);
|
|
654
654
|
const previousDateTo = addDaysUtc(dateFrom, -1);
|
|
655
655
|
const previousDateFrom = addDaysUtc(dateFrom, -spanDays);
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
656
|
+
let rows, previousRows;
|
|
657
|
+
try {
|
|
658
|
+
[rows, previousRows] = await Promise.all([
|
|
659
|
+
fetchWindow(dateFrom, dateTo),
|
|
660
|
+
fetchWindow(previousDateFrom, previousDateTo),
|
|
661
|
+
]);
|
|
662
|
+
}
|
|
663
|
+
catch (readError) {
|
|
664
|
+
return j({ error: String(readError) });
|
|
665
|
+
}
|
|
666
666
|
const uniqueActors = new Set(rows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
|
|
667
667
|
const previousUniqueActors = new Set(previousRows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
|
|
668
668
|
const dangerousEvents = rows.filter((entry) => isDangerousAuditAction(entry.action)).length;
|
|
669
669
|
return j({
|
|
670
|
-
events:
|
|
670
|
+
events: rows.length,
|
|
671
671
|
uniqueActors,
|
|
672
672
|
dangerousEvents,
|
|
673
|
-
previousEvents:
|
|
673
|
+
previousEvents: previousRows.length,
|
|
674
674
|
previousUniqueActors,
|
|
675
675
|
dateFrom,
|
|
676
676
|
dateTo,
|
|
@@ -690,15 +690,15 @@ export function registerOrgTools(server) {
|
|
|
690
690
|
inputSchema: {},
|
|
691
691
|
}, async () => {
|
|
692
692
|
const projectId = getProjectId();
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
.select('action')
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
const actions = Array.from(new Set(
|
|
693
|
+
let data;
|
|
694
|
+
try {
|
|
695
|
+
data = await readAllRows(db().from('audit_log').select('action')
|
|
696
|
+
.eq('project_id', projectId).order('action', { ascending: true }));
|
|
697
|
+
}
|
|
698
|
+
catch (readError) {
|
|
699
|
+
return j({ error: String(readError) });
|
|
700
|
+
}
|
|
701
|
+
const actions = Array.from(new Set(data.map((row) => row.action).filter(Boolean)));
|
|
702
702
|
return j({ actions });
|
|
703
703
|
});
|
|
704
704
|
server.registerTool('get_model_registry', {
|
|
@@ -834,6 +834,29 @@ export function registerOrgTools(server) {
|
|
|
834
834
|
note: 'The domain is stored but will not resolve to this org until DNS/certificate verification marks custom_domain_verified true.',
|
|
835
835
|
});
|
|
836
836
|
});
|
|
837
|
+
server.registerTool('submit_support_ticket', {
|
|
838
|
+
title: 'Submit Support Ticket',
|
|
839
|
+
annotations: {
|
|
840
|
+
readOnlyHint: false,
|
|
841
|
+
destructiveHint: false,
|
|
842
|
+
idempotentHint: false,
|
|
843
|
+
openWorldHint: false,
|
|
844
|
+
},
|
|
845
|
+
description: 'Get help by filing a support ticket on the tenant\'s behalf as a bug report, feature request, or feedback. Uses the same support pipeline as the dashboard.',
|
|
846
|
+
inputSchema: {
|
|
847
|
+
title: z.string().min(1).max(200),
|
|
848
|
+
description: z.string().min(1),
|
|
849
|
+
type: z.enum(['bug', 'feature', 'feedback', 'other']).optional(),
|
|
850
|
+
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
|
851
|
+
page_context: z.record(z.string(), z.unknown()).optional(),
|
|
852
|
+
},
|
|
853
|
+
}, async ({ title, description, type, priority, page_context }) => {
|
|
854
|
+
const result = await callSendinelV1('/api/v1/support/tickets', {
|
|
855
|
+
method: 'POST',
|
|
856
|
+
body: { title, description, type, priority, page_context },
|
|
857
|
+
});
|
|
858
|
+
return result.ok ? j(result.data) : j({ error: result.error });
|
|
859
|
+
});
|
|
837
860
|
server.registerTool('start_provider_switch', {
|
|
838
861
|
title: 'Start Provider Switch',
|
|
839
862
|
annotations: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.46",
|
|
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",
|