@sendinel/mcp-server 1.0.91 → 1.0.93
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 +3 -3
- package/build/server.d.ts +1 -0
- package/build/server.js +9 -6
- package/build/tools/analytics.js +0 -250
- package/build/tools/campaigns.js +23 -11
- package/build/tools/delivery-ops.js +54 -0
- package/build/tools/gateway.d.ts +1 -0
- package/build/tools/gateway.js +2 -2
- package/build/tools/social-posts.js +197 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Sendinel currently exposes 35 tool groups and 337 tools (gateway mode exposes 4
|
|
|
20
20
|
| `agent-mailboxes` | 6 | Bound inbox, threads, status and always-approved replies/provisioning |
|
|
21
21
|
| `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
|
|
22
22
|
| `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
|
|
23
|
-
| `analytics` |
|
|
23
|
+
| `analytics` | 15 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
|
|
24
24
|
| `approvals` | 2 | Poll and list risk-tiered action approvals |
|
|
25
25
|
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
26
26
|
| `brand_kit` | 5 | Brand Core text import, media library upload/URL ingest, asset-context reads |
|
|
@@ -32,7 +32,7 @@ Sendinel currently exposes 35 tool groups and 337 tools (gateway mode exposes 4
|
|
|
32
32
|
| `content` | 14 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
|
|
33
33
|
| `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
|
|
34
34
|
| `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
|
|
35
|
-
| `delivery-ops` |
|
|
35
|
+
| `delivery-ops` | 25 | Domains, DNSBL, deliverability diagnostics, queue, DLQ, exports, blackout dates, abuse overrides |
|
|
36
36
|
| `drafts` | 6 | Draft create/approve/reject and draft-to-campaign promotion |
|
|
37
37
|
| `forms` | 3 | Form creation, listing, stats |
|
|
38
38
|
| `gdpr` | 4 | Contact data deletion and GDPR log |
|
|
@@ -46,7 +46,7 @@ Sendinel currently exposes 35 tool groups and 337 tools (gateway mode exposes 4
|
|
|
46
46
|
| `sites` | 5 | Site, sender, and brand voice management |
|
|
47
47
|
| `skills` | 3 | Operator skills catalog |
|
|
48
48
|
| `sms` | 7 | SMS send, log, consent, inbound |
|
|
49
|
-
| `social-posts` |
|
|
49
|
+
| `social-posts` | 17 | Social campaign creation, publisher scheduling, social posts, engagement reads, and LinkedIn reply proposals for human review |
|
|
50
50
|
| `surveys` | 5 | Survey creation, listing, status, results |
|
|
51
51
|
| `templates` | 7 | Email template CRUD, version restore, migration translation |
|
|
52
52
|
| `warmup` | 4 | IP/domain warmup schedule control |
|
package/build/server.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
export interface ToolRegistrationOptions {
|
|
3
3
|
scopes?: string[];
|
|
4
4
|
toolGroups?: string[];
|
|
5
|
+
toolAllowlist?: string[];
|
|
5
6
|
}
|
|
6
7
|
declare const GROUP_REGISTRY: Record<string, (server: McpServer, options?: ToolRegistrationOptions) => void>;
|
|
7
8
|
export declare const ALL_TOOL_GROUPS: string[];
|
package/build/server.js
CHANGED
|
@@ -256,12 +256,12 @@ export function getRegisteredToolGroups(options) {
|
|
|
256
256
|
toolGroups[name] = currentGroup;
|
|
257
257
|
},
|
|
258
258
|
};
|
|
259
|
-
for (const group of groups) {
|
|
259
|
+
for (const group of options?.toolAllowlist?.length ? ALL_TOOL_GROUPS : groups) {
|
|
260
260
|
const register = GROUP_REGISTRY[group];
|
|
261
261
|
if (!register)
|
|
262
262
|
continue;
|
|
263
263
|
currentGroup = group;
|
|
264
|
-
const effectiveServer = createScopeProxy(collector, options?.scopes?.length ? options.scopes : null, group);
|
|
264
|
+
const effectiveServer = createScopeProxy(collector, options?.scopes?.length ? options.scopes : null, group, groups.includes(group) ? undefined : options?.toolAllowlist);
|
|
265
265
|
register(effectiveServer, options);
|
|
266
266
|
}
|
|
267
267
|
return toolGroups;
|
|
@@ -317,11 +317,14 @@ function scanToolDefinition(name, config) {
|
|
|
317
317
|
* and wraps handlers with scope enforcement.
|
|
318
318
|
*/
|
|
319
319
|
// scopes=null means no scope restriction (all tools allowed) but scan still runs.
|
|
320
|
-
function createScopeProxy(server, scopes, toolGroup) {
|
|
320
|
+
function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
321
321
|
return new Proxy(server, {
|
|
322
322
|
get(target, prop, receiver) {
|
|
323
323
|
if (prop === 'registerTool') {
|
|
324
324
|
return (name, config, handler) => {
|
|
325
|
+
// A per-credential exception admits only this tool, never its entire group.
|
|
326
|
+
if (allowedTools !== undefined && !allowedTools.includes(name))
|
|
327
|
+
return;
|
|
325
328
|
// ── Tool definition scan (prompt injection / poisoning defense) ──
|
|
326
329
|
// Always runs, regardless of whether scope enforcement is active.
|
|
327
330
|
const findings = scanToolDefinition(name, config);
|
|
@@ -378,15 +381,15 @@ export function registerAllTools(server, options) {
|
|
|
378
381
|
if (rawScopes.includes('gateway')) {
|
|
379
382
|
const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
|
|
380
383
|
const proxied = createScopeProxy(server, permissionScopes, 'gateway');
|
|
381
|
-
registerGatewayTools(proxied, { scopes: permissionScopes, toolGroups: options?.toolGroups });
|
|
384
|
+
registerGatewayTools(proxied, { scopes: permissionScopes, toolGroups: options?.toolGroups, toolAllowlist: options?.toolAllowlist });
|
|
382
385
|
return;
|
|
383
386
|
}
|
|
384
387
|
const groups = options?.toolGroups === undefined
|
|
385
388
|
? ALL_TOOL_GROUPS
|
|
386
389
|
: options.toolGroups;
|
|
387
|
-
for (const group of groups) {
|
|
390
|
+
for (const group of options?.toolAllowlist?.length ? ALL_TOOL_GROUPS : groups) {
|
|
388
391
|
const register = GROUP_REGISTRY[group];
|
|
389
392
|
if (register)
|
|
390
|
-
register(createScopeProxy(server, options?.scopes?.length ? options.scopes : null, group), options);
|
|
393
|
+
register(createScopeProxy(server, options?.scopes?.length ? options.scopes : null, group, groups.includes(group) ? undefined : options?.toolAllowlist), options);
|
|
391
394
|
}
|
|
392
395
|
}
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { isTrueConversionEvent, TRUE_CONVERSION_DEFINITION } from "@sendinel/domain";
|
|
2
2
|
import { percentRate, groupMetricRows, readRevenue, isComplainedRow, isOpenedRow, countPeople, isClickedRow, summarizeEmailRows, countEmailRows, isDeliveredRow, isBouncedRow, fetchAllRows, readAllRows } from "@sendinel/domain";
|
|
3
|
-
import { withApproval } from '../lib/action-approvals.js';
|
|
4
3
|
import { z } from 'zod';
|
|
5
4
|
import { db, adminDb, requestContext } from '../db.js';
|
|
6
5
|
import { getProjectId } from '../project.js';
|
|
@@ -14,66 +13,6 @@ const pctNumber = (rate) => Math.round(rate * 1000) / 10;
|
|
|
14
13
|
const roundRate = (rate) => Math.round(rate * 1000) / 1000;
|
|
15
14
|
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
16
15
|
const stripeLifecycleStatuses = ['active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete', 'incomplete_expired', 'paused'];
|
|
17
|
-
const socialPlatforms = ['instagram', 'linkedin', 'twitter', 'facebook', 'tiktok', 'youtube', 'pinterest', 'uploadpost', 'other'];
|
|
18
|
-
const socialPublishers = ['uploadpost', 'buffer'];
|
|
19
|
-
const UPLOADPOST_API_BASE = process.env.UPLOADPOST_API_BASE || 'https://api.upload-post.com/api';
|
|
20
|
-
function slugifyCampaign(value) {
|
|
21
|
-
return value
|
|
22
|
-
.trim()
|
|
23
|
-
.toLowerCase()
|
|
24
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
-
.replace(/^-+|-+$/g, '')
|
|
26
|
-
.slice(0, 100) || 'campaign';
|
|
27
|
-
}
|
|
28
|
-
function buildSocialTrackedUrl(params) {
|
|
29
|
-
const url = new URL(params.destination_url);
|
|
30
|
-
const utmCampaign = params.utm_campaign?.trim() || slugifyCampaign(params.name);
|
|
31
|
-
url.searchParams.set('utm_source', params.platform);
|
|
32
|
-
url.searchParams.set('utm_medium', 'social');
|
|
33
|
-
url.searchParams.set('utm_campaign', utmCampaign);
|
|
34
|
-
if (params.utm_content?.trim())
|
|
35
|
-
url.searchParams.set('utm_content', params.utm_content.trim());
|
|
36
|
-
return {
|
|
37
|
-
tracked_url: url.toString(),
|
|
38
|
-
utm_source: params.platform,
|
|
39
|
-
utm_medium: 'social',
|
|
40
|
-
utm_campaign: utmCampaign,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
async function scheduleUploadPost(apiKey, params) {
|
|
44
|
-
const res = await fetch(`${UPLOADPOST_API_BASE}/uploadposts/schedule`, {
|
|
45
|
-
method: 'POST',
|
|
46
|
-
headers: {
|
|
47
|
-
Authorization: `Apikey ${apiKey}`,
|
|
48
|
-
'Content-Type': 'application/json',
|
|
49
|
-
},
|
|
50
|
-
body: JSON.stringify({
|
|
51
|
-
platform: params.platform,
|
|
52
|
-
content: params.link ? `${params.content}\n\n${params.link}` : params.content,
|
|
53
|
-
scheduled_at: params.scheduledAt?.toISOString(),
|
|
54
|
-
media_urls: params.mediaUrls,
|
|
55
|
-
link: params.link ?? undefined,
|
|
56
|
-
}),
|
|
57
|
-
});
|
|
58
|
-
if (!res.ok) {
|
|
59
|
-
const text = await res.text().catch(() => '');
|
|
60
|
-
throw new Error(`UploadPost schedule failed: ${res.status}${text ? ` ${text.slice(0, 200)}` : ''}`);
|
|
61
|
-
}
|
|
62
|
-
const json = await res.json();
|
|
63
|
-
const publisherPostId = json.id ?? json.request_id ?? json.post_id;
|
|
64
|
-
if (!publisherPostId)
|
|
65
|
-
throw new Error('UploadPost schedule response did not include a post id.');
|
|
66
|
-
return {
|
|
67
|
-
publisherPostId: String(publisherPostId),
|
|
68
|
-
scheduledAt: json.scheduled_at ? new Date(json.scheduled_at) : params.scheduledAt,
|
|
69
|
-
postUrl: json.post_url ?? json.post?.post_url,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
async function publishViaByoPublisher(publisher, credentials, params) {
|
|
73
|
-
if (publisher === 'uploadpost')
|
|
74
|
-
return scheduleUploadPost(credentials, params);
|
|
75
|
-
throw new Error('Buffer publisher is not yet implemented. Connect UploadPost instead.');
|
|
76
|
-
}
|
|
77
16
|
export function computeAnalyticsMetrics(rows, eventCounts) {
|
|
78
17
|
const metrics = summarizeEmailRows(rows);
|
|
79
18
|
return { ...metrics, delivery_rate: metrics.delivery_rate ?? 0, open_rate: metrics.open_rate ?? 0,
|
|
@@ -1239,60 +1178,6 @@ export function registerAnalyticsTools(server) {
|
|
|
1239
1178
|
reputation_signals_30d: reputationSignals30d ?? 0,
|
|
1240
1179
|
});
|
|
1241
1180
|
});
|
|
1242
|
-
server.registerTool('abuse_monitor_override', {
|
|
1243
|
-
title: 'Abuse Monitor Override',
|
|
1244
|
-
annotations: {
|
|
1245
|
-
readOnlyHint: false,
|
|
1246
|
-
destructiveHint: false,
|
|
1247
|
-
idempotentHint: false,
|
|
1248
|
-
openWorldHint: false,
|
|
1249
|
-
},
|
|
1250
|
-
description: 'Create a temporary abuse monitor override for a paused site. Overrides expire automatically and are capped at 72 hours.',
|
|
1251
|
-
inputSchema: {
|
|
1252
|
-
siteId: z.string().uuid(),
|
|
1253
|
-
reason: z.string().min(3).max(500),
|
|
1254
|
-
ttlHours: z.number().int().min(1).max(72),
|
|
1255
|
-
},
|
|
1256
|
-
}, async ({ siteId, reason, ttlHours }) => {
|
|
1257
|
-
const projectId = getProjectId();
|
|
1258
|
-
const { data: site, error: siteErr } = await db()
|
|
1259
|
-
.from('sites')
|
|
1260
|
-
.select('id')
|
|
1261
|
-
.eq('id', siteId)
|
|
1262
|
-
.eq('project_id', projectId)
|
|
1263
|
-
.maybeSingle();
|
|
1264
|
-
if (siteErr || !site)
|
|
1265
|
-
return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
|
|
1266
|
-
const expiresAt = new Date(Date.now() + ttlHours * 3600 * 1000).toISOString();
|
|
1267
|
-
const { data: metrics } = await db()
|
|
1268
|
-
.from('tenant_email_metrics')
|
|
1269
|
-
.select('complaint_rate, pause_level')
|
|
1270
|
-
.eq('site_id', siteId)
|
|
1271
|
-
.maybeSingle();
|
|
1272
|
-
const { error } = await db().from('audit_log').insert({
|
|
1273
|
-
project_id: projectId,
|
|
1274
|
-
actor_type: 'mcp_tool',
|
|
1275
|
-
actor_id: 'abuse_monitor_override',
|
|
1276
|
-
action: 'abuse_override',
|
|
1277
|
-
resource_type: 'site',
|
|
1278
|
-
resource_id: siteId,
|
|
1279
|
-
entity_id: siteId,
|
|
1280
|
-
metadata: { reason, expires_at: expiresAt },
|
|
1281
|
-
details: { reason, expires_at: expiresAt },
|
|
1282
|
-
});
|
|
1283
|
-
if (error)
|
|
1284
|
-
return j({ error: error.message });
|
|
1285
|
-
return j({
|
|
1286
|
-
ok: true,
|
|
1287
|
-
site_id: siteId,
|
|
1288
|
-
expires_at: expiresAt,
|
|
1289
|
-
complaint_rate: metrics?.complaint_rate ?? null,
|
|
1290
|
-
pause_level: metrics?.pause_level ?? null,
|
|
1291
|
-
warning: Number(metrics?.complaint_rate ?? 0) >= 0.001
|
|
1292
|
-
? 'Complaint rate is still at or above 0.10%; use this override only for verified false positives.'
|
|
1293
|
-
: null,
|
|
1294
|
-
});
|
|
1295
|
-
});
|
|
1296
1181
|
server.registerTool('get_site_insights', {
|
|
1297
1182
|
title: 'Get Site Insights',
|
|
1298
1183
|
annotations: {
|
|
@@ -1333,46 +1218,6 @@ export function registerAnalyticsTools(server) {
|
|
|
1333
1218
|
const rank = { critical: 0, warning: 1, info: 2 };
|
|
1334
1219
|
return j((data ?? []).sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9)));
|
|
1335
1220
|
});
|
|
1336
|
-
server.registerTool('create_social_campaign', {
|
|
1337
|
-
title: 'Create Social Campaign',
|
|
1338
|
-
annotations: {
|
|
1339
|
-
readOnlyHint: false,
|
|
1340
|
-
destructiveHint: false,
|
|
1341
|
-
idempotentHint: false,
|
|
1342
|
-
openWorldHint: false,
|
|
1343
|
-
},
|
|
1344
|
-
description: 'Create a social media campaign with a UTM-tracked link. Returns the tracked URL to paste into the social post.',
|
|
1345
|
-
inputSchema: {
|
|
1346
|
-
name: z.string(),
|
|
1347
|
-
platform: z.enum(['instagram', 'linkedin', 'twitter', 'facebook', 'tiktok', 'youtube', 'pinterest', 'uploadpost', 'other']),
|
|
1348
|
-
destination_url: z.string().url(),
|
|
1349
|
-
site_id: z.string().uuid().optional(),
|
|
1350
|
-
utm_campaign: z.string().optional(),
|
|
1351
|
-
utm_content: z.string().optional(),
|
|
1352
|
-
notes: z.string().optional(),
|
|
1353
|
-
},
|
|
1354
|
-
}, async (p) => {
|
|
1355
|
-
const projectId = getProjectId();
|
|
1356
|
-
const utm = buildSocialTrackedUrl(p);
|
|
1357
|
-
const insert = {
|
|
1358
|
-
project_id: projectId,
|
|
1359
|
-
site_id: p.site_id ?? null,
|
|
1360
|
-
name: p.name,
|
|
1361
|
-
platform: p.platform,
|
|
1362
|
-
destination_url: p.destination_url,
|
|
1363
|
-
utm_source: utm.utm_source,
|
|
1364
|
-
utm_medium: utm.utm_medium,
|
|
1365
|
-
utm_campaign: utm.utm_campaign,
|
|
1366
|
-
utm_content: p.utm_content ?? null,
|
|
1367
|
-
tracked_url: utm.tracked_url,
|
|
1368
|
-
notes: p.notes ?? null,
|
|
1369
|
-
status: 'draft',
|
|
1370
|
-
};
|
|
1371
|
-
const { data, error } = await db().from('social_campaigns').insert(insert).select().single();
|
|
1372
|
-
if (error)
|
|
1373
|
-
return txt(`Error: ${error.message}`);
|
|
1374
|
-
return j({ id: data.id, tracked_url: data.tracked_url, campaign: data });
|
|
1375
|
-
});
|
|
1376
1221
|
server.registerTool('list_social_campaigns', {
|
|
1377
1222
|
title: 'List Social Campaigns',
|
|
1378
1223
|
annotations: {
|
|
@@ -1403,99 +1248,4 @@ export function registerAnalyticsTools(server) {
|
|
|
1403
1248
|
return txt(`Error: ${error.message}`);
|
|
1404
1249
|
return j({ campaigns: data ?? [] });
|
|
1405
1250
|
});
|
|
1406
|
-
server.registerTool('schedule_social_post', {
|
|
1407
|
-
title: 'Schedule Social Post',
|
|
1408
|
-
annotations: {
|
|
1409
|
-
readOnlyHint: false,
|
|
1410
|
-
destructiveHint: true,
|
|
1411
|
-
idempotentHint: false,
|
|
1412
|
-
openWorldHint: true,
|
|
1413
|
-
},
|
|
1414
|
-
description: 'Schedule a social campaign post via a connected BYO publisher (UploadPost, Buffer). Returns publisherPostId on success.',
|
|
1415
|
-
inputSchema: {
|
|
1416
|
-
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 the same arguments plus this approval_id to run the action.'),
|
|
1417
|
-
campaign_id: z.string().uuid().describe('UUID of the social campaign in Sendinel'),
|
|
1418
|
-
content: z.string().min(1).describe('Post body text'),
|
|
1419
|
-
publisher: z.enum(socialPublishers).describe('Which BYO publisher to route through'),
|
|
1420
|
-
scheduled_at: z.string().optional().describe('ISO 8601 datetime. Omit for immediate publish.'),
|
|
1421
|
-
media_urls: z.array(z.string().url()).optional().describe('Optional image/video URLs'),
|
|
1422
|
-
},
|
|
1423
|
-
}, withApproval('schedule_social_post', async (p) => {
|
|
1424
|
-
const projectId = getProjectId();
|
|
1425
|
-
const scheduledAt = p.scheduled_at ? new Date(p.scheduled_at) : undefined;
|
|
1426
|
-
if (scheduledAt && Number.isNaN(scheduledAt.getTime()))
|
|
1427
|
-
return txt('Error: scheduled_at must be a valid ISO 8601 datetime.');
|
|
1428
|
-
const admin = adminDb();
|
|
1429
|
-
const { data: campaign, error: campaignError } = await admin
|
|
1430
|
-
.from('social_campaigns')
|
|
1431
|
-
.select('id, project_id, platform, tracked_url')
|
|
1432
|
-
.eq('project_id', projectId)
|
|
1433
|
-
.eq('id', p.campaign_id)
|
|
1434
|
-
.maybeSingle();
|
|
1435
|
-
if (campaignError)
|
|
1436
|
-
return txt(`Error: ${campaignError.message}`);
|
|
1437
|
-
if (!campaign)
|
|
1438
|
-
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
1439
|
-
const { data: project, error: projectError } = await admin
|
|
1440
|
-
.from('projects')
|
|
1441
|
-
.select('org_id')
|
|
1442
|
-
.eq('id', campaign.project_id)
|
|
1443
|
-
.maybeSingle();
|
|
1444
|
-
if (projectError)
|
|
1445
|
-
return txt(`Error: ${projectError.message}`);
|
|
1446
|
-
if (!project?.org_id)
|
|
1447
|
-
return j({ error: 'Project not found', code: 'NOT_FOUND' });
|
|
1448
|
-
const { data: connection, error: connectionError } = await admin
|
|
1449
|
-
.from('social_platform_connections')
|
|
1450
|
-
.select('encrypted_access_token')
|
|
1451
|
-
.eq('org_id', project.org_id)
|
|
1452
|
-
.eq('platform', p.publisher)
|
|
1453
|
-
.maybeSingle();
|
|
1454
|
-
if (connectionError)
|
|
1455
|
-
return txt(`Error: ${connectionError.message}`);
|
|
1456
|
-
if (!connection?.encrypted_access_token) {
|
|
1457
|
-
return j({ error: `No ${p.publisher} connection found. Connect it in Settings -> Integrations.`, code: 'NO_CONNECTION' });
|
|
1458
|
-
}
|
|
1459
|
-
try {
|
|
1460
|
-
const result = await publishViaByoPublisher(p.publisher, decrypt(connection.encrypted_access_token), {
|
|
1461
|
-
content: p.content,
|
|
1462
|
-
platform: campaign.platform,
|
|
1463
|
-
scheduledAt,
|
|
1464
|
-
mediaUrls: p.media_urls,
|
|
1465
|
-
link: campaign.tracked_url,
|
|
1466
|
-
});
|
|
1467
|
-
const publishStatus = result.scheduledAt || scheduledAt ? 'scheduled' : 'published';
|
|
1468
|
-
const { error: updateError } = await admin
|
|
1469
|
-
.from('social_campaigns')
|
|
1470
|
-
.update({
|
|
1471
|
-
publisher: p.publisher,
|
|
1472
|
-
publisher_post_id: result.publisherPostId,
|
|
1473
|
-
scheduled_at: result.scheduledAt?.toISOString() ?? scheduledAt?.toISOString() ?? null,
|
|
1474
|
-
publish_status: publishStatus,
|
|
1475
|
-
publish_error: null,
|
|
1476
|
-
platform_post_url: result.postUrl ?? null,
|
|
1477
|
-
status: 'active',
|
|
1478
|
-
updated_at: new Date().toISOString(),
|
|
1479
|
-
})
|
|
1480
|
-
.eq('project_id', projectId)
|
|
1481
|
-
.eq('id', p.campaign_id);
|
|
1482
|
-
if (updateError)
|
|
1483
|
-
return txt(`Error: ${updateError.message}`);
|
|
1484
|
-
return j({ success: true, publisherPostId: result.publisherPostId, publish_status: publishStatus, post_url: result.postUrl ?? null });
|
|
1485
|
-
}
|
|
1486
|
-
catch (err) {
|
|
1487
|
-
const message = err instanceof Error ? err.message : 'Publish failed';
|
|
1488
|
-
await admin
|
|
1489
|
-
.from('social_campaigns')
|
|
1490
|
-
.update({
|
|
1491
|
-
publisher: p.publisher,
|
|
1492
|
-
publish_status: 'failed',
|
|
1493
|
-
publish_error: message.slice(0, 500),
|
|
1494
|
-
updated_at: new Date().toISOString(),
|
|
1495
|
-
})
|
|
1496
|
-
.eq('project_id', projectId)
|
|
1497
|
-
.eq('id', p.campaign_id);
|
|
1498
|
-
return txt(`Error: ${message}`);
|
|
1499
|
-
}
|
|
1500
|
-
}));
|
|
1501
1251
|
}
|
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 { assertSendAccepted, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, readAllRows, } from '@sendinel/domain';
|
|
3
|
+
import { assertSendAccepted, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, 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
|
|
@@ -2342,7 +2342,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2342
2342
|
// Site verification
|
|
2343
2343
|
const { data: site } = await db()
|
|
2344
2344
|
.from('sites')
|
|
2345
|
-
.select('from_email, from_name, name, brand_voice')
|
|
2345
|
+
.select('from_email, from_name, name, brand_voice, physical_address')
|
|
2346
2346
|
.eq('id', site_id)
|
|
2347
2347
|
.eq('project_id', projectId)
|
|
2348
2348
|
.maybeSingle();
|
|
@@ -2351,6 +2351,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2351
2351
|
let resolvedSubject = subject;
|
|
2352
2352
|
let resolvedHtml = html;
|
|
2353
2353
|
let resolvedText = text;
|
|
2354
|
+
let brandWarningCodes = [];
|
|
2354
2355
|
if (template_id) {
|
|
2355
2356
|
const { data: template, error: templateError } = await db()
|
|
2356
2357
|
.from('campaign_templates')
|
|
@@ -2366,7 +2367,10 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2366
2367
|
const projectBrand = (project?.brand_settings ?? null);
|
|
2367
2368
|
const siteBrand = (site.brand_voice ?? null);
|
|
2368
2369
|
const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
|
|
2369
|
-
const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined);
|
|
2370
|
+
const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined, { address: site.physical_address ?? undefined });
|
|
2371
|
+
brandWarningCodes = brandWarnings(mergedBrand);
|
|
2372
|
+
if (brandWarningCodes.length)
|
|
2373
|
+
console.warn('MCP send brand warnings', { projectId, site_id, warnings: brandWarningCodes });
|
|
2370
2374
|
const vars = template_vars ?? {};
|
|
2371
2375
|
const renderVars = { ...brandVars, ...vars };
|
|
2372
2376
|
const subjectFallingBack = !subject?.trim();
|
|
@@ -2378,6 +2382,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2378
2382
|
const satisfied = new Set([
|
|
2379
2383
|
...Object.keys(vars),
|
|
2380
2384
|
'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
|
|
2385
|
+
'brand_logo_html', 'brand_font', 'brand_address',
|
|
2381
2386
|
...Object.keys(NAME_FALLBACKS),
|
|
2382
2387
|
]);
|
|
2383
2388
|
const missing = requiredVars.filter((name) => !satisfied.has(name));
|
|
@@ -2385,14 +2390,14 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2385
2390
|
return txt(`Error: Missing required template variable(s): ${missing.join(', ')}`);
|
|
2386
2391
|
}
|
|
2387
2392
|
if (template.format === 'text') {
|
|
2388
|
-
resolvedText = renderTemplateVars(template.body_text ?? '', renderVars);
|
|
2393
|
+
resolvedText = renderTemplateVars(template.body_text ?? '', renderVars, { brandTokens: brandVars });
|
|
2389
2394
|
resolvedHtml = undefined;
|
|
2390
2395
|
}
|
|
2391
2396
|
else {
|
|
2392
|
-
resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars);
|
|
2397
|
+
resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars, { brandTokens: brandVars });
|
|
2393
2398
|
resolvedText = text ?? stripHtml(resolvedHtml);
|
|
2394
2399
|
}
|
|
2395
|
-
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars);
|
|
2400
|
+
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
|
|
2396
2401
|
}
|
|
2397
2402
|
if (!resolvedSubject?.trim())
|
|
2398
2403
|
return txt('Error: subject is required');
|
|
@@ -2432,6 +2437,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2432
2437
|
}).select('id').single();
|
|
2433
2438
|
return j({
|
|
2434
2439
|
sent: true,
|
|
2440
|
+
...(brandWarningCodes.length ? { warnings: brandWarningCodes } : {}),
|
|
2435
2441
|
id: logRow?.id,
|
|
2436
2442
|
to,
|
|
2437
2443
|
provider: sendResult.provider,
|
|
@@ -2525,7 +2531,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2525
2531
|
// Site verification
|
|
2526
2532
|
const { data: site } = await db()
|
|
2527
2533
|
.from('sites')
|
|
2528
|
-
.select('from_email, from_name, name, brand_voice')
|
|
2534
|
+
.select('from_email, from_name, name, brand_voice, physical_address')
|
|
2529
2535
|
.eq('id', site_id)
|
|
2530
2536
|
.eq('project_id', projectId)
|
|
2531
2537
|
.maybeSingle();
|
|
@@ -2534,6 +2540,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2534
2540
|
let resolvedSubject = subject;
|
|
2535
2541
|
let resolvedHtml = html;
|
|
2536
2542
|
let resolvedText = text;
|
|
2543
|
+
let brandWarningCodes = [];
|
|
2537
2544
|
if (template_id) {
|
|
2538
2545
|
const { data: template, error: templateError } = await db()
|
|
2539
2546
|
.from('campaign_templates')
|
|
@@ -2549,7 +2556,10 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2549
2556
|
const projectBrand = (project?.brand_settings ?? null);
|
|
2550
2557
|
const siteBrand = (site.brand_voice ?? null);
|
|
2551
2558
|
const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
|
|
2552
|
-
const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined);
|
|
2559
|
+
const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined, { address: site.physical_address ?? undefined });
|
|
2560
|
+
brandWarningCodes = brandWarnings(mergedBrand);
|
|
2561
|
+
if (brandWarningCodes.length)
|
|
2562
|
+
console.warn('MCP send brand warnings', { projectId, site_id, warnings: brandWarningCodes });
|
|
2553
2563
|
const vars = template_vars ?? {};
|
|
2554
2564
|
const renderVars = { ...brandVars, ...vars };
|
|
2555
2565
|
const subjectFallingBack = !subject?.trim();
|
|
@@ -2561,6 +2571,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2561
2571
|
const satisfied = new Set([
|
|
2562
2572
|
...Object.keys(vars),
|
|
2563
2573
|
'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
|
|
2574
|
+
'brand_logo_html', 'brand_font', 'brand_address',
|
|
2564
2575
|
...Object.keys(NAME_FALLBACKS),
|
|
2565
2576
|
]);
|
|
2566
2577
|
const missing = requiredVars.filter((name) => !satisfied.has(name));
|
|
@@ -2568,14 +2579,14 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2568
2579
|
return txt(`Error: Missing required template variable(s): ${missing.join(', ')}`);
|
|
2569
2580
|
}
|
|
2570
2581
|
if (template.format === 'text') {
|
|
2571
|
-
resolvedText = renderTemplateVars(template.body_text ?? '', renderVars);
|
|
2582
|
+
resolvedText = renderTemplateVars(template.body_text ?? '', renderVars, { brandTokens: brandVars });
|
|
2572
2583
|
resolvedHtml = undefined;
|
|
2573
2584
|
}
|
|
2574
2585
|
else {
|
|
2575
|
-
resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars);
|
|
2586
|
+
resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars, { brandTokens: brandVars });
|
|
2576
2587
|
resolvedText = text ?? stripHtml(resolvedHtml);
|
|
2577
2588
|
}
|
|
2578
|
-
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars);
|
|
2589
|
+
resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars, { brandTokens: brandVars });
|
|
2579
2590
|
}
|
|
2580
2591
|
if (!resolvedSubject?.trim())
|
|
2581
2592
|
return txt('Error: subject is required');
|
|
@@ -2666,6 +2677,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
|
|
|
2666
2677
|
}).eq('id', logRow.id);
|
|
2667
2678
|
return j({
|
|
2668
2679
|
sent: true,
|
|
2680
|
+
...(brandWarningCodes.length ? { warnings: brandWarningCodes } : {}),
|
|
2669
2681
|
id: logRow.id,
|
|
2670
2682
|
reply_action_id: minted.id,
|
|
2671
2683
|
correlation_id,
|
|
@@ -1054,4 +1054,58 @@ export function registerDeliveryOpsTools(server) {
|
|
|
1054
1054
|
return j({ error: v1ErrorMessage(result.error), status: result.status });
|
|
1055
1055
|
return j(result.data);
|
|
1056
1056
|
});
|
|
1057
|
+
server.registerTool('abuse_monitor_override', {
|
|
1058
|
+
title: 'Abuse Monitor Override',
|
|
1059
|
+
annotations: {
|
|
1060
|
+
readOnlyHint: false,
|
|
1061
|
+
destructiveHint: false,
|
|
1062
|
+
idempotentHint: false,
|
|
1063
|
+
openWorldHint: false,
|
|
1064
|
+
},
|
|
1065
|
+
description: 'Create a temporary abuse monitor override for a paused site. Overrides expire automatically and are capped at 72 hours.',
|
|
1066
|
+
inputSchema: {
|
|
1067
|
+
siteId: z.string().uuid(),
|
|
1068
|
+
reason: z.string().min(3).max(500),
|
|
1069
|
+
ttlHours: z.number().int().min(1).max(72),
|
|
1070
|
+
},
|
|
1071
|
+
}, async ({ siteId, reason, ttlHours }) => {
|
|
1072
|
+
const projectId = getProjectId();
|
|
1073
|
+
const { data: site, error: siteErr } = await db()
|
|
1074
|
+
.from('sites')
|
|
1075
|
+
.select('id')
|
|
1076
|
+
.eq('id', siteId)
|
|
1077
|
+
.eq('project_id', projectId)
|
|
1078
|
+
.maybeSingle();
|
|
1079
|
+
if (siteErr || !site)
|
|
1080
|
+
return j({ error: siteErr?.message ?? 'Site not found', code: 'NOT_FOUND' });
|
|
1081
|
+
const expiresAt = new Date(Date.now() + ttlHours * 3600 * 1000).toISOString();
|
|
1082
|
+
const { data: metrics } = await db()
|
|
1083
|
+
.from('tenant_email_metrics')
|
|
1084
|
+
.select('complaint_rate, pause_level')
|
|
1085
|
+
.eq('site_id', siteId)
|
|
1086
|
+
.maybeSingle();
|
|
1087
|
+
const { error } = await db().from('audit_log').insert({
|
|
1088
|
+
project_id: projectId,
|
|
1089
|
+
actor_type: 'mcp_tool',
|
|
1090
|
+
actor_id: 'abuse_monitor_override',
|
|
1091
|
+
action: 'abuse_override',
|
|
1092
|
+
resource_type: 'site',
|
|
1093
|
+
resource_id: siteId,
|
|
1094
|
+
entity_id: siteId,
|
|
1095
|
+
metadata: { reason, expires_at: expiresAt },
|
|
1096
|
+
details: { reason, expires_at: expiresAt },
|
|
1097
|
+
});
|
|
1098
|
+
if (error)
|
|
1099
|
+
return j({ error: error.message });
|
|
1100
|
+
return j({
|
|
1101
|
+
ok: true,
|
|
1102
|
+
site_id: siteId,
|
|
1103
|
+
expires_at: expiresAt,
|
|
1104
|
+
complaint_rate: metrics?.complaint_rate ?? null,
|
|
1105
|
+
pause_level: metrics?.pause_level ?? null,
|
|
1106
|
+
warning: Number(metrics?.complaint_rate ?? 0) >= 0.001
|
|
1107
|
+
? 'Complaint rate is still at or above 0.10%; use this override only for verified false positives.'
|
|
1108
|
+
: null,
|
|
1109
|
+
});
|
|
1110
|
+
});
|
|
1057
1111
|
}
|
package/build/tools/gateway.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
export interface GatewayOptions {
|
|
3
3
|
scopes?: string[];
|
|
4
4
|
toolGroups?: string[];
|
|
5
|
+
toolAllowlist?: string[];
|
|
5
6
|
}
|
|
6
7
|
/**
|
|
7
8
|
* Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
|
package/build/tools/gateway.js
CHANGED
|
@@ -25,12 +25,12 @@ export function matchesToolQuery(entry, query) {
|
|
|
25
25
|
const SEARCH_LIMIT = 10;
|
|
26
26
|
async function buildCatalog(options) {
|
|
27
27
|
const innerServer = new McpServer({ name: 'sendinel-gateway-inner', version: '1.0.0' });
|
|
28
|
-
registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups });
|
|
28
|
+
registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups, toolAllowlist: options.toolAllowlist });
|
|
29
29
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
30
30
|
const client = new Client({ name: 'sendinel-gateway', version: '1.0.0' });
|
|
31
31
|
await innerServer.connect(serverTransport);
|
|
32
32
|
await client.connect(clientTransport);
|
|
33
|
-
const toolGroupMap = getRegisteredToolGroups({ scopes: options.scopes, toolGroups: options.toolGroups });
|
|
33
|
+
const toolGroupMap = getRegisteredToolGroups({ scopes: options.scopes, toolGroups: options.toolGroups, toolAllowlist: options.toolAllowlist });
|
|
34
34
|
const { tools } = await client.listTools();
|
|
35
35
|
const entries = tools
|
|
36
36
|
.map((tool) => ({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
2
|
import { withApproval } from '../lib/action-approvals.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { db } from '../db.js';
|
|
4
|
+
import { db, adminDb } from '../db.js';
|
|
5
|
+
import { decrypt } from '../crypto.js';
|
|
5
6
|
import { getProjectId } from '../project.js';
|
|
6
7
|
import { getAnthropicModelId } from '../anthropic-model.js';
|
|
7
8
|
import { assertUsableStopReason } from '@sendinel/domain';
|
|
@@ -12,6 +13,66 @@ import { resolveSurveyShortLink } from '../lib/survey-link-resolver.js';
|
|
|
12
13
|
import { registerSocialReplyTools } from './social-replies.js';
|
|
13
14
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
14
15
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
16
|
+
const socialPlatforms = ['instagram', 'linkedin', 'twitter', 'facebook', 'tiktok', 'youtube', 'pinterest', 'uploadpost', 'other'];
|
|
17
|
+
const socialPublishers = ['uploadpost', 'buffer'];
|
|
18
|
+
const UPLOADPOST_API_BASE = process.env.UPLOADPOST_API_BASE || 'https://api.upload-post.com/api';
|
|
19
|
+
function slugifyCampaign(value) {
|
|
20
|
+
return value
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '')
|
|
25
|
+
.slice(0, 100) || 'campaign';
|
|
26
|
+
}
|
|
27
|
+
function buildSocialTrackedUrl(params) {
|
|
28
|
+
const url = new URL(params.destination_url);
|
|
29
|
+
const utmCampaign = params.utm_campaign?.trim() || slugifyCampaign(params.name);
|
|
30
|
+
url.searchParams.set('utm_source', params.platform);
|
|
31
|
+
url.searchParams.set('utm_medium', 'social');
|
|
32
|
+
url.searchParams.set('utm_campaign', utmCampaign);
|
|
33
|
+
if (params.utm_content?.trim())
|
|
34
|
+
url.searchParams.set('utm_content', params.utm_content.trim());
|
|
35
|
+
return {
|
|
36
|
+
tracked_url: url.toString(),
|
|
37
|
+
utm_source: params.platform,
|
|
38
|
+
utm_medium: 'social',
|
|
39
|
+
utm_campaign: utmCampaign,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function scheduleUploadPost(apiKey, params) {
|
|
43
|
+
const res = await fetch(`${UPLOADPOST_API_BASE}/uploadposts/schedule`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Apikey ${apiKey}`,
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
platform: params.platform,
|
|
51
|
+
content: params.link ? `${params.content}\n\n${params.link}` : params.content,
|
|
52
|
+
scheduled_at: params.scheduledAt?.toISOString(),
|
|
53
|
+
media_urls: params.mediaUrls,
|
|
54
|
+
link: params.link ?? undefined,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const text = await res.text().catch(() => '');
|
|
59
|
+
throw new Error(`UploadPost schedule failed: ${res.status}${text ? ` ${text.slice(0, 200)}` : ''}`);
|
|
60
|
+
}
|
|
61
|
+
const json = await res.json();
|
|
62
|
+
const publisherPostId = json.id ?? json.request_id ?? json.post_id;
|
|
63
|
+
if (!publisherPostId)
|
|
64
|
+
throw new Error('UploadPost schedule response did not include a post id.');
|
|
65
|
+
return {
|
|
66
|
+
publisherPostId: String(publisherPostId),
|
|
67
|
+
scheduledAt: json.scheduled_at ? new Date(json.scheduled_at) : params.scheduledAt,
|
|
68
|
+
postUrl: json.post_url ?? json.post?.post_url,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function publishViaByoPublisher(publisher, credentials, params) {
|
|
72
|
+
if (publisher === 'uploadpost')
|
|
73
|
+
return scheduleUploadPost(credentials, params);
|
|
74
|
+
throw new Error('Buffer publisher is not yet implemented. Connect UploadPost instead.');
|
|
75
|
+
}
|
|
15
76
|
const PLATFORMS = [
|
|
16
77
|
'instagram', 'linkedin', 'twitter', 'x', 'facebook', 'tiktok',
|
|
17
78
|
'youtube', 'pinterest', 'threads', 'reddit', 'bluesky', 'google_business',
|
|
@@ -577,4 +638,139 @@ export function registerSocialPostTools(server) {
|
|
|
577
638
|
fireWebhookEvent('social.post.archived', { social_post_id: data.id, title: data.title }, getProjectId()).catch(() => { });
|
|
578
639
|
return j(data);
|
|
579
640
|
}));
|
|
641
|
+
server.registerTool('create_social_campaign', {
|
|
642
|
+
title: 'Create Social Campaign',
|
|
643
|
+
annotations: {
|
|
644
|
+
readOnlyHint: false,
|
|
645
|
+
destructiveHint: false,
|
|
646
|
+
idempotentHint: false,
|
|
647
|
+
openWorldHint: false,
|
|
648
|
+
},
|
|
649
|
+
description: 'Create a social media campaign with a UTM-tracked link. Returns the tracked URL to paste into the social post.',
|
|
650
|
+
inputSchema: {
|
|
651
|
+
name: z.string(),
|
|
652
|
+
platform: z.enum(['instagram', 'linkedin', 'twitter', 'facebook', 'tiktok', 'youtube', 'pinterest', 'uploadpost', 'other']),
|
|
653
|
+
destination_url: z.string().url(),
|
|
654
|
+
site_id: z.string().uuid().optional(),
|
|
655
|
+
utm_campaign: z.string().optional(),
|
|
656
|
+
utm_content: z.string().optional(),
|
|
657
|
+
notes: z.string().optional(),
|
|
658
|
+
},
|
|
659
|
+
}, async (p) => {
|
|
660
|
+
const projectId = getProjectId();
|
|
661
|
+
const utm = buildSocialTrackedUrl(p);
|
|
662
|
+
const insert = {
|
|
663
|
+
project_id: projectId,
|
|
664
|
+
site_id: p.site_id ?? null,
|
|
665
|
+
name: p.name,
|
|
666
|
+
platform: p.platform,
|
|
667
|
+
destination_url: p.destination_url,
|
|
668
|
+
utm_source: utm.utm_source,
|
|
669
|
+
utm_medium: utm.utm_medium,
|
|
670
|
+
utm_campaign: utm.utm_campaign,
|
|
671
|
+
utm_content: p.utm_content ?? null,
|
|
672
|
+
tracked_url: utm.tracked_url,
|
|
673
|
+
notes: p.notes ?? null,
|
|
674
|
+
status: 'draft',
|
|
675
|
+
};
|
|
676
|
+
const { data, error } = await db().from('social_campaigns').insert(insert).select().single();
|
|
677
|
+
if (error)
|
|
678
|
+
return txt(`Error: ${error.message}`);
|
|
679
|
+
return j({ id: data.id, tracked_url: data.tracked_url, campaign: data });
|
|
680
|
+
});
|
|
681
|
+
server.registerTool('schedule_social_post', {
|
|
682
|
+
title: 'Schedule Social Post',
|
|
683
|
+
annotations: {
|
|
684
|
+
readOnlyHint: false,
|
|
685
|
+
destructiveHint: true,
|
|
686
|
+
idempotentHint: false,
|
|
687
|
+
openWorldHint: true,
|
|
688
|
+
},
|
|
689
|
+
description: 'Schedule a social campaign post via a connected BYO publisher (UploadPost, Buffer). Returns publisherPostId on success.',
|
|
690
|
+
inputSchema: {
|
|
691
|
+
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 the same arguments plus this approval_id to run the action.'),
|
|
692
|
+
campaign_id: z.string().uuid().describe('UUID of the social campaign in Sendinel'),
|
|
693
|
+
content: z.string().min(1).describe('Post body text'),
|
|
694
|
+
publisher: z.enum(socialPublishers).describe('Which BYO publisher to route through'),
|
|
695
|
+
scheduled_at: z.string().optional().describe('ISO 8601 datetime. Omit for immediate publish.'),
|
|
696
|
+
media_urls: z.array(z.string().url()).optional().describe('Optional image/video URLs'),
|
|
697
|
+
},
|
|
698
|
+
}, withApproval('schedule_social_post', async (p) => {
|
|
699
|
+
const projectId = getProjectId();
|
|
700
|
+
const scheduledAt = p.scheduled_at ? new Date(p.scheduled_at) : undefined;
|
|
701
|
+
if (scheduledAt && Number.isNaN(scheduledAt.getTime()))
|
|
702
|
+
return txt('Error: scheduled_at must be a valid ISO 8601 datetime.');
|
|
703
|
+
const admin = adminDb();
|
|
704
|
+
const { data: campaign, error: campaignError } = await admin
|
|
705
|
+
.from('social_campaigns')
|
|
706
|
+
.select('id, project_id, platform, tracked_url')
|
|
707
|
+
.eq('project_id', projectId)
|
|
708
|
+
.eq('id', p.campaign_id)
|
|
709
|
+
.maybeSingle();
|
|
710
|
+
if (campaignError)
|
|
711
|
+
return txt(`Error: ${campaignError.message}`);
|
|
712
|
+
if (!campaign)
|
|
713
|
+
return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
|
|
714
|
+
const { data: project, error: projectError } = await admin
|
|
715
|
+
.from('projects')
|
|
716
|
+
.select('org_id')
|
|
717
|
+
.eq('id', campaign.project_id)
|
|
718
|
+
.maybeSingle();
|
|
719
|
+
if (projectError)
|
|
720
|
+
return txt(`Error: ${projectError.message}`);
|
|
721
|
+
if (!project?.org_id)
|
|
722
|
+
return j({ error: 'Project not found', code: 'NOT_FOUND' });
|
|
723
|
+
const { data: connection, error: connectionError } = await admin
|
|
724
|
+
.from('social_platform_connections')
|
|
725
|
+
.select('encrypted_access_token')
|
|
726
|
+
.eq('org_id', project.org_id)
|
|
727
|
+
.eq('platform', p.publisher)
|
|
728
|
+
.maybeSingle();
|
|
729
|
+
if (connectionError)
|
|
730
|
+
return txt(`Error: ${connectionError.message}`);
|
|
731
|
+
if (!connection?.encrypted_access_token) {
|
|
732
|
+
return j({ error: `No ${p.publisher} connection found. Connect it in Settings -> Integrations.`, code: 'NO_CONNECTION' });
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
const result = await publishViaByoPublisher(p.publisher, decrypt(connection.encrypted_access_token), {
|
|
736
|
+
content: p.content,
|
|
737
|
+
platform: campaign.platform,
|
|
738
|
+
scheduledAt,
|
|
739
|
+
mediaUrls: p.media_urls,
|
|
740
|
+
link: campaign.tracked_url,
|
|
741
|
+
});
|
|
742
|
+
const publishStatus = result.scheduledAt || scheduledAt ? 'scheduled' : 'published';
|
|
743
|
+
const { error: updateError } = await admin
|
|
744
|
+
.from('social_campaigns')
|
|
745
|
+
.update({
|
|
746
|
+
publisher: p.publisher,
|
|
747
|
+
publisher_post_id: result.publisherPostId,
|
|
748
|
+
scheduled_at: result.scheduledAt?.toISOString() ?? scheduledAt?.toISOString() ?? null,
|
|
749
|
+
publish_status: publishStatus,
|
|
750
|
+
publish_error: null,
|
|
751
|
+
platform_post_url: result.postUrl ?? null,
|
|
752
|
+
status: 'active',
|
|
753
|
+
updated_at: new Date().toISOString(),
|
|
754
|
+
})
|
|
755
|
+
.eq('project_id', projectId)
|
|
756
|
+
.eq('id', p.campaign_id);
|
|
757
|
+
if (updateError)
|
|
758
|
+
return txt(`Error: ${updateError.message}`);
|
|
759
|
+
return j({ success: true, publisherPostId: result.publisherPostId, publish_status: publishStatus, post_url: result.postUrl ?? null });
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
const message = err instanceof Error ? err.message : 'Publish failed';
|
|
763
|
+
await admin
|
|
764
|
+
.from('social_campaigns')
|
|
765
|
+
.update({
|
|
766
|
+
publisher: p.publisher,
|
|
767
|
+
publish_status: 'failed',
|
|
768
|
+
publish_error: message.slice(0, 500),
|
|
769
|
+
updated_at: new Date().toISOString(),
|
|
770
|
+
})
|
|
771
|
+
.eq('project_id', projectId)
|
|
772
|
+
.eq('id', p.campaign_id);
|
|
773
|
+
return txt(`Error: ${message}`);
|
|
774
|
+
}
|
|
775
|
+
}));
|
|
580
776
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.93",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"jose": "^6.2.12",
|
|
36
36
|
"qrcode": "^1.5.4",
|
|
37
37
|
"resend": "^6.32.0",
|
|
38
|
-
"undici": "^8.
|
|
38
|
+
"undici": "^8.12.2",
|
|
39
39
|
"zod": "^4.4.3"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|