@sendinel/mcp-server 1.0.43 → 1.0.45
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/contacts.js +37 -79
- package/build/tools/org.js +23 -0
- 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/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, } from '@sendinel/domain';
|
|
4
|
+
import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, } from '@sendinel/domain';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { db, projectClientForWriteback } from '../db.js';
|
|
7
7
|
import { getProjectId } from '../project.js';
|
|
@@ -2104,7 +2104,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2104
2104
|
idempotentHint: true,
|
|
2105
2105
|
openWorldHint: false,
|
|
2106
2106
|
},
|
|
2107
|
-
description: 'Find duplicate contacts using fuzzy email matching and exact name matching. Returns grouped candidates with confidence scores.',
|
|
2107
|
+
description: 'Find duplicate contacts using fuzzy email matching and exact name matching. Returns grouped candidates with confidence scores. Each group also suggests which contact to keep (suggested_target_id, source_ids) for merge_subscribers, and flags probably_different_people when the first names differ (e.g. two people at one company): never merge those without asking the user.',
|
|
2108
2108
|
inputSchema: {
|
|
2109
2109
|
confidence_threshold: z.number().min(0.5).max(1).optional().describe('Minimum similarity (0.5-1.0, default 0.8)'),
|
|
2110
2110
|
limit: z.number().int().min(1).max(200).optional().describe('Max duplicate groups (default 50)'),
|
|
@@ -2130,15 +2130,35 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2130
2130
|
return j({ duplicate_groups: [], total_groups: 0, coverage: { contacts_scanned: 0, contacts_matching: 0, contact_limit: CONTACT_SCAN_LIMIT, complete: true } });
|
|
2131
2131
|
siteIds = ids;
|
|
2132
2132
|
}
|
|
2133
|
+
// If the last sync time can't be read, hide nothing extra: a longer list beats a missing duplicate.
|
|
2134
|
+
let byodCutoff = null;
|
|
2135
|
+
try {
|
|
2136
|
+
const { data: lastSync } = await db()
|
|
2137
|
+
.from('cron_runs')
|
|
2138
|
+
.select('started_at')
|
|
2139
|
+
.eq('job_name', 'byod-sync')
|
|
2140
|
+
.eq('status', 'success')
|
|
2141
|
+
.order('started_at', { ascending: false })
|
|
2142
|
+
.limit(1)
|
|
2143
|
+
.maybeSingle();
|
|
2144
|
+
byodCutoff = lastSync?.started_at ?? null;
|
|
2145
|
+
}
|
|
2146
|
+
catch {
|
|
2147
|
+
byodCutoff = null;
|
|
2148
|
+
}
|
|
2133
2149
|
const pageQuery = (from) => {
|
|
2134
2150
|
let q = db()
|
|
2135
2151
|
.from('subscribers')
|
|
2136
2152
|
.select('id, email, first_name, last_name, engagement_score, lifecycle_stage, created_at, source', { count: 'exact' })
|
|
2137
|
-
.eq('project_id', projectId)
|
|
2138
|
-
.eq('global_unsubscribed', false);
|
|
2153
|
+
.eq('project_id', projectId);
|
|
2139
2154
|
// #3604: hide seed/fixture rows like every other default contact view.
|
|
2140
2155
|
if (!include_test)
|
|
2141
2156
|
q = q.not('is_test', 'is', true);
|
|
2157
|
+
// #3750: scan the same contacts as the dashboard's Find & Merge Duplicates screen. Unsubscribed
|
|
2158
|
+
// contacts are still duplicates (a merge keeps their consent), and rows the last BYOD sync
|
|
2159
|
+
// no longer saw are hidden there.
|
|
2160
|
+
if (byodCutoff)
|
|
2161
|
+
q = q.or(`source.neq.database_import,updated_at.gte.${byodCutoff}`);
|
|
2142
2162
|
if (siteIds)
|
|
2143
2163
|
q = q.in('id', siteIds);
|
|
2144
2164
|
return q.order('created_at', { ascending: true }).order('id', { ascending: true })
|
|
@@ -2168,82 +2188,20 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2168
2188
|
const coverage = { contacts_scanned: scanned, contacts_matching: matching, contact_limit: CONTACT_SCAN_LIMIT, complete: matching === null ? null : matching <= scanned };
|
|
2169
2189
|
if (!contacts?.length)
|
|
2170
2190
|
return j({ duplicate_groups: [], total_groups: 0, coverage });
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
}
|
|
2183
|
-
return dp[m];
|
|
2184
|
-
};
|
|
2185
|
-
const similarity = (a, b) => {
|
|
2186
|
-
if (a === b)
|
|
2187
|
-
return 1;
|
|
2188
|
-
const maxLen = Math.max(a.length, b.length);
|
|
2189
|
-
if (maxLen === 0)
|
|
2190
|
-
return 1;
|
|
2191
|
-
return 1 - lev(a, b) / maxLen;
|
|
2192
|
-
};
|
|
2193
|
-
const byDomain = {};
|
|
2194
|
-
for (const c of contacts) {
|
|
2195
|
-
const [, domain] = c.email.toLowerCase().split('@');
|
|
2196
|
-
if (!byDomain[domain])
|
|
2197
|
-
byDomain[domain] = [];
|
|
2198
|
-
byDomain[domain].push(c);
|
|
2199
|
-
}
|
|
2200
|
-
const groups = [];
|
|
2201
|
-
const merged = new Set();
|
|
2202
|
-
for (const domain of Object.keys(byDomain)) {
|
|
2203
|
-
const dc = byDomain[domain];
|
|
2204
|
-
for (let i = 0; i < dc.length; i++) {
|
|
2205
|
-
if (merged.has(dc[i].id))
|
|
2206
|
-
continue;
|
|
2207
|
-
const localA = dc[i].email.split('@')[0].toLowerCase();
|
|
2208
|
-
const group = [dc[i]];
|
|
2209
|
-
for (let jj = i + 1; jj < dc.length; jj++) {
|
|
2210
|
-
if (merged.has(dc[jj].id))
|
|
2211
|
-
continue;
|
|
2212
|
-
const localB = dc[jj].email.split('@')[0].toLowerCase();
|
|
2213
|
-
const sim = similarity(localA, localB);
|
|
2214
|
-
if (sim >= confidence_threshold && sim < 1) {
|
|
2215
|
-
group.push(dc[jj]);
|
|
2216
|
-
merged.add(dc[jj].id);
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
if (group.length > 1) {
|
|
2220
|
-
merged.add(dc[i].id);
|
|
2221
|
-
const avgSim = similarity(group[0].email.split('@')[0].toLowerCase(), group[1].email.split('@')[0].toLowerCase());
|
|
2222
|
-
groups.push({ group, confidence: Math.round(avgSim * 100) / 100, reason: 'similar_email' });
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
}
|
|
2226
|
-
const byName = {};
|
|
2227
|
-
for (const c of contacts) {
|
|
2228
|
-
if (merged.has(c.id))
|
|
2229
|
-
continue;
|
|
2230
|
-
const fn = (c.first_name ?? '').toLowerCase().trim();
|
|
2231
|
-
const ln = (c.last_name ?? '').toLowerCase().trim();
|
|
2232
|
-
if (!fn && !ln)
|
|
2233
|
-
continue;
|
|
2234
|
-
const key = `${fn}|${ln}`;
|
|
2235
|
-
if (!byName[key])
|
|
2236
|
-
byName[key] = [];
|
|
2237
|
-
byName[key].push(c);
|
|
2238
|
-
}
|
|
2239
|
-
for (const nameGroup of Object.values(byName)) {
|
|
2240
|
-
if (nameGroup.length > 1) {
|
|
2241
|
-
groups.push({ group: nameGroup, confidence: 0.7, reason: 'same_name' });
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
groups.sort((a, b) => b.confidence - a.confidence);
|
|
2191
|
+
// The matching rule is shared with the dashboard screen (@sendinel/domain, #3743/#3750).
|
|
2192
|
+
const groups = findContactDuplicateGroups(contacts, { threshold: confidence_threshold })
|
|
2193
|
+
.map((g) => ({
|
|
2194
|
+
group: g.contacts,
|
|
2195
|
+
confidence: g.confidence,
|
|
2196
|
+
reason: g.reason,
|
|
2197
|
+
suggested_target_id: g.suggested_target_id,
|
|
2198
|
+
source_ids: g.source_ids,
|
|
2199
|
+
probably_different_people: g.probably_different_people,
|
|
2200
|
+
different_first_names: g.different_first_names,
|
|
2201
|
+
}));
|
|
2245
2202
|
const result = groups.slice(0, maxGroups);
|
|
2246
|
-
|
|
2203
|
+
// total_groups counts every group found; groups_truncated says when limit cut the list.
|
|
2204
|
+
return j({ duplicate_groups: result, total_groups: groups.length, groups_truncated: groups.length > result.length, coverage });
|
|
2247
2205
|
});
|
|
2248
2206
|
server.registerTool('merge_subscribers', {
|
|
2249
2207
|
title: 'Merge Subscribers',
|
package/build/tools/org.js
CHANGED
|
@@ -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.45",
|
|
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",
|