@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 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` | 18 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue 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` | 24 | Domains, DNSBL, deliverability diagnostics, queue, DLQ, exports, blackout dates |
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` | 15 | Social posts, engagement reads, and LinkedIn reply proposals for human review |
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
  }
@@ -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
  }
@@ -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
  }
@@ -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
@@ -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.91",
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.10.0",
38
+ "undici": "^8.12.2",
39
39
  "zod": "^4.4.3"
40
40
  },
41
41
  "devDependencies": {