@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 +3 -3
- package/build/audit.js +1 -0
- package/build/auth.d.ts +1 -0
- package/build/auth.js +12 -1
- package/build/db.d.ts +2 -0
- package/build/db.js +1 -0
- package/build/index.js +1 -0
- package/build/scoped-token.d.ts +1 -0
- package/build/scoped-token.js +3 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +9 -6
- package/build/tools/analytics.js +0 -250
- 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/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
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
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 }));
|
package/build/scoped-token.d.ts
CHANGED
|
@@ -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;
|
package/build/scoped-token.js
CHANGED
|
@@ -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
|
}
|
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
|
}
|
|
@@ -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.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.
|
|
38
|
+
"undici": "^8.12.2",
|
|
39
39
|
"zod": "^4.4.3"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|