@sendinel/mcp-server 1.0.90 → 1.0.92

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/audit.js CHANGED
@@ -36,6 +36,7 @@ export async function logAudit(opts) {
36
36
  resource_id: opts.resourceId ?? null,
37
37
  details: {
38
38
  ...(opts.details ?? {}),
39
+ ...(context?.actorType === 'synchronex_scoped_jwt' ? { kid: context.kid ?? null } : {}),
39
40
  ...(context?.workspaceId ? { workspace_id: context.workspaceId } : {}),
40
41
  // #4004: same actor + agent identity the dashboard records in api_usage. Only fields known for
41
42
  // this request are written; nothing is defaulted.
package/build/auth.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface ApiKeyInfo {
8
8
  authMethod?: 'api_key' | 'scoped_jwt';
9
9
  actsAsUserId?: string | null;
10
10
  workspaceId?: string | null;
11
+ kid?: string | null;
11
12
  }
12
13
  export declare class McpAuthenticationError extends Error {
13
14
  readonly status: 401 | 403 | 503;
package/build/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { decodeProtectedHeader, errors } from 'jose';
1
2
  import { adminDb } from './db.js';
2
3
  import { ALL_TOOL_GROUPS } from './server.js';
3
4
  import { looksLikeScopedJwt, scopedJwtVerificationEnabled, verifyScopedToken, } from './scoped-token.js';
@@ -82,7 +83,15 @@ export async function authenticateBearer(token, overrideProjectId) {
82
83
  try {
83
84
  claims = await verifyScopedToken(token);
84
85
  }
85
- catch {
86
+ catch (error) {
87
+ let kid = null;
88
+ try {
89
+ const header = decodeProtectedHeader(token);
90
+ kid = typeof header.kid === 'string' ? header.kid.slice(0, 64) : null;
91
+ }
92
+ catch { /* Unreadable headers have an unknown key id. */ }
93
+ const code = error instanceof errors.JOSEError ? error.code : null;
94
+ console.warn('[scoped-jwt] verification failed', { kid, code });
86
95
  throw new McpAuthenticationError('Invalid or expired token', 401, 'INVALID_SCOPED_TOKEN');
87
96
  }
88
97
  if (claims.scope === 'data'
@@ -107,6 +116,7 @@ export async function authenticateBearer(token, overrideProjectId) {
107
116
  action: 'mcp.authenticate',
108
117
  resource_type: 'mcp_transport',
109
118
  details: {
119
+ kid: claims.kid ?? null,
110
120
  workspace_id: claims.workspaceId,
111
121
  token_scope: claims.scope,
112
122
  },
@@ -120,6 +130,7 @@ export async function authenticateBearer(token, overrideProjectId) {
120
130
  authMethod: 'scoped_jwt',
121
131
  actsAsUserId: claims.userId,
122
132
  workspaceId: claims.workspaceId,
133
+ kid: claims.kid,
123
134
  };
124
135
  }
125
136
  let info = await validateKey(token);
package/build/db.d.ts CHANGED
@@ -44,6 +44,7 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
44
44
  agentLabel?: string | null;
45
45
  keyId?: string | null;
46
46
  workspaceId?: string | null;
47
+ kid?: string | null;
47
48
  v1Caller?: V1Caller;
48
49
  }): Promise<T>;
49
50
  export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T): Promise<T>;
@@ -65,6 +66,7 @@ export declare function requestContext(): {
65
66
  agentLabel?: string | null;
66
67
  keyId?: string | null;
67
68
  workspaceId?: string | null;
69
+ kid?: string | null;
68
70
  v1Caller?: V1Caller;
69
71
  } | undefined;
70
72
  export declare function projectClientForWriteback(): any | null;
package/build/db.js CHANGED
@@ -75,6 +75,7 @@ export function requestContext() {
75
75
  agentLabel: ctx.agentLabel,
76
76
  keyId: ctx.keyId,
77
77
  workspaceId: ctx.workspaceId,
78
+ kid: ctx.kid,
78
79
  v1Caller: ctx.v1Caller,
79
80
  }
80
81
  : undefined;
package/build/index.js CHANGED
@@ -115,6 +115,7 @@ async function runHttp() {
115
115
  actorId: info.actsAsUserId ?? null,
116
116
  authKind: info.authMethod === 'scoped_jwt' ? 'scoped_jwt' : 'api_key',
117
117
  workspaceId: info.workspaceId ?? null,
118
+ kid: info.kid ?? null,
118
119
  });
119
120
  });
120
121
  app.get('/health', (_req, res) => res.json({ ok: true }));
@@ -1,6 +1,7 @@
1
1
  export declare const SCOPED_TOKEN_SCOPES: readonly ['data', 'service:provision'];
2
2
  export type ScopedTokenScope = (typeof SCOPED_TOKEN_SCOPES)[number];
3
3
  export type ScopedTokenClaims = {
4
+ kid: string | null;
4
5
  userId: string;
5
6
  projectId: string | null;
6
7
  workspaceId: string | null;
@@ -1,4 +1,4 @@
1
- import { createRemoteJWKSet, jwtVerify } from 'jose';
1
+ import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from 'jose';
2
2
  const ISSUER = 'synchronex';
3
3
  const AUDIENCE = 'sendinel';
4
4
  const ALGORITHM = 'RS256';
@@ -50,7 +50,9 @@ export async function verifyScopedToken(token) {
50
50
  if (payload.workspace_id !== null && payload.workspace_id !== undefined && typeof payload.workspace_id !== 'string') {
51
51
  throw new Error('Scoped token workspace claim is invalid');
52
52
  }
53
+ const { kid } = decodeProtectedHeader(token);
53
54
  return {
55
+ kid: typeof kid === 'string' ? kid : null,
54
56
  userId,
55
57
  projectId,
56
58
  workspaceId,
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
  }
@@ -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.90",
3
+ "version": "1.0.92",
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": {