@socialneuron/mcp-server 1.8.1 → 1.8.2
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/CHANGELOG.md +30 -0
- package/README.md +76 -47
- package/dist/apps/analytics-pulse/mcp-app.html +167 -0
- package/dist/apps/content-calendar/mcp-app.html +475 -0
- package/dist/http.js +1950 -398
- package/dist/index.js +1786 -345
- package/dist/sn.js +1771 -330
- package/package.json +14 -11
- package/tools.lock.json +116 -101
package/dist/http.js
CHANGED
|
@@ -56,25 +56,140 @@ __export(credentials_exports, {
|
|
|
56
56
|
saveSupabaseUrl: () => saveSupabaseUrl
|
|
57
57
|
});
|
|
58
58
|
import { execFileSync } from "node:child_process";
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
closeSync,
|
|
61
|
+
constants,
|
|
62
|
+
existsSync,
|
|
63
|
+
fchmodSync,
|
|
64
|
+
fstatSync,
|
|
65
|
+
ftruncateSync,
|
|
66
|
+
lstatSync,
|
|
67
|
+
mkdirSync,
|
|
68
|
+
openSync,
|
|
69
|
+
readFileSync,
|
|
70
|
+
unlinkSync,
|
|
71
|
+
writeFileSync
|
|
72
|
+
} from "node:fs";
|
|
60
73
|
import { homedir, platform } from "node:os";
|
|
61
74
|
import { join } from "node:path";
|
|
75
|
+
function assertSafeCredentialPaths() {
|
|
76
|
+
if (platform() === "win32") return;
|
|
77
|
+
const uid = process.getuid?.();
|
|
78
|
+
if (existsSync(CONFIG_DIR)) {
|
|
79
|
+
const directory = lstatSync(CONFIG_DIR);
|
|
80
|
+
if (directory.isSymbolicLink() || !directory.isDirectory()) {
|
|
81
|
+
throw new Error("Unsafe Social Neuron credential directory. Refusing to use it.");
|
|
82
|
+
}
|
|
83
|
+
if (uid !== void 0 && directory.uid !== uid) {
|
|
84
|
+
throw new Error("Social Neuron credential directory is not owned by the current user.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (existsSync(CREDENTIALS_FILE)) {
|
|
88
|
+
const file = lstatSync(CREDENTIALS_FILE);
|
|
89
|
+
if (file.isSymbolicLink() || !file.isFile()) {
|
|
90
|
+
throw new Error("Unsafe Social Neuron credential file. Refusing to use it.");
|
|
91
|
+
}
|
|
92
|
+
if (uid !== void 0 && file.uid !== uid) {
|
|
93
|
+
throw new Error("Social Neuron credential file is not owned by the current user.");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function openValidatedCredentialPath(target, kind, flags) {
|
|
98
|
+
let fd;
|
|
99
|
+
try {
|
|
100
|
+
fd = openSync(
|
|
101
|
+
target,
|
|
102
|
+
flags | constants.O_NOFOLLOW | (kind === "directory" ? constants.O_DIRECTORY : 0)
|
|
103
|
+
);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === "ENOENT") return null;
|
|
106
|
+
throw new Error(`Unsafe Social Neuron credential ${kind}. Refusing to use it.`);
|
|
107
|
+
}
|
|
108
|
+
const opened = fstatSync(fd);
|
|
109
|
+
const uid = process.getuid?.();
|
|
110
|
+
if ((kind === "directory" ? !opened.isDirectory() : !opened.isFile()) || uid !== void 0 && opened.uid !== uid || kind === "file" && opened.nlink !== 1) {
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
throw new Error(`Unsafe Social Neuron credential ${kind}. Refusing to use it.`);
|
|
113
|
+
}
|
|
114
|
+
return fd;
|
|
115
|
+
}
|
|
116
|
+
function hardenCredentialPath(target, kind, mode) {
|
|
117
|
+
const fd = openValidatedCredentialPath(target, kind, constants.O_RDONLY);
|
|
118
|
+
if (fd === null) return;
|
|
119
|
+
try {
|
|
120
|
+
const opened = fstatSync(fd);
|
|
121
|
+
if ((opened.mode & 63) !== 0) fchmodSync(fd, mode);
|
|
122
|
+
} finally {
|
|
123
|
+
closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function hardenCredentialPermissions() {
|
|
127
|
+
if (platform() === "win32") return;
|
|
128
|
+
hardenCredentialPath(CONFIG_DIR, "directory", 448);
|
|
129
|
+
hardenCredentialPath(CREDENTIALS_FILE, "file", 384);
|
|
130
|
+
}
|
|
62
131
|
function readCredentialsFile() {
|
|
132
|
+
if (platform() !== "win32") hardenCredentialPath(CONFIG_DIR, "directory", 448);
|
|
133
|
+
const fd = platform() === "win32" ? (() => {
|
|
134
|
+
try {
|
|
135
|
+
return openSync(CREDENTIALS_FILE, constants.O_RDONLY);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error.code === "ENOENT") return null;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
})() : openValidatedCredentialPath(CREDENTIALS_FILE, "file", constants.O_RDONLY);
|
|
141
|
+
if (fd === null) return {};
|
|
63
142
|
try {
|
|
64
|
-
if (
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
143
|
+
if (platform() !== "win32") fchmodSync(fd, 384);
|
|
144
|
+
const parsed = JSON.parse(readFileSync(fd, "utf-8"));
|
|
145
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
146
|
+
throw new Error("Social Neuron credential file is invalid.");
|
|
147
|
+
}
|
|
148
|
+
return parsed;
|
|
149
|
+
} finally {
|
|
150
|
+
closeSync(fd);
|
|
69
151
|
}
|
|
70
152
|
}
|
|
71
153
|
function writeCredentialsFile(data) {
|
|
72
154
|
if (!existsSync(CONFIG_DIR)) {
|
|
73
155
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
74
156
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
157
|
+
assertSafeCredentialPaths();
|
|
158
|
+
const payload = JSON.stringify(data, null, 2) + "\n";
|
|
159
|
+
if (platform() === "win32") {
|
|
160
|
+
writeFileSync(CREDENTIALS_FILE, payload, { mode: 384 });
|
|
161
|
+
} else {
|
|
162
|
+
let fd;
|
|
163
|
+
try {
|
|
164
|
+
fd = openSync(
|
|
165
|
+
CREDENTIALS_FILE,
|
|
166
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
167
|
+
384
|
|
168
|
+
);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error.code !== "EEXIST") {
|
|
171
|
+
throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
fd = openSync(CREDENTIALS_FILE, constants.O_WRONLY | constants.O_NOFOLLOW);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const opened = fstatSync(fd);
|
|
181
|
+
const uid = process.getuid?.();
|
|
182
|
+
if (!opened.isFile() || opened.nlink !== 1 || uid !== void 0 && opened.uid !== uid) {
|
|
183
|
+
throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
|
|
184
|
+
}
|
|
185
|
+
fchmodSync(fd, 384);
|
|
186
|
+
ftruncateSync(fd, 0);
|
|
187
|
+
writeFileSync(fd, payload);
|
|
188
|
+
} finally {
|
|
189
|
+
closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
hardenCredentialPermissions();
|
|
78
193
|
}
|
|
79
194
|
function macKeychainRead(service) {
|
|
80
195
|
try {
|
|
@@ -284,7 +399,6 @@ async function validateApiKey(apiKey, _attempt = 0) {
|
|
|
284
399
|
}
|
|
285
400
|
);
|
|
286
401
|
if (!response.ok) {
|
|
287
|
-
const text = await response.text();
|
|
288
402
|
const retryable = response.status === 429 || response.status >= 500;
|
|
289
403
|
if (retryable && _attempt < VALIDATE_MAX_RETRIES) {
|
|
290
404
|
await sleep(300 * (_attempt + 1));
|
|
@@ -293,11 +407,19 @@ async function validateApiKey(apiKey, _attempt = 0) {
|
|
|
293
407
|
return {
|
|
294
408
|
valid: false,
|
|
295
409
|
retryable,
|
|
296
|
-
error: `Validation failed (HTTP ${response.status})
|
|
410
|
+
error: `Validation failed (HTTP ${response.status}).`
|
|
297
411
|
};
|
|
298
412
|
}
|
|
299
|
-
|
|
300
|
-
|
|
413
|
+
const result = await response.json();
|
|
414
|
+
if (!result.valid) {
|
|
415
|
+
return {
|
|
416
|
+
valid: false,
|
|
417
|
+
retryable: false,
|
|
418
|
+
error: "API key is invalid, expired, or revoked."
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
return result;
|
|
422
|
+
} catch {
|
|
301
423
|
if (_attempt < VALIDATE_MAX_RETRIES) {
|
|
302
424
|
await sleep(300 * (_attempt + 1));
|
|
303
425
|
return validateApiKey(apiKey, _attempt + 1);
|
|
@@ -305,7 +427,7 @@ async function validateApiKey(apiKey, _attempt = 0) {
|
|
|
305
427
|
return {
|
|
306
428
|
valid: false,
|
|
307
429
|
retryable: true,
|
|
308
|
-
error:
|
|
430
|
+
error: "Authentication service is temporarily unavailable."
|
|
309
431
|
};
|
|
310
432
|
}
|
|
311
433
|
}
|
|
@@ -326,10 +448,12 @@ __export(posthog_exports, {
|
|
|
326
448
|
initPostHog: () => initPostHog,
|
|
327
449
|
shutdownPostHog: () => shutdownPostHog
|
|
328
450
|
});
|
|
329
|
-
import {
|
|
451
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
330
452
|
import { PostHog } from "posthog-node";
|
|
331
453
|
function hashUserId(userId) {
|
|
332
|
-
|
|
454
|
+
const hmac = createHmac("sha256", POSTHOG_PSEUDONYMIZATION_KEY);
|
|
455
|
+
hmac.update(userId);
|
|
456
|
+
return hmac.digest("hex").substring(0, 32);
|
|
333
457
|
}
|
|
334
458
|
function initPostHog() {
|
|
335
459
|
if (isTelemetryDisabled()) return;
|
|
@@ -365,13 +489,13 @@ async function shutdownPostHog() {
|
|
|
365
489
|
client = null;
|
|
366
490
|
}
|
|
367
491
|
}
|
|
368
|
-
var
|
|
492
|
+
var POSTHOG_PSEUDONYMIZATION_KEY, client;
|
|
369
493
|
var init_posthog = __esm({
|
|
370
494
|
"src/lib/posthog.ts"() {
|
|
371
495
|
"use strict";
|
|
372
496
|
init_supabase();
|
|
373
497
|
init_request_context();
|
|
374
|
-
|
|
498
|
+
POSTHOG_PSEUDONYMIZATION_KEY = process.env.POSTHOG_PSEUDONYMIZATION_KEY || randomBytes(32).toString("hex");
|
|
375
499
|
client = null;
|
|
376
500
|
}
|
|
377
501
|
});
|
|
@@ -658,6 +782,9 @@ var TOOL_SCOPES = {
|
|
|
658
782
|
generate_carousel: "mcp:write",
|
|
659
783
|
create_carousel: "mcp:write",
|
|
660
784
|
upload_media: "mcp:write",
|
|
785
|
+
cancel_async_job: "mcp:write",
|
|
786
|
+
delete_carousel: "mcp:write",
|
|
787
|
+
delete_content_plan: "mcp:write",
|
|
661
788
|
// mcp:read (media)
|
|
662
789
|
get_media_url: "mcp:read",
|
|
663
790
|
// F4 Hyperframes — HTML composition runtime
|
|
@@ -665,6 +792,8 @@ var TOOL_SCOPES = {
|
|
|
665
792
|
render_hyperframes: "mcp:write",
|
|
666
793
|
// mcp:distribute
|
|
667
794
|
schedule_post: "mcp:distribute",
|
|
795
|
+
reschedule_post: "mcp:distribute",
|
|
796
|
+
cancel_scheduled_post: "mcp:distribute",
|
|
668
797
|
start_platform_connection: "mcp:distribute",
|
|
669
798
|
// mcp:analytics
|
|
670
799
|
refresh_platform_analytics: "mcp:analytics",
|
|
@@ -679,6 +808,7 @@ var TOOL_SCOPES = {
|
|
|
679
808
|
list_autopilot_configs: "mcp:autopilot",
|
|
680
809
|
update_autopilot_config: "mcp:autopilot",
|
|
681
810
|
get_autopilot_status: "mcp:autopilot",
|
|
811
|
+
delete_autopilot_config: "mcp:autopilot",
|
|
682
812
|
// Recipes
|
|
683
813
|
list_recipes: "mcp:read",
|
|
684
814
|
get_recipe_details: "mcp:read",
|
|
@@ -722,6 +852,7 @@ var TOOL_SCOPES = {
|
|
|
722
852
|
detect_anomalies: "mcp:analytics",
|
|
723
853
|
// mcp:read (Apps — entry tool for the Content Calendar MCP App; reads recent posts)
|
|
724
854
|
open_content_calendar: "mcp:read",
|
|
855
|
+
open_analytics_pulse: "mcp:read",
|
|
725
856
|
// mcp:write (Agentic harness — learning loop write-back)
|
|
726
857
|
write_agent_reflection: "mcp:write",
|
|
727
858
|
record_outcome: "mcp:write",
|
|
@@ -737,6 +868,7 @@ var TOOL_SCOPES = {
|
|
|
737
868
|
get_active_campaigns: "mcp:read",
|
|
738
869
|
// mcp:read / mcp:write (Skills)
|
|
739
870
|
list_skills: "mcp:read",
|
|
871
|
+
get_skill: "mcp:read",
|
|
740
872
|
run_skill: "mcp:write",
|
|
741
873
|
// mcp:read (Loop observability — growth-loop KPIs + content learning state)
|
|
742
874
|
get_loop_pulse: "mcp:read",
|
|
@@ -750,6 +882,9 @@ function hasScope(userScopes, required) {
|
|
|
750
882
|
}
|
|
751
883
|
return false;
|
|
752
884
|
}
|
|
885
|
+
function getAllScopes() {
|
|
886
|
+
return Object.keys(SCOPE_HIERARCHY);
|
|
887
|
+
}
|
|
753
888
|
|
|
754
889
|
// src/lib/tool-annotations.ts
|
|
755
890
|
var ACRONYMS = {
|
|
@@ -808,6 +943,11 @@ var SCOPE_DEFAULTS = {
|
|
|
808
943
|
var OVERRIDES = {
|
|
809
944
|
// Destructive or overwrite tools
|
|
810
945
|
delete_comment: { destructiveHint: true },
|
|
946
|
+
cancel_async_job: { destructiveHint: true, idempotentHint: true },
|
|
947
|
+
cancel_scheduled_post: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
948
|
+
delete_carousel: { destructiveHint: true, idempotentHint: true },
|
|
949
|
+
delete_content_plan: { destructiveHint: true, idempotentHint: true },
|
|
950
|
+
delete_autopilot_config: { destructiveHint: true, idempotentHint: true },
|
|
811
951
|
moderate_comment: { destructiveHint: true },
|
|
812
952
|
save_brand_profile: { destructiveHint: true, idempotentHint: true },
|
|
813
953
|
update_platform_voice: { destructiveHint: true, idempotentHint: true },
|
|
@@ -1041,6 +1181,26 @@ var TOOL_CATALOG = [
|
|
|
1041
1181
|
module: "carousel",
|
|
1042
1182
|
scope: "mcp:write"
|
|
1043
1183
|
},
|
|
1184
|
+
{
|
|
1185
|
+
name: "cancel_async_job",
|
|
1186
|
+
description: "Cancel an owned pending async job and refund an eligible debit",
|
|
1187
|
+
module: "lifecycle",
|
|
1188
|
+
scope: "mcp:write",
|
|
1189
|
+
task_intent: "Stop a queued generation or render before worker execution",
|
|
1190
|
+
use_when: "The user explicitly wants to cancel a pending job and has confirmed the action.",
|
|
1191
|
+
avoid_when: "The job is already processing or terminal; check_status instead.",
|
|
1192
|
+
next_tools: ["check_status", "get_credit_balance"]
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
name: "delete_carousel",
|
|
1196
|
+
description: "Delete an owned carousel content record from one project",
|
|
1197
|
+
module: "lifecycle",
|
|
1198
|
+
scope: "mcp:write",
|
|
1199
|
+
task_intent: "Remove an unwanted carousel record from Social Neuron",
|
|
1200
|
+
use_when: "The user explicitly confirms deletion and understands stored media follows normal retention.",
|
|
1201
|
+
avoid_when: "The user expects an already-published social post or retained media object to be removed.",
|
|
1202
|
+
next_tools: ["list_recent_posts"]
|
|
1203
|
+
},
|
|
1044
1204
|
// media
|
|
1045
1205
|
{
|
|
1046
1206
|
name: "upload_media",
|
|
@@ -1061,6 +1221,26 @@ var TOOL_CATALOG = [
|
|
|
1061
1221
|
module: "distribution",
|
|
1062
1222
|
scope: "mcp:distribute"
|
|
1063
1223
|
},
|
|
1224
|
+
{
|
|
1225
|
+
name: "reschedule_post",
|
|
1226
|
+
description: "Atomically move an unclaimed scheduled post to a new time within its brand project",
|
|
1227
|
+
module: "distribution",
|
|
1228
|
+
scope: "mcp:distribute",
|
|
1229
|
+
task_intent: "Move an already scheduled post without creating a duplicate publication",
|
|
1230
|
+
use_when: "A pending or scheduled post needs a different future publication time.",
|
|
1231
|
+
avoid_when: "The post is already publishing or published; create a new post instead.",
|
|
1232
|
+
next_tools: ["list_recent_posts", "open_content_calendar"]
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
name: "cancel_scheduled_post",
|
|
1236
|
+
description: "Cancel an owned scheduled post before publishing begins",
|
|
1237
|
+
module: "lifecycle",
|
|
1238
|
+
scope: "mcp:distribute",
|
|
1239
|
+
task_intent: "Unschedule a post without publishing it",
|
|
1240
|
+
use_when: "The user explicitly confirms cancellation of a draft, pending, or scheduled post.",
|
|
1241
|
+
avoid_when: "The post is already publishing or published.",
|
|
1242
|
+
next_tools: ["list_recent_posts", "open_content_calendar"]
|
|
1243
|
+
},
|
|
1064
1244
|
{
|
|
1065
1245
|
name: "list_recent_posts",
|
|
1066
1246
|
description: "List recently published or scheduled posts",
|
|
@@ -1251,6 +1431,16 @@ var TOOL_CATALOG = [
|
|
|
1251
1431
|
module: "planning",
|
|
1252
1432
|
scope: "mcp:write"
|
|
1253
1433
|
},
|
|
1434
|
+
{
|
|
1435
|
+
name: "delete_content_plan",
|
|
1436
|
+
description: "Permanently delete an owned content plan from one project",
|
|
1437
|
+
module: "lifecycle",
|
|
1438
|
+
scope: "mcp:write",
|
|
1439
|
+
task_intent: "Remove an obsolete saved content plan",
|
|
1440
|
+
use_when: "The user explicitly confirms permanent deletion of a specific plan.",
|
|
1441
|
+
avoid_when: "The plan has scheduled posts the user also expects to cancel; cancel those posts separately.",
|
|
1442
|
+
next_tools: ["plan_content_week", "list_recent_posts"]
|
|
1443
|
+
},
|
|
1254
1444
|
{
|
|
1255
1445
|
name: "submit_content_plan_for_approval",
|
|
1256
1446
|
description: "Submit a content plan for team approval",
|
|
@@ -1345,6 +1535,16 @@ var TOOL_CATALOG = [
|
|
|
1345
1535
|
module: "autopilot",
|
|
1346
1536
|
scope: "mcp:autopilot"
|
|
1347
1537
|
},
|
|
1538
|
+
{
|
|
1539
|
+
name: "delete_autopilot_config",
|
|
1540
|
+
description: "Permanently delete an owned autopilot configuration from one project",
|
|
1541
|
+
module: "lifecycle",
|
|
1542
|
+
scope: "mcp:autopilot",
|
|
1543
|
+
task_intent: "Remove an autopilot configuration while retaining historical run records",
|
|
1544
|
+
use_when: "The user explicitly confirms deletion of the configuration.",
|
|
1545
|
+
avoid_when: "The user only wants to pause or edit automation, or expects historical posts to be deleted.",
|
|
1546
|
+
next_tools: ["list_autopilot_configs"]
|
|
1547
|
+
},
|
|
1348
1548
|
// extraction
|
|
1349
1549
|
{
|
|
1350
1550
|
name: "extract_url_content",
|
|
@@ -1461,7 +1661,13 @@ var TOOL_CATALOG = [
|
|
|
1461
1661
|
// apps (MCP Apps — interactive UI inside the host)
|
|
1462
1662
|
{
|
|
1463
1663
|
name: "open_content_calendar",
|
|
1464
|
-
description: "Open
|
|
1664
|
+
description: "Open a project-scoped interactive calendar inside MCP App-capable hosts. Uses reschedule_post for conflict-safe drag/drop and schedule_post for new posts.",
|
|
1665
|
+
module: "apps",
|
|
1666
|
+
scope: "mcp:read"
|
|
1667
|
+
},
|
|
1668
|
+
{
|
|
1669
|
+
name: "open_analytics_pulse",
|
|
1670
|
+
description: "Open a project-scoped interactive performance dashboard with views, engagement, platform mix, and top posts.",
|
|
1465
1671
|
module: "apps",
|
|
1466
1672
|
scope: "mcp:read"
|
|
1467
1673
|
},
|
|
@@ -1576,6 +1782,12 @@ var TOOL_CATALOG = [
|
|
|
1576
1782
|
module: "skills",
|
|
1577
1783
|
scope: "mcp:read"
|
|
1578
1784
|
},
|
|
1785
|
+
{
|
|
1786
|
+
name: "get_skill",
|
|
1787
|
+
description: "Fetch the full body and current compiled guidance for one Social Neuron skill by slug.",
|
|
1788
|
+
module: "skills",
|
|
1789
|
+
scope: "mcp:read"
|
|
1790
|
+
},
|
|
1579
1791
|
{
|
|
1580
1792
|
name: "run_skill",
|
|
1581
1793
|
description: "Run a Social Neuron workflow skill end-to-end (brand-locked content production). Returns a structured run preview with the step plan, credit cost, and a deep-link to launch in the SN dashboard.",
|
|
@@ -1635,6 +1847,7 @@ var ANTHROPIC_DIRECTORY_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
|
|
|
1635
1847
|
"list_hyperframes_blocks",
|
|
1636
1848
|
"render_hyperframes",
|
|
1637
1849
|
"list_skills",
|
|
1850
|
+
"get_skill",
|
|
1638
1851
|
"run_skill"
|
|
1639
1852
|
]);
|
|
1640
1853
|
function resolveToolProfile(value) {
|
|
@@ -1649,7 +1862,7 @@ function isToolAllowedByProfile(toolName, profile) {
|
|
|
1649
1862
|
}
|
|
1650
1863
|
function publicToolsForProfile(profile) {
|
|
1651
1864
|
return TOOL_CATALOG.filter(
|
|
1652
|
-
(tool) => !tool.localOnly && !tool.internal && isToolAllowedByProfile(tool.name, profile)
|
|
1865
|
+
(tool) => !tool.localOnly && !tool.internal && !tool.hiddenFromPublicCount && isToolAllowedByProfile(tool.name, profile)
|
|
1653
1866
|
);
|
|
1654
1867
|
}
|
|
1655
1868
|
function applyToolProfile(server, profile) {
|
|
@@ -1695,7 +1908,8 @@ var constants_default = {
|
|
|
1695
1908
|
ssn: "\\d{3}-\\d{2}-\\d{4}",
|
|
1696
1909
|
ip: "(?<![\\d.])(?:\\d{1,3}\\.){3}\\d{1,3}(?![\\d.])"
|
|
1697
1910
|
},
|
|
1698
|
-
MAX_LENGTH: 1e4
|
|
1911
|
+
MAX_LENGTH: 1e4,
|
|
1912
|
+
MAX_OUTPUT_LENGTH: 1e6
|
|
1699
1913
|
};
|
|
1700
1914
|
|
|
1701
1915
|
// src/lib/agent-harness/constants.ts
|
|
@@ -1756,7 +1970,8 @@ function scrubPii(text, _role) {
|
|
|
1756
1970
|
function scan(text, options) {
|
|
1757
1971
|
const flagged = /* @__PURE__ */ new Set();
|
|
1758
1972
|
let risk = 0;
|
|
1759
|
-
|
|
1973
|
+
const maxLength = options.source === "mcp_tool_output" ? CONSTANTS.MAX_OUTPUT_LENGTH : CONSTANTS.MAX_LENGTH;
|
|
1974
|
+
if (text.length > maxLength) {
|
|
1760
1975
|
return {
|
|
1761
1976
|
passed: false,
|
|
1762
1977
|
risk_score: 1,
|
|
@@ -1803,6 +2018,85 @@ import { z } from "zod";
|
|
|
1803
2018
|
// src/lib/edge-function.ts
|
|
1804
2019
|
init_supabase();
|
|
1805
2020
|
init_request_context();
|
|
2021
|
+
var SAFE_GATEWAY_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
2022
|
+
"daily_limit_reached",
|
|
2023
|
+
"insufficient_credits",
|
|
2024
|
+
"project_scope_mismatch",
|
|
2025
|
+
"schedule_conflict",
|
|
2026
|
+
"post_not_found",
|
|
2027
|
+
"post_not_reschedulable",
|
|
2028
|
+
"post_in_progress",
|
|
2029
|
+
"not_cancellable",
|
|
2030
|
+
"publishing_in_progress",
|
|
2031
|
+
"plan_upgrade_required",
|
|
2032
|
+
"rate_limited",
|
|
2033
|
+
"validation_error",
|
|
2034
|
+
"permission_denied",
|
|
2035
|
+
"not_found"
|
|
2036
|
+
]);
|
|
2037
|
+
function safeGatewayError(responseText, status) {
|
|
2038
|
+
try {
|
|
2039
|
+
const parsed = JSON.parse(responseText);
|
|
2040
|
+
const nested = parsed.error && typeof parsed.error === "object" ? parsed.error : null;
|
|
2041
|
+
const candidates = [
|
|
2042
|
+
nested?.error_type,
|
|
2043
|
+
nested?.code,
|
|
2044
|
+
parsed.error_type,
|
|
2045
|
+
parsed.error_code,
|
|
2046
|
+
parsed.code,
|
|
2047
|
+
typeof parsed.error === "string" ? parsed.error : null
|
|
2048
|
+
];
|
|
2049
|
+
for (const value of candidates) {
|
|
2050
|
+
if (typeof value !== "string") continue;
|
|
2051
|
+
const normalized = value.trim().toLowerCase();
|
|
2052
|
+
if (SAFE_GATEWAY_ERROR_CODES.has(normalized)) return normalized;
|
|
2053
|
+
const embedded = [...SAFE_GATEWAY_ERROR_CODES].find((code) => normalized.includes(code));
|
|
2054
|
+
if (embedded) return embedded;
|
|
2055
|
+
}
|
|
2056
|
+
} catch {
|
|
2057
|
+
}
|
|
2058
|
+
return `Backend request failed (HTTP ${status}).`;
|
|
2059
|
+
}
|
|
2060
|
+
var SAFE_BILLING_STATUSES = /* @__PURE__ */ new Set([
|
|
2061
|
+
"reserved",
|
|
2062
|
+
"charged",
|
|
2063
|
+
"refunded",
|
|
2064
|
+
"failed_no_charge",
|
|
2065
|
+
"refund_pending",
|
|
2066
|
+
"not_charged"
|
|
2067
|
+
]);
|
|
2068
|
+
var SAFE_JOB_STATUSES = /* @__PURE__ */ new Set([
|
|
2069
|
+
"queued",
|
|
2070
|
+
"pending",
|
|
2071
|
+
"processing",
|
|
2072
|
+
"completed",
|
|
2073
|
+
"failed",
|
|
2074
|
+
"cancelled",
|
|
2075
|
+
"canceled"
|
|
2076
|
+
]);
|
|
2077
|
+
function safeFailureData(responseText) {
|
|
2078
|
+
try {
|
|
2079
|
+
const parsed = JSON.parse(responseText);
|
|
2080
|
+
const metric = (name) => {
|
|
2081
|
+
const value = parsed[name];
|
|
2082
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
2083
|
+
};
|
|
2084
|
+
const billingStatus = typeof parsed.billing_status === "string" && SAFE_BILLING_STATUSES.has(parsed.billing_status) ? parsed.billing_status : void 0;
|
|
2085
|
+
const failureReason = parsed.failure_reason === "generation_failed" || parsed.failure_reason === "authentication_failed" || parsed.failure_reason === "cancelled_by_user" ? parsed.failure_reason : void 0;
|
|
2086
|
+
const jobStatus = typeof parsed.status === "string" && SAFE_JOB_STATUSES.has(parsed.status) ? parsed.status : void 0;
|
|
2087
|
+
const safe = {
|
|
2088
|
+
...jobStatus ? { status: jobStatus } : {},
|
|
2089
|
+
...metric("credits_reserved") !== void 0 ? { credits_reserved: metric("credits_reserved") } : {},
|
|
2090
|
+
...metric("credits_charged") !== void 0 ? { credits_charged: metric("credits_charged") } : {},
|
|
2091
|
+
...metric("credits_refunded") !== void 0 ? { credits_refunded: metric("credits_refunded") } : {},
|
|
2092
|
+
...billingStatus ? { billing_status: billingStatus } : {},
|
|
2093
|
+
...failureReason ? { failure_reason: failureReason } : {}
|
|
2094
|
+
};
|
|
2095
|
+
return Object.keys(safe).length > 0 ? safe : null;
|
|
2096
|
+
} catch {
|
|
2097
|
+
return null;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
1806
2100
|
function getApiKeyOrNull() {
|
|
1807
2101
|
const envKey = process.env.SOCIALNEURON_API_KEY;
|
|
1808
2102
|
if (envKey && envKey.trim().length) return envKey.trim();
|
|
@@ -1873,33 +2167,28 @@ async function callEdgeFunction(functionName, body, options) {
|
|
|
1873
2167
|
clearTimeout(timer);
|
|
1874
2168
|
const responseText = await response.text();
|
|
1875
2169
|
if (!response.ok) {
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
const errorJson = JSON.parse(responseText);
|
|
1879
|
-
errorMessage = errorJson.error || errorJson.message || responseText;
|
|
1880
|
-
} catch {
|
|
1881
|
-
errorMessage = responseText || `HTTP ${response.status}`;
|
|
1882
|
-
}
|
|
2170
|
+
const errorCode = safeGatewayError(responseText, response.status);
|
|
2171
|
+
const failureData = safeFailureData(responseText);
|
|
1883
2172
|
if (response.status === 401) {
|
|
1884
2173
|
return {
|
|
1885
|
-
data:
|
|
2174
|
+
data: failureData,
|
|
1886
2175
|
error: `Authentication failed (HTTP 401). Run 'npx @socialneuron/mcp-server login' to re-authenticate.`
|
|
1887
2176
|
};
|
|
1888
2177
|
}
|
|
1889
2178
|
if (response.status === 403) {
|
|
1890
2179
|
return {
|
|
1891
|
-
data:
|
|
1892
|
-
error: `Forbidden (HTTP 403): ${
|
|
2180
|
+
data: failureData,
|
|
2181
|
+
error: `Forbidden (HTTP 403): ${errorCode}. This action isn't permitted for your account, plan, or scope \u2014 your connection is still valid.`
|
|
1893
2182
|
};
|
|
1894
2183
|
}
|
|
1895
2184
|
if (response.status === 429) {
|
|
1896
2185
|
const retryAfter = response.headers.get("retry-after") || "60";
|
|
1897
2186
|
return {
|
|
1898
|
-
data:
|
|
2187
|
+
data: failureData,
|
|
1899
2188
|
error: `Rate limit exceeded (HTTP 429). Wait ${retryAfter}s before retrying. Reduce request frequency or upgrade your plan.`
|
|
1900
2189
|
};
|
|
1901
2190
|
}
|
|
1902
|
-
return { data:
|
|
2191
|
+
return { data: failureData, error: errorCode };
|
|
1903
2192
|
}
|
|
1904
2193
|
try {
|
|
1905
2194
|
const data = JSON.parse(responseText);
|
|
@@ -1915,8 +2204,7 @@ async function callEdgeFunction(functionName, body, options) {
|
|
|
1915
2204
|
error: `Edge Function '${functionName}' timed out after ${timeoutMs}ms`
|
|
1916
2205
|
};
|
|
1917
2206
|
}
|
|
1918
|
-
|
|
1919
|
-
return { data: null, error: message };
|
|
2207
|
+
return { data: null, error: "Network request failed. Please retry." };
|
|
1920
2208
|
}
|
|
1921
2209
|
}
|
|
1922
2210
|
|
|
@@ -1933,6 +2221,45 @@ var CATEGORY_CONFIGS = {
|
|
|
1933
2221
|
read: { maxTokens: 60, refillRate: 60 / 60 }
|
|
1934
2222
|
// 60 req/min — default
|
|
1935
2223
|
};
|
|
2224
|
+
var GENERATION_TOOLS = /* @__PURE__ */ new Set([
|
|
2225
|
+
"generate_content",
|
|
2226
|
+
"adapt_content",
|
|
2227
|
+
"generate_video",
|
|
2228
|
+
"generate_image",
|
|
2229
|
+
"create_storyboard",
|
|
2230
|
+
"generate_voiceover",
|
|
2231
|
+
"generate_carousel",
|
|
2232
|
+
"create_carousel",
|
|
2233
|
+
"render_demo_video",
|
|
2234
|
+
"render_template_video",
|
|
2235
|
+
"render_hyperframes",
|
|
2236
|
+
"execute_recipe",
|
|
2237
|
+
"run_content_pipeline",
|
|
2238
|
+
"plan_content_week",
|
|
2239
|
+
"extract_brand",
|
|
2240
|
+
"generate_performance_digest"
|
|
2241
|
+
]);
|
|
2242
|
+
var POSTING_TOOLS = /* @__PURE__ */ new Set([
|
|
2243
|
+
"schedule_post",
|
|
2244
|
+
"reschedule_post",
|
|
2245
|
+
"schedule_content_plan",
|
|
2246
|
+
"reply_to_comment",
|
|
2247
|
+
"post_comment",
|
|
2248
|
+
"moderate_comment",
|
|
2249
|
+
"delete_comment",
|
|
2250
|
+
"start_platform_connection",
|
|
2251
|
+
"refresh_platform_analytics"
|
|
2252
|
+
]);
|
|
2253
|
+
function rateLimitCategoryForTool(toolName) {
|
|
2254
|
+
if (!toolName) return "read";
|
|
2255
|
+
if (GENERATION_TOOLS.has(toolName)) return "generation";
|
|
2256
|
+
if (POSTING_TOOLS.has(toolName)) return "posting";
|
|
2257
|
+
if (toolName === "upload_media") return "upload";
|
|
2258
|
+
if (toolName === "capture_screenshot" || toolName === "capture_app_page") return "screenshot";
|
|
2259
|
+
const scope = TOOL_SCOPES[toolName];
|
|
2260
|
+
if (scope === "mcp:write" || scope === "mcp:distribute" || scope === "mcp:analytics" || scope === "mcp:comments" || scope === "mcp:autopilot") return "posting";
|
|
2261
|
+
return "read";
|
|
2262
|
+
}
|
|
1936
2263
|
var RateLimiter = class {
|
|
1937
2264
|
tokens;
|
|
1938
2265
|
lastRefill;
|
|
@@ -1997,7 +2324,7 @@ function checkRateLimit(category, key) {
|
|
|
1997
2324
|
init_supabase();
|
|
1998
2325
|
|
|
1999
2326
|
// src/lib/version.ts
|
|
2000
|
-
var MCP_VERSION = "1.8.
|
|
2327
|
+
var MCP_VERSION = "1.8.2";
|
|
2001
2328
|
|
|
2002
2329
|
// src/tools/ideation.ts
|
|
2003
2330
|
function asEnvelope(data) {
|
|
@@ -2033,8 +2360,8 @@ function registerIdeationTools(server) {
|
|
|
2033
2360
|
brand_voice: z.string().max(500).optional().describe(
|
|
2034
2361
|
'Tone directive (e.g. "direct, no jargon, second person" or "witty Gen-Z energy with emoji"). Leave blank to auto-load from project brand profile if project_id is set.'
|
|
2035
2362
|
),
|
|
2036
|
-
model: z.enum(["gemini-2.
|
|
2037
|
-
"AI model to use. Defaults to gemini-2.5-flash. Use gemini-2.5-pro for highest quality."
|
|
2363
|
+
model: z.enum(["gemini-2.5-flash", "gemini-2.5-pro"]).optional().describe(
|
|
2364
|
+
"AI model to use. Defaults to gemini-2.5-flash. Use gemini-2.5-pro for highest quality. Retired provider models are intentionally not accepted."
|
|
2038
2365
|
),
|
|
2039
2366
|
project_id: z.string().uuid().optional().describe(
|
|
2040
2367
|
"Project ID to auto-load brand profile and performance context for prompt enrichment."
|
|
@@ -2043,7 +2370,7 @@ function registerIdeationTools(server) {
|
|
|
2043
2370
|
async ({ prompt, content_type, platform: platform2, brand_voice, model, project_id }) => {
|
|
2044
2371
|
try {
|
|
2045
2372
|
const userId = await getDefaultUserId();
|
|
2046
|
-
const rl = checkRateLimit("
|
|
2373
|
+
const rl = checkRateLimit("generation", `generate_content:${userId}`);
|
|
2047
2374
|
if (!rl.allowed) {
|
|
2048
2375
|
return {
|
|
2049
2376
|
content: [
|
|
@@ -2300,7 +2627,7 @@ Content Type: ${content_type}`;
|
|
|
2300
2627
|
async ({ content, source_platform, target_platform, brand_voice, project_id }) => {
|
|
2301
2628
|
try {
|
|
2302
2629
|
const userId = await getDefaultUserId();
|
|
2303
|
-
const rl = checkRateLimit("
|
|
2630
|
+
const rl = checkRateLimit("generation", `adapt_content:${userId}`);
|
|
2304
2631
|
if (!rl.allowed) {
|
|
2305
2632
|
return {
|
|
2306
2633
|
content: [
|
|
@@ -2407,11 +2734,12 @@ function buildCheckStatusPayload(job, liveStatus) {
|
|
|
2407
2734
|
const progress = liveStatus?.progress ?? null;
|
|
2408
2735
|
const resultUrl = liveStatus?.resultUrl ?? job.result_url ?? null;
|
|
2409
2736
|
const r2Key = resultUrl && !resultUrl.startsWith("http") ? resultUrl : null;
|
|
2410
|
-
const
|
|
2737
|
+
const rawError = liveStatus?.error ?? job.error_message ?? null;
|
|
2738
|
+
const errorMessage = rawError && status === "failed" ? "Generation failed. Retry or choose another model." : rawError && (status === "cancelled" || status === "canceled") ? "Cancelled by user." : null;
|
|
2411
2739
|
const allUrls = job.result_metadata?.all_urls ?? null;
|
|
2412
2740
|
const modelRequested = job.result_metadata?.model_requested ?? null;
|
|
2413
2741
|
const modelDelivered = job.result_metadata?.model_delivered ?? null;
|
|
2414
|
-
const fallbackReason = job.result_metadata?.fallback_reason
|
|
2742
|
+
const fallbackReason = job.result_metadata?.fallback_reason ? "Requested model was unavailable; a fallback model was used." : null;
|
|
2415
2743
|
return {
|
|
2416
2744
|
job_id: job.id,
|
|
2417
2745
|
job_type: job.job_type,
|
|
@@ -2423,6 +2751,11 @@ function buildCheckStatusPayload(job, liveStatus) {
|
|
|
2423
2751
|
all_urls: allUrls,
|
|
2424
2752
|
error: errorMessage,
|
|
2425
2753
|
credits_cost: job.credits_cost,
|
|
2754
|
+
credits_reserved: job.credits_reserved ?? null,
|
|
2755
|
+
credits_charged: job.credits_charged ?? null,
|
|
2756
|
+
credits_refunded: job.credits_refunded ?? null,
|
|
2757
|
+
billing_status: job.billing_status ?? "unknown",
|
|
2758
|
+
failure_reason: job.failure_reason ?? null,
|
|
2426
2759
|
created_at: job.created_at,
|
|
2427
2760
|
completed_at: job.completed_at,
|
|
2428
2761
|
model_requested: modelRequested,
|
|
@@ -2512,6 +2845,13 @@ function checkAssetBudget(requestedCount = 1) {
|
|
|
2512
2845
|
}
|
|
2513
2846
|
|
|
2514
2847
|
// src/tools/content.ts
|
|
2848
|
+
function buildFallbackDisclosureLine(meta) {
|
|
2849
|
+
const requested = meta?.model_requested;
|
|
2850
|
+
const delivered = meta?.model_delivered;
|
|
2851
|
+
if (!requested || !delivered || requested === delivered) return null;
|
|
2852
|
+
const reason = meta?.fallback_reason ? " because the requested model was unavailable" : "";
|
|
2853
|
+
return `Note: requested "${requested}" but delivered "${delivered}"${reason} \u2014 cost never exceeds the requested model's price.`;
|
|
2854
|
+
}
|
|
2515
2855
|
function asEnvelope2(data) {
|
|
2516
2856
|
return {
|
|
2517
2857
|
_meta: {
|
|
@@ -2619,7 +2959,7 @@ function registerContentTools(server) {
|
|
|
2619
2959
|
isError: true
|
|
2620
2960
|
};
|
|
2621
2961
|
}
|
|
2622
|
-
const rateLimit = checkRateLimit("
|
|
2962
|
+
const rateLimit = checkRateLimit("generation", `generate_video:${userId}`);
|
|
2623
2963
|
if (!rateLimit.allowed) {
|
|
2624
2964
|
return {
|
|
2625
2965
|
content: [
|
|
@@ -2760,7 +3100,7 @@ function registerContentTools(server) {
|
|
|
2760
3100
|
isError: true
|
|
2761
3101
|
};
|
|
2762
3102
|
}
|
|
2763
|
-
const rateLimit = checkRateLimit("
|
|
3103
|
+
const rateLimit = checkRateLimit("generation", `generate_image:${userId}`);
|
|
2764
3104
|
if (!rateLimit.allowed) {
|
|
2765
3105
|
return {
|
|
2766
3106
|
content: [
|
|
@@ -2900,20 +3240,28 @@ function registerContentTools(server) {
|
|
|
2900
3240
|
}
|
|
2901
3241
|
);
|
|
2902
3242
|
if (liveStatus) {
|
|
3243
|
+
const livePayload = buildCheckStatusPayload(job, liveStatus);
|
|
2903
3244
|
const lines2 = [
|
|
2904
3245
|
`Job: ${job.id}`,
|
|
2905
3246
|
`Type: ${job.job_type}`,
|
|
2906
3247
|
`Model: ${job.model}`,
|
|
2907
|
-
`Status: ${
|
|
2908
|
-
`Progress: ${
|
|
3248
|
+
`Status: ${livePayload.status}`,
|
|
3249
|
+
`Progress: ${livePayload.progress}%`
|
|
2909
3250
|
];
|
|
2910
|
-
if (
|
|
2911
|
-
lines2.push(`Result URL: ${
|
|
3251
|
+
if (livePayload.result_url) {
|
|
3252
|
+
lines2.push(`Result URL: ${livePayload.result_url}`);
|
|
2912
3253
|
}
|
|
2913
|
-
if (
|
|
2914
|
-
lines2.push(`Error: ${
|
|
3254
|
+
if (livePayload.error) {
|
|
3255
|
+
lines2.push(`Error: ${livePayload.error}`);
|
|
2915
3256
|
}
|
|
3257
|
+
const fallbackDisclosure2 = buildFallbackDisclosureLine(job.result_metadata);
|
|
3258
|
+
if (fallbackDisclosure2) lines2.push(fallbackDisclosure2);
|
|
2916
3259
|
lines2.push(`Credits: ${job.credits_cost}`);
|
|
3260
|
+
if (job.billing_status) {
|
|
3261
|
+
lines2.push(
|
|
3262
|
+
`Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
|
|
3263
|
+
);
|
|
3264
|
+
}
|
|
2917
3265
|
lines2.push(`Created: ${job.created_at}`);
|
|
2918
3266
|
if (format === "json") {
|
|
2919
3267
|
return {
|
|
@@ -2931,7 +3279,7 @@ function registerContentTools(server) {
|
|
|
2931
3279
|
// `job` + `liveStatus`; do not duplicate them here.
|
|
2932
3280
|
asEnvelope2({
|
|
2933
3281
|
...liveStatus,
|
|
2934
|
-
...
|
|
3282
|
+
...livePayload
|
|
2935
3283
|
}),
|
|
2936
3284
|
null,
|
|
2937
3285
|
2
|
|
@@ -2974,7 +3322,14 @@ function registerContentTools(server) {
|
|
|
2974
3322
|
if (job.error_message) {
|
|
2975
3323
|
lines.push(`Error: ${job.error_message}`);
|
|
2976
3324
|
}
|
|
3325
|
+
const fallbackDisclosure = buildFallbackDisclosureLine(job.result_metadata);
|
|
3326
|
+
if (fallbackDisclosure) lines.push(fallbackDisclosure);
|
|
2977
3327
|
lines.push(`Credits: ${job.credits_cost}`);
|
|
3328
|
+
if (job.billing_status) {
|
|
3329
|
+
lines.push(
|
|
3330
|
+
`Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
2978
3333
|
lines.push(`Created: ${job.created_at}`);
|
|
2979
3334
|
if (job.completed_at) {
|
|
2980
3335
|
lines.push(`Completed: ${job.completed_at}`);
|
|
@@ -3204,7 +3559,7 @@ Return ONLY valid JSON in this exact format:
|
|
|
3204
3559
|
};
|
|
3205
3560
|
}
|
|
3206
3561
|
const rateLimit = checkRateLimit(
|
|
3207
|
-
"
|
|
3562
|
+
"generation",
|
|
3208
3563
|
`generate_voiceover:${userId}`
|
|
3209
3564
|
);
|
|
3210
3565
|
if (!rateLimit.allowed) {
|
|
@@ -3371,7 +3726,7 @@ Return ONLY valid JSON in this exact format:
|
|
|
3371
3726
|
}
|
|
3372
3727
|
const userId = await getDefaultUserId();
|
|
3373
3728
|
const rateLimit = checkRateLimit(
|
|
3374
|
-
"
|
|
3729
|
+
"generation",
|
|
3375
3730
|
`generate_carousel:${userId}`
|
|
3376
3731
|
);
|
|
3377
3732
|
if (!rateLimit.allowed) {
|
|
@@ -3473,7 +3828,7 @@ Return ONLY valid JSON in this exact format:
|
|
|
3473
3828
|
|
|
3474
3829
|
// src/tools/distribution.ts
|
|
3475
3830
|
import { z as z3 } from "zod";
|
|
3476
|
-
import { createHash
|
|
3831
|
+
import { createHash } from "node:crypto";
|
|
3477
3832
|
|
|
3478
3833
|
// src/lib/sanitize-error.ts
|
|
3479
3834
|
var ERROR_PATTERNS = [
|
|
@@ -3512,9 +3867,6 @@ var ERROR_PATTERNS = [
|
|
|
3512
3867
|
];
|
|
3513
3868
|
function sanitizeError(error) {
|
|
3514
3869
|
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
|
|
3515
|
-
if (process.env.NODE_ENV !== "production") {
|
|
3516
|
-
console.error("[Error]", msg);
|
|
3517
|
-
}
|
|
3518
3870
|
for (const [pattern, userMessage] of ERROR_PATTERNS) {
|
|
3519
3871
|
if (pattern.test(msg)) {
|
|
3520
3872
|
return userMessage;
|
|
@@ -3685,6 +4037,7 @@ function evaluateQuality(input) {
|
|
|
3685
4037
|
const firstLine = caption.split("\n")[0]?.trim() ?? "";
|
|
3686
4038
|
const hashtags = countHashtags(caption);
|
|
3687
4039
|
const threshold = Math.min(35, Math.max(0, input.threshold ?? 26));
|
|
4040
|
+
const isShortFormX = platforms.length > 0 && platforms.every((p) => p === "twitter") && caption.length <= 280;
|
|
3688
4041
|
const blockedTerms = [
|
|
3689
4042
|
...(input.brandAvoidPatterns ?? []).map((t) => t.trim()).filter(Boolean),
|
|
3690
4043
|
...(input.customBannedTerms ?? []).map((t) => t.trim()).filter(Boolean)
|
|
@@ -3694,11 +4047,12 @@ function evaluateQuality(input) {
|
|
|
3694
4047
|
if (firstLine.length >= 20 && firstLine.length <= 120) hookScore += 1;
|
|
3695
4048
|
if (/[!?]/.test(firstLine) || /\b\d+(\.\d+)?\b/.test(firstLine)) hookScore += 1;
|
|
3696
4049
|
if (/\b(how|why|stop|avoid|build|launch|scale|grow|mistake)\b/i.test(firstLine)) hookScore += 1;
|
|
4050
|
+
if (isShortFormX) hookScore = Math.max(hookScore, 3);
|
|
3697
4051
|
categories.push({
|
|
3698
4052
|
name: "Hook Strength",
|
|
3699
4053
|
score: Math.min(5, hookScore),
|
|
3700
4054
|
maxScore: 5,
|
|
3701
|
-
detail: "First line should create curiosity/value within 120 chars."
|
|
4055
|
+
detail: isShortFormX ? "Short-form X: keyword-hook heuristics neutralized; substance judge arbitrates." : "First line should create curiosity/value within 120 chars."
|
|
3702
4056
|
});
|
|
3703
4057
|
let clarityScore = 2;
|
|
3704
4058
|
if (caption.length >= 80 && caption.length <= 1200) clarityScore += 2;
|
|
@@ -3724,7 +4078,8 @@ function evaluateQuality(input) {
|
|
|
3724
4078
|
detail: "Length, title, and hashtag usage should match target platforms."
|
|
3725
4079
|
});
|
|
3726
4080
|
let brandScore = 3;
|
|
3727
|
-
const
|
|
4081
|
+
const rawBrandKeyword = input.brandKeyword ?? process.env.SOCIALNEURON_BRAND_KEYWORD?.trim();
|
|
4082
|
+
const brandKeyword = rawBrandKeyword ? rawBrandKeyword.slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : null;
|
|
3728
4083
|
if (brandKeyword && new RegExp("\\b" + brandKeyword + "\\b", "i").test(title + " " + caption))
|
|
3729
4084
|
brandScore += 1;
|
|
3730
4085
|
if (!/\b(you|your|customer|audience)\b/i.test(caption)) brandScore -= 1;
|
|
@@ -3735,11 +4090,12 @@ function evaluateQuality(input) {
|
|
|
3735
4090
|
brandScore -= Math.min(2, matched.length);
|
|
3736
4091
|
}
|
|
3737
4092
|
}
|
|
4093
|
+
if (isShortFormX) brandScore = Math.max(brandScore, 3);
|
|
3738
4094
|
categories.push({
|
|
3739
4095
|
name: "Brand Alignment",
|
|
3740
4096
|
score: Math.max(0, Math.min(5, brandScore)),
|
|
3741
4097
|
maxScore: 5,
|
|
3742
|
-
detail: "Voice should match brand context and audience focus."
|
|
4098
|
+
detail: isShortFormX ? "Short-form X: second-person-address heuristic neutralized; blocked terms still enforced." : "Voice should match brand context and audience focus."
|
|
3743
4099
|
});
|
|
3744
4100
|
let noveltyScore = 2;
|
|
3745
4101
|
if (/\b(case study|framework|workflow|playbook|breakdown|behind the scenes)\b/i.test(caption))
|
|
@@ -3750,21 +4106,23 @@ function evaluateQuality(input) {
|
|
|
3750
4106
|
const matched = blockedTerms.filter((term) => lowerCombined.includes(term.toLowerCase()));
|
|
3751
4107
|
if (matched.length > 0) noveltyScore -= Math.min(2, matched.length);
|
|
3752
4108
|
}
|
|
4109
|
+
if (isShortFormX) noveltyScore = Math.max(noveltyScore, 3);
|
|
3753
4110
|
categories.push({
|
|
3754
4111
|
name: "Novelty",
|
|
3755
4112
|
score: Math.max(0, Math.min(5, noveltyScore)),
|
|
3756
4113
|
maxScore: 5,
|
|
3757
|
-
detail: "Avoid generic phrasing; include distinct angle."
|
|
4114
|
+
detail: isShortFormX ? "Short-form X: keyword-novelty neutralized; substance judge arbitrates." : "Avoid generic phrasing; include distinct angle."
|
|
3758
4115
|
});
|
|
3759
4116
|
let ctaScore = 2;
|
|
3760
4117
|
if (/\b(comment|reply|share|save|follow|subscribe|click|try|book|download)\b/i.test(caption))
|
|
3761
4118
|
ctaScore += 2;
|
|
3762
4119
|
if (/\?$/.test(firstLine)) ctaScore += 1;
|
|
4120
|
+
if (isShortFormX) ctaScore = Math.max(ctaScore, 3);
|
|
3763
4121
|
categories.push({
|
|
3764
4122
|
name: "CTA Strength",
|
|
3765
4123
|
score: Math.min(5, ctaScore),
|
|
3766
4124
|
maxScore: 5,
|
|
3767
|
-
detail: "Should include a clear next action."
|
|
4125
|
+
detail: isShortFormX ? "Short-form X: explicit CTA optional; native posts may close without one." : "Should include a clear next action."
|
|
3768
4126
|
});
|
|
3769
4127
|
let safetyScore = 5;
|
|
3770
4128
|
if (/\b(guarantee|guaranteed|no risk|risk-free|always works|100%)\b/i.test(caption))
|
|
@@ -3822,7 +4180,9 @@ var PLATFORM_CASE_MAP = {
|
|
|
3822
4180
|
threads: "Threads",
|
|
3823
4181
|
bluesky: "Bluesky"
|
|
3824
4182
|
};
|
|
3825
|
-
var TIKTOK_AUDIT_APPROVED = false
|
|
4183
|
+
var TIKTOK_AUDIT_APPROVED = !["false", "0", "no"].includes(
|
|
4184
|
+
(process.env.TIKTOK_AUDIT_APPROVED ?? "").toLowerCase()
|
|
4185
|
+
);
|
|
3826
4186
|
var MCP_NOT_LIVE_FOR_POSTING = /* @__PURE__ */ new Set(["LinkedIn", "Pinterest", "Reddit"]);
|
|
3827
4187
|
function asEnvelope3(data) {
|
|
3828
4188
|
return {
|
|
@@ -3833,6 +4193,106 @@ function asEnvelope3(data) {
|
|
|
3833
4193
|
data
|
|
3834
4194
|
};
|
|
3835
4195
|
}
|
|
4196
|
+
function publicConnectedAccount(value) {
|
|
4197
|
+
if (!value || typeof value !== "object") return null;
|
|
4198
|
+
const row = value;
|
|
4199
|
+
if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
|
|
4200
|
+
return null;
|
|
4201
|
+
}
|
|
4202
|
+
return {
|
|
4203
|
+
id: row.id,
|
|
4204
|
+
platform: row.platform,
|
|
4205
|
+
status: row.status,
|
|
4206
|
+
...typeof row.effective_status === "string" ? { effective_status: row.effective_status } : {},
|
|
4207
|
+
username: typeof row.username === "string" ? row.username : null,
|
|
4208
|
+
created_at: row.created_at,
|
|
4209
|
+
...typeof row.updated_at === "string" || row.updated_at === null ? { updated_at: row.updated_at } : {},
|
|
4210
|
+
...typeof row.expires_at === "string" || row.expires_at === null ? { expires_at: row.expires_at } : {},
|
|
4211
|
+
...typeof row.has_refresh_token === "boolean" ? { has_refresh_token: row.has_refresh_token } : {},
|
|
4212
|
+
...typeof row.project_id === "string" || row.project_id === null ? { project_id: row.project_id } : {}
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4215
|
+
function publicPostRecord(value) {
|
|
4216
|
+
if (!value || typeof value !== "object") return null;
|
|
4217
|
+
const row = value;
|
|
4218
|
+
if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
|
|
4219
|
+
return null;
|
|
4220
|
+
}
|
|
4221
|
+
const nullableString = (field) => typeof row[field] === "string" ? row[field] : null;
|
|
4222
|
+
return {
|
|
4223
|
+
id: row.id,
|
|
4224
|
+
platform: row.platform,
|
|
4225
|
+
status: row.status,
|
|
4226
|
+
title: nullableString("title"),
|
|
4227
|
+
external_post_id: nullableString("external_post_id"),
|
|
4228
|
+
published_at: nullableString("published_at"),
|
|
4229
|
+
scheduled_at: nullableString("scheduled_at"),
|
|
4230
|
+
created_at: row.created_at
|
|
4231
|
+
};
|
|
4232
|
+
}
|
|
4233
|
+
var VIDEO_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4234
|
+
"mp4",
|
|
4235
|
+
"mov",
|
|
4236
|
+
"m4v",
|
|
4237
|
+
"webm",
|
|
4238
|
+
"avi",
|
|
4239
|
+
"mkv",
|
|
4240
|
+
"mpeg",
|
|
4241
|
+
"mpg"
|
|
4242
|
+
]);
|
|
4243
|
+
var IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4244
|
+
"jpg",
|
|
4245
|
+
"jpeg",
|
|
4246
|
+
"png",
|
|
4247
|
+
"webp",
|
|
4248
|
+
"gif",
|
|
4249
|
+
"avif",
|
|
4250
|
+
"heic"
|
|
4251
|
+
]);
|
|
4252
|
+
function mediaTypeFromPath(value) {
|
|
4253
|
+
try {
|
|
4254
|
+
const pathname = value.startsWith("http") ? new URL(value).pathname : value;
|
|
4255
|
+
const extension = pathname.split(".").pop()?.toLowerCase();
|
|
4256
|
+
if (!extension) return null;
|
|
4257
|
+
if (VIDEO_FILE_EXTENSIONS.has(extension)) return "VIDEO";
|
|
4258
|
+
if (IMAGE_FILE_EXTENSIONS.has(extension)) return "IMAGE";
|
|
4259
|
+
} catch {
|
|
4260
|
+
return null;
|
|
4261
|
+
}
|
|
4262
|
+
return null;
|
|
4263
|
+
}
|
|
4264
|
+
function inferScheduleMediaType(explicit, singleCandidates, collectionCandidates) {
|
|
4265
|
+
if (explicit) return explicit;
|
|
4266
|
+
const populatedCollection = collectionCandidates.find(
|
|
4267
|
+
(values) => Array.isArray(values) && values.length > 0
|
|
4268
|
+
);
|
|
4269
|
+
if (populatedCollection) {
|
|
4270
|
+
if (populatedCollection.length > 1) return "CAROUSEL_ALBUM";
|
|
4271
|
+
for (const values of collectionCandidates) {
|
|
4272
|
+
if (!values || values.length !== 1) continue;
|
|
4273
|
+
const inferred = mediaTypeFromPath(values[0]);
|
|
4274
|
+
if (inferred) return inferred;
|
|
4275
|
+
}
|
|
4276
|
+
return null;
|
|
4277
|
+
}
|
|
4278
|
+
for (const value of singleCandidates) {
|
|
4279
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
4280
|
+
const inferred = mediaTypeFromPath(value);
|
|
4281
|
+
if (inferred) return inferred;
|
|
4282
|
+
}
|
|
4283
|
+
return null;
|
|
4284
|
+
}
|
|
4285
|
+
async function validatePublishMediaUrl(url) {
|
|
4286
|
+
try {
|
|
4287
|
+
if (new URL(url).protocol !== "https:") {
|
|
4288
|
+
return "Media URLs must use HTTPS.";
|
|
4289
|
+
}
|
|
4290
|
+
} catch {
|
|
4291
|
+
return "Media URL is invalid.";
|
|
4292
|
+
}
|
|
4293
|
+
const check = await validateUrlForSSRF(url);
|
|
4294
|
+
return check.isValid ? null : check.error || "Media URL failed safety validation.";
|
|
4295
|
+
}
|
|
3836
4296
|
function accountEffectiveStatus(account) {
|
|
3837
4297
|
return account.effective_status || account.status;
|
|
3838
4298
|
}
|
|
@@ -3851,22 +4311,11 @@ function requestedAccountIdForPlatform(accountId, accountIds, platform2) {
|
|
|
3851
4311
|
if (!accountIds) return void 0;
|
|
3852
4312
|
return accountIds[platform2] || accountIds[platform2.toLowerCase()];
|
|
3853
4313
|
}
|
|
3854
|
-
function isAlreadyR2Signed(url) {
|
|
3855
|
-
try {
|
|
3856
|
-
const u = new URL(url);
|
|
3857
|
-
return u.searchParams.has("X-Amz-Signature");
|
|
3858
|
-
} catch {
|
|
3859
|
-
return false;
|
|
3860
|
-
}
|
|
3861
|
-
}
|
|
3862
4314
|
async function rehostExternalUrl(mediaUrl, projectId) {
|
|
3863
4315
|
const ssrf = await validateUrlForSSRF(mediaUrl);
|
|
3864
4316
|
if (!ssrf.isValid) {
|
|
3865
4317
|
return { error: ssrf.error ?? "URL rejected by SSRF check" };
|
|
3866
4318
|
}
|
|
3867
|
-
if (isAlreadyR2Signed(mediaUrl)) {
|
|
3868
|
-
return { signedUrl: mediaUrl, r2Key: "" };
|
|
3869
|
-
}
|
|
3870
4319
|
const { data, error } = await callEdgeFunction(
|
|
3871
4320
|
"upload-to-r2",
|
|
3872
4321
|
{ url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
|
|
@@ -3885,19 +4334,19 @@ function registerDistributionTools(server) {
|
|
|
3885
4334
|
media_url: z3.string().optional().describe(
|
|
3886
4335
|
"URL of the media file to post. Any public HTTPS URL works \u2014 including ephemeral generator URLs (Replicate, OpenAI, DALL-E). The server persists non-R2 URLs into R2 before posting so scheduled posts and byte-upload platforms (X, LinkedIn, YouTube, Bluesky) do not 404 when the source URL expires. Set auto_rehost=false to skip. Not needed if media_urls, r2_key, or job_id is provided."
|
|
3887
4336
|
),
|
|
3888
|
-
media_urls: z3.array(z3.string()).optional().describe(
|
|
4337
|
+
media_urls: z3.array(z3.string().url()).min(2).max(10).optional().describe(
|
|
3889
4338
|
"Array of 2-10 image URLs for carousel posts. Same rehosting rules as media_url \u2014 ephemeral URLs are persisted automatically. Use with media_type=CAROUSEL_ALBUM."
|
|
3890
4339
|
),
|
|
3891
4340
|
r2_key: z3.string().optional().describe(
|
|
3892
4341
|
"R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
|
|
3893
4342
|
),
|
|
3894
|
-
r2_keys: z3.array(z3.string()).optional().describe(
|
|
4343
|
+
r2_keys: z3.array(z3.string()).min(2).max(10).optional().describe(
|
|
3895
4344
|
"Array of R2 object keys for carousel posts. Each is signed on demand. Alternative to media_urls."
|
|
3896
4345
|
),
|
|
3897
4346
|
job_id: z3.string().optional().describe(
|
|
3898
4347
|
"Async job ID from generate_image/generate_video. Resolves the completed job's R2 key and signs it. Alternative to media_url/r2_key."
|
|
3899
4348
|
),
|
|
3900
|
-
job_ids: z3.array(z3.string()).optional().describe(
|
|
4349
|
+
job_ids: z3.array(z3.string()).min(2).max(10).optional().describe(
|
|
3901
4350
|
"Array of async job IDs for carousel posts. Each resolved to its R2 key. Alternative to media_urls/r2_keys."
|
|
3902
4351
|
),
|
|
3903
4352
|
platform_metadata: z3.object({
|
|
@@ -3927,7 +4376,10 @@ function registerDistributionTools(server) {
|
|
|
3927
4376
|
category_id: z3.string().optional(),
|
|
3928
4377
|
tags: z3.array(z3.string()).optional(),
|
|
3929
4378
|
made_for_kids: z3.boolean().optional(),
|
|
3930
|
-
notify_subscribers: z3.boolean().optional()
|
|
4379
|
+
notify_subscribers: z3.boolean().optional(),
|
|
4380
|
+
contains_synthetic_media: z3.boolean().optional().describe(
|
|
4381
|
+
"YouTube altered-or-synthetic-content disclosure. Defaults to true for MCP posts; set false explicitly for verified non-AI media."
|
|
4382
|
+
)
|
|
3931
4383
|
}).optional(),
|
|
3932
4384
|
facebook: z3.object({
|
|
3933
4385
|
page_id: z3.string().optional().describe("Facebook Page ID to post to."),
|
|
@@ -4010,14 +4462,8 @@ function registerDistributionTools(server) {
|
|
|
4010
4462
|
auto_rehost: z3.boolean().optional().describe(
|
|
4011
4463
|
"Whether to persist non-R2 media_url/media_urls into R2 before posting. Default: true. Set to false only if you know the source URL will outlive the scheduling window and every target platform supports URL ingest."
|
|
4012
4464
|
),
|
|
4013
|
-
|
|
4014
|
-
"
|
|
4015
|
-
),
|
|
4016
|
-
origin: z3.enum(["human", "hermes", "user"]).optional().describe(
|
|
4017
|
-
"Originator lineage for the post. Marks who scheduled it; use the agent value when calling from an autonomous workflow, otherwise default 'human'."
|
|
4018
|
-
),
|
|
4019
|
-
hermes_run_id: z3.string().optional().describe(
|
|
4020
|
-
"Optional agent run identifier for traceability between a draft and the run that produced it."
|
|
4465
|
+
idempotency_key: z3.string().regex(/^[a-zA-Z0-9_-]{8,128}$/).optional().describe(
|
|
4466
|
+
"Stable 8-128 character retry key (letters, numbers, underscore, hyphen). Reuse the same key when retrying the same publish request to prevent duplicate posts."
|
|
4021
4467
|
)
|
|
4022
4468
|
},
|
|
4023
4469
|
async ({
|
|
@@ -4040,9 +4486,7 @@ function registerDistributionTools(server) {
|
|
|
4040
4486
|
platform_metadata,
|
|
4041
4487
|
account_id,
|
|
4042
4488
|
account_ids,
|
|
4043
|
-
|
|
4044
|
-
origin,
|
|
4045
|
-
hermes_run_id
|
|
4489
|
+
idempotency_key
|
|
4046
4490
|
}) => {
|
|
4047
4491
|
const format = response_format ?? "text";
|
|
4048
4492
|
if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
|
|
@@ -4071,6 +4515,8 @@ function registerDistributionTools(server) {
|
|
|
4071
4515
|
}
|
|
4072
4516
|
let resolvedMediaUrl = media_url;
|
|
4073
4517
|
let resolvedMediaUrls = media_urls;
|
|
4518
|
+
let resolvedMediaUrlIsTrustedR2 = false;
|
|
4519
|
+
let resolvedMediaUrlsAreTrustedR2 = (media_urls ?? []).map(() => false);
|
|
4074
4520
|
const signR2Key = async (key) => {
|
|
4075
4521
|
const cleanKey = key.startsWith("r2://") ? key.slice(5) : key;
|
|
4076
4522
|
const { data: signData } = await callEdgeFunction(
|
|
@@ -4088,8 +4534,11 @@ function registerDistributionTools(server) {
|
|
|
4088
4534
|
);
|
|
4089
4535
|
const resultUrl = jobData?.job?.result_url;
|
|
4090
4536
|
if (!resultUrl) return null;
|
|
4091
|
-
if (!resultUrl.startsWith("http"))
|
|
4092
|
-
|
|
4537
|
+
if (!resultUrl.startsWith("http")) {
|
|
4538
|
+
const signed = await signR2Key(resultUrl);
|
|
4539
|
+
return signed ? { url: signed, trustedR2: true } : null;
|
|
4540
|
+
}
|
|
4541
|
+
return { url: resultUrl, trustedR2: false };
|
|
4093
4542
|
};
|
|
4094
4543
|
try {
|
|
4095
4544
|
if (r2_key && !resolvedMediaUrl) {
|
|
@@ -4106,6 +4555,7 @@ function registerDistributionTools(server) {
|
|
|
4106
4555
|
};
|
|
4107
4556
|
}
|
|
4108
4557
|
resolvedMediaUrl = signed;
|
|
4558
|
+
resolvedMediaUrlIsTrustedR2 = true;
|
|
4109
4559
|
} else if (job_id && !resolvedMediaUrl && !r2_key) {
|
|
4110
4560
|
const resolved = await resolveJobId(job_id);
|
|
4111
4561
|
if (!resolved) {
|
|
@@ -4119,7 +4569,8 @@ function registerDistributionTools(server) {
|
|
|
4119
4569
|
isError: true
|
|
4120
4570
|
};
|
|
4121
4571
|
}
|
|
4122
|
-
resolvedMediaUrl = resolved;
|
|
4572
|
+
resolvedMediaUrl = resolved.url;
|
|
4573
|
+
resolvedMediaUrlIsTrustedR2 = resolved.trustedR2;
|
|
4123
4574
|
}
|
|
4124
4575
|
if (r2_keys && r2_keys.length > 0 && !resolvedMediaUrls) {
|
|
4125
4576
|
const signed = await Promise.all(r2_keys.map(signR2Key));
|
|
@@ -4136,6 +4587,7 @@ function registerDistributionTools(server) {
|
|
|
4136
4587
|
};
|
|
4137
4588
|
}
|
|
4138
4589
|
resolvedMediaUrls = signed;
|
|
4590
|
+
resolvedMediaUrlsAreTrustedR2 = signed.map(() => true);
|
|
4139
4591
|
} else if (job_ids && job_ids.length > 0 && !resolvedMediaUrls && !r2_keys) {
|
|
4140
4592
|
const resolved = await Promise.all(job_ids.map(resolveJobId));
|
|
4141
4593
|
const failIdx = resolved.findIndex((r) => !r);
|
|
@@ -4150,10 +4602,12 @@ function registerDistributionTools(server) {
|
|
|
4150
4602
|
isError: true
|
|
4151
4603
|
};
|
|
4152
4604
|
}
|
|
4153
|
-
|
|
4605
|
+
const resolvedJobs = resolved;
|
|
4606
|
+
resolvedMediaUrls = resolvedJobs.map((item) => item.url);
|
|
4607
|
+
resolvedMediaUrlsAreTrustedR2 = resolvedJobs.map((item) => item.trustedR2);
|
|
4154
4608
|
}
|
|
4155
4609
|
const shouldRehost = auto_rehost !== false;
|
|
4156
|
-
if (shouldRehost && resolvedMediaUrl) {
|
|
4610
|
+
if (shouldRehost && resolvedMediaUrl && !resolvedMediaUrlIsTrustedR2) {
|
|
4157
4611
|
const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
|
|
4158
4612
|
if ("error" in rehost) {
|
|
4159
4613
|
return {
|
|
@@ -4167,10 +4621,13 @@ function registerDistributionTools(server) {
|
|
|
4167
4621
|
};
|
|
4168
4622
|
}
|
|
4169
4623
|
resolvedMediaUrl = rehost.signedUrl;
|
|
4624
|
+
resolvedMediaUrlIsTrustedR2 = true;
|
|
4170
4625
|
}
|
|
4171
4626
|
if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
|
|
4172
4627
|
const rehosted = await Promise.all(
|
|
4173
|
-
resolvedMediaUrls.map(
|
|
4628
|
+
resolvedMediaUrls.map(
|
|
4629
|
+
(u, index) => resolvedMediaUrlsAreTrustedR2[index] ? Promise.resolve({ signedUrl: u, r2Key: "" }) : rehostExternalUrl(u, project_id)
|
|
4630
|
+
)
|
|
4174
4631
|
);
|
|
4175
4632
|
const failIdx = rehosted.findIndex((r) => "error" in r);
|
|
4176
4633
|
if (failIdx !== -1) {
|
|
@@ -4186,6 +4643,7 @@ function registerDistributionTools(server) {
|
|
|
4186
4643
|
};
|
|
4187
4644
|
}
|
|
4188
4645
|
resolvedMediaUrls = rehosted.map((r) => r.signedUrl);
|
|
4646
|
+
resolvedMediaUrlsAreTrustedR2 = resolvedMediaUrls.map(() => true);
|
|
4189
4647
|
}
|
|
4190
4648
|
} catch (resolveErr) {
|
|
4191
4649
|
return {
|
|
@@ -4198,6 +4656,42 @@ function registerDistributionTools(server) {
|
|
|
4198
4656
|
isError: true
|
|
4199
4657
|
};
|
|
4200
4658
|
}
|
|
4659
|
+
const hasResolvedMedia = Boolean(
|
|
4660
|
+
resolvedMediaUrl || resolvedMediaUrls && resolvedMediaUrls.length > 0
|
|
4661
|
+
);
|
|
4662
|
+
const resolvedMediaType = inferScheduleMediaType(
|
|
4663
|
+
media_type,
|
|
4664
|
+
[resolvedMediaUrl, r2_key],
|
|
4665
|
+
[resolvedMediaUrls, r2_keys]
|
|
4666
|
+
);
|
|
4667
|
+
if (hasResolvedMedia && !resolvedMediaType) {
|
|
4668
|
+
return {
|
|
4669
|
+
content: [
|
|
4670
|
+
{
|
|
4671
|
+
type: "text",
|
|
4672
|
+
text: "media_type is required when the media format cannot be inferred from a file extension. Set IMAGE, VIDEO, or CAROUSEL_ALBUM explicitly."
|
|
4673
|
+
}
|
|
4674
|
+
],
|
|
4675
|
+
isError: true
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
const finalUrls = [resolvedMediaUrl, ...resolvedMediaUrls ?? []].filter(
|
|
4679
|
+
(value) => typeof value === "string" && value.length > 0
|
|
4680
|
+
);
|
|
4681
|
+
for (const url of finalUrls) {
|
|
4682
|
+
const validationError = await validatePublishMediaUrl(url);
|
|
4683
|
+
if (validationError) {
|
|
4684
|
+
return {
|
|
4685
|
+
content: [
|
|
4686
|
+
{
|
|
4687
|
+
type: "text",
|
|
4688
|
+
text: `Media URL blocked: ${validationError}`
|
|
4689
|
+
}
|
|
4690
|
+
],
|
|
4691
|
+
isError: true
|
|
4692
|
+
};
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4201
4695
|
const normalizedPlatforms = platforms.map(
|
|
4202
4696
|
(p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p
|
|
4203
4697
|
);
|
|
@@ -4323,12 +4817,32 @@ Created with Social Neuron`;
|
|
|
4323
4817
|
};
|
|
4324
4818
|
return true;
|
|
4325
4819
|
})();
|
|
4820
|
+
const defaultAiDisclosure = (platformKey, field) => {
|
|
4821
|
+
const existing = normalizedPlatformMetadata?.[platformKey];
|
|
4822
|
+
if (existing && existing[field] !== void 0) return;
|
|
4823
|
+
normalizedPlatformMetadata = {
|
|
4824
|
+
...normalizedPlatformMetadata ?? {},
|
|
4825
|
+
[platformKey]: {
|
|
4826
|
+
...existing ?? {},
|
|
4827
|
+
[field]: true
|
|
4828
|
+
}
|
|
4829
|
+
};
|
|
4830
|
+
};
|
|
4831
|
+
if (normalizedPlatforms.includes("TikTok")) {
|
|
4832
|
+
defaultAiDisclosure("tiktok", "is_ai_generated");
|
|
4833
|
+
}
|
|
4834
|
+
if (normalizedPlatforms.includes("Instagram")) {
|
|
4835
|
+
defaultAiDisclosure("instagram", "is_ai_generated");
|
|
4836
|
+
}
|
|
4837
|
+
if (normalizedPlatforms.includes("YouTube")) {
|
|
4838
|
+
defaultAiDisclosure("youtube", "contains_synthetic_media");
|
|
4839
|
+
}
|
|
4326
4840
|
const { data, error } = await callEdgeFunction(
|
|
4327
4841
|
"schedule-post",
|
|
4328
4842
|
{
|
|
4329
4843
|
mediaUrl: resolvedMediaUrl,
|
|
4330
4844
|
mediaUrls: resolvedMediaUrls,
|
|
4331
|
-
mediaType:
|
|
4845
|
+
mediaType: resolvedMediaType ?? void 0,
|
|
4332
4846
|
caption: finalCaption,
|
|
4333
4847
|
platforms: normalizedPlatforms,
|
|
4334
4848
|
title,
|
|
@@ -4341,14 +4855,10 @@ Created with Social Neuron`;
|
|
|
4341
4855
|
normalizedPlatformMetadata
|
|
4342
4856
|
)
|
|
4343
4857
|
} : {},
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
//
|
|
4348
|
-
// against the posts.origin CHECK constraint and persists hermesRunId when
|
|
4349
|
-
// origin === 'hermes'.
|
|
4350
|
-
...origin ? { origin } : {},
|
|
4351
|
-
...hermes_run_id ? { hermesRunId: hermes_run_id } : {}
|
|
4858
|
+
...idempotency_key ? { idempotencyKey: idempotency_key } : {}
|
|
4859
|
+
// Attribution is assigned by the authenticated gateway. Visual QA
|
|
4860
|
+
// attestations are server-produced evidence and are deliberately not
|
|
4861
|
+
// accepted from an MCP caller.
|
|
4352
4862
|
},
|
|
4353
4863
|
{ timeoutMs: 3e4 }
|
|
4354
4864
|
);
|
|
@@ -4414,6 +4924,99 @@ Created with Social Neuron`;
|
|
|
4414
4924
|
};
|
|
4415
4925
|
}
|
|
4416
4926
|
);
|
|
4927
|
+
server.tool(
|
|
4928
|
+
"reschedule_post",
|
|
4929
|
+
"Move an existing pending or scheduled post to a new future time without creating a duplicate. Pass project_id for the post's brand. expected_scheduled_at is recommended: it prevents overwriting a change made in another client after the calendar was loaded.",
|
|
4930
|
+
{
|
|
4931
|
+
post_id: z3.string().uuid().describe("Post ID returned by list_recent_posts."),
|
|
4932
|
+
project_id: z3.string().uuid().optional().describe(
|
|
4933
|
+
"Brand/project ID that owns the post. Defaults to the authenticated key's project or the account default."
|
|
4934
|
+
),
|
|
4935
|
+
scheduled_at: z3.string().datetime({ offset: true }).describe("New future publish time as an ISO 8601 datetime with timezone."),
|
|
4936
|
+
expected_scheduled_at: z3.string().datetime({ offset: true }).optional().describe(
|
|
4937
|
+
"Optional current schedule timestamp. If it changed since you read it, the update is rejected instead of silently overwriting it."
|
|
4938
|
+
),
|
|
4939
|
+
response_format: z3.enum(["text", "json"]).default("text")
|
|
4940
|
+
},
|
|
4941
|
+
async ({
|
|
4942
|
+
post_id,
|
|
4943
|
+
project_id,
|
|
4944
|
+
scheduled_at,
|
|
4945
|
+
expected_scheduled_at,
|
|
4946
|
+
response_format
|
|
4947
|
+
}) => {
|
|
4948
|
+
const next = new Date(scheduled_at);
|
|
4949
|
+
if (!Number.isFinite(next.getTime()) || next.getTime() <= Date.now()) {
|
|
4950
|
+
return {
|
|
4951
|
+
content: [
|
|
4952
|
+
{
|
|
4953
|
+
type: "text",
|
|
4954
|
+
text: "scheduled_at must be a valid future ISO datetime with timezone."
|
|
4955
|
+
}
|
|
4956
|
+
],
|
|
4957
|
+
isError: true
|
|
4958
|
+
};
|
|
4959
|
+
}
|
|
4960
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
4961
|
+
if (!resolvedProjectId) {
|
|
4962
|
+
return {
|
|
4963
|
+
content: [
|
|
4964
|
+
{
|
|
4965
|
+
type: "text",
|
|
4966
|
+
text: "No project_id was provided and no default project is configured."
|
|
4967
|
+
}
|
|
4968
|
+
],
|
|
4969
|
+
isError: true
|
|
4970
|
+
};
|
|
4971
|
+
}
|
|
4972
|
+
const userId = await getDefaultUserId();
|
|
4973
|
+
const rateLimit = checkRateLimit("posting", `reschedule_post:${userId}`);
|
|
4974
|
+
if (!rateLimit.allowed) {
|
|
4975
|
+
return {
|
|
4976
|
+
content: [
|
|
4977
|
+
{
|
|
4978
|
+
type: "text",
|
|
4979
|
+
text: `Rate limit exceeded. Retry in ~${rateLimit.retryAfter}s.`
|
|
4980
|
+
}
|
|
4981
|
+
],
|
|
4982
|
+
isError: true
|
|
4983
|
+
};
|
|
4984
|
+
}
|
|
4985
|
+
const { data: result, error } = await callEdgeFunction("mcp-data", {
|
|
4986
|
+
action: "reschedule-scheduled-post",
|
|
4987
|
+
post_id,
|
|
4988
|
+
projectId: resolvedProjectId,
|
|
4989
|
+
project_id: resolvedProjectId,
|
|
4990
|
+
scheduled_at: next.toISOString(),
|
|
4991
|
+
...expected_scheduled_at ? { expected_scheduled_at: new Date(expected_scheduled_at).toISOString() } : {}
|
|
4992
|
+
});
|
|
4993
|
+
if (error || !result?.success) {
|
|
4994
|
+
const code = result?.error ?? error ?? "reschedule_failed";
|
|
4995
|
+
const recovery = code === "publishing_in_progress" ? "The worker has already started publishing this post." : code === "schedule_conflict" ? `The schedule changed in another client${result?.current_scheduled_at ? ` to ${result.current_scheduled_at}` : ""}; refresh the calendar before retrying.` : code === "not_found" ? "The post was not found in this project." : code === "not_reschedulable" ? `This post can no longer be rescheduled${result?.status ? ` (status: ${result.status})` : ""}.` : "The post could not be rescheduled.";
|
|
4996
|
+
return {
|
|
4997
|
+
content: [{ type: "text", text: recovery }],
|
|
4998
|
+
isError: true
|
|
4999
|
+
};
|
|
5000
|
+
}
|
|
5001
|
+
const publicResult = {
|
|
5002
|
+
success: true,
|
|
5003
|
+
post_id: result.post_id ?? post_id,
|
|
5004
|
+
project_id: result.project_id ?? resolvedProjectId,
|
|
5005
|
+
previous_scheduled_at: result.previous_scheduled_at ?? null,
|
|
5006
|
+
scheduled_at: result.scheduled_at ?? next.toISOString()
|
|
5007
|
+
};
|
|
5008
|
+
const structuredContent = asEnvelope3(publicResult);
|
|
5009
|
+
return {
|
|
5010
|
+
structuredContent,
|
|
5011
|
+
content: [
|
|
5012
|
+
{
|
|
5013
|
+
type: "text",
|
|
5014
|
+
text: response_format === "json" ? JSON.stringify(structuredContent, null, 2) : `Post ${publicResult.post_id} rescheduled to ${publicResult.scheduled_at}.`
|
|
5015
|
+
}
|
|
5016
|
+
]
|
|
5017
|
+
};
|
|
5018
|
+
}
|
|
5019
|
+
);
|
|
4417
5020
|
server.tool(
|
|
4418
5021
|
"list_connected_accounts",
|
|
4419
5022
|
"Check which social platforms have active OAuth connections for posting. Call this before schedule_post to verify credentials. Pass project_id to list the accounts for a specific brand/project, then pass the returned account id as account_id/account_ids when posting. If a platform is missing or expired, the user needs to reconnect at socialneuron.com/settings/connections.",
|
|
@@ -4442,14 +5045,16 @@ Created with Social Neuron`;
|
|
|
4442
5045
|
isError: true
|
|
4443
5046
|
};
|
|
4444
5047
|
}
|
|
4445
|
-
const accounts = result.accounts ?? [];
|
|
5048
|
+
const accounts = (result.accounts ?? []).map(publicConnectedAccount).filter((account) => account !== null);
|
|
4446
5049
|
if (accounts.length === 0) {
|
|
4447
5050
|
if (format === "json") {
|
|
5051
|
+
const structuredContent = asEnvelope3({ accounts: [] });
|
|
4448
5052
|
return {
|
|
5053
|
+
structuredContent,
|
|
4449
5054
|
content: [
|
|
4450
5055
|
{
|
|
4451
5056
|
type: "text",
|
|
4452
|
-
text: JSON.stringify(
|
|
5057
|
+
text: JSON.stringify(structuredContent, null, 2)
|
|
4453
5058
|
}
|
|
4454
5059
|
]
|
|
4455
5060
|
};
|
|
@@ -4477,11 +5082,13 @@ Created with Social Neuron`;
|
|
|
4477
5082
|
);
|
|
4478
5083
|
}
|
|
4479
5084
|
if (format === "json") {
|
|
5085
|
+
const structuredContent = asEnvelope3({ accounts });
|
|
4480
5086
|
return {
|
|
5087
|
+
structuredContent,
|
|
4481
5088
|
content: [
|
|
4482
5089
|
{
|
|
4483
5090
|
type: "text",
|
|
4484
|
-
text: JSON.stringify(
|
|
5091
|
+
text: JSON.stringify(structuredContent, null, 2)
|
|
4485
5092
|
}
|
|
4486
5093
|
]
|
|
4487
5094
|
};
|
|
@@ -4495,6 +5102,9 @@ Created with Social Neuron`;
|
|
|
4495
5102
|
"list_recent_posts",
|
|
4496
5103
|
"List recent published and scheduled posts with status, platform, title, and timestamps. Use to check what has been posted before planning new content, or to find post IDs for fetch_analytics. Filter by platform or status to narrow results.",
|
|
4497
5104
|
{
|
|
5105
|
+
project_id: z3.string().uuid().optional().describe(
|
|
5106
|
+
"Brand/project ID to scope posts. Defaults to the authenticated key's project or the account default."
|
|
5107
|
+
),
|
|
4498
5108
|
platform: z3.enum([
|
|
4499
5109
|
"youtube",
|
|
4500
5110
|
"tiktok",
|
|
@@ -4510,13 +5120,27 @@ Created with Social Neuron`;
|
|
|
4510
5120
|
limit: z3.number().min(1).max(50).optional().describe("Maximum number of posts to return. Defaults to 20."),
|
|
4511
5121
|
response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
4512
5122
|
},
|
|
4513
|
-
async ({ platform: platform2, status, days, limit, response_format }) => {
|
|
5123
|
+
async ({ project_id, platform: platform2, status, days, limit, response_format }) => {
|
|
4514
5124
|
const format = response_format ?? "text";
|
|
4515
5125
|
const lookbackDays = days ?? 7;
|
|
4516
|
-
const
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
5126
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
5127
|
+
if (!resolvedProjectId) {
|
|
5128
|
+
return {
|
|
5129
|
+
content: [
|
|
5130
|
+
{
|
|
5131
|
+
type: "text",
|
|
5132
|
+
text: "No project_id was provided and no default project is configured."
|
|
5133
|
+
}
|
|
5134
|
+
],
|
|
5135
|
+
isError: true
|
|
5136
|
+
};
|
|
5137
|
+
}
|
|
5138
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
5139
|
+
action: "recent-posts",
|
|
5140
|
+
days: lookbackDays,
|
|
5141
|
+
limit: limit ?? 20,
|
|
5142
|
+
projectId: resolvedProjectId,
|
|
5143
|
+
project_id: resolvedProjectId,
|
|
4520
5144
|
...platform2 ? { platform: platform2 } : {},
|
|
4521
5145
|
...status ? { status } : {}
|
|
4522
5146
|
});
|
|
@@ -4531,7 +5155,7 @@ Created with Social Neuron`;
|
|
|
4531
5155
|
isError: true
|
|
4532
5156
|
};
|
|
4533
5157
|
}
|
|
4534
|
-
const rows = result.posts ?? [];
|
|
5158
|
+
const rows = (result.posts ?? []).map(publicPostRecord).filter((post) => post !== null);
|
|
4535
5159
|
if (rows.length === 0) {
|
|
4536
5160
|
if (format === "json") {
|
|
4537
5161
|
const structuredContent2 = asEnvelope3({ posts: [] });
|
|
@@ -4606,8 +5230,11 @@ Created with Social Neuron`;
|
|
|
4606
5230
|
};
|
|
4607
5231
|
server.tool(
|
|
4608
5232
|
"find_next_slots",
|
|
4609
|
-
"Find optimal posting time slots based on
|
|
5233
|
+
"Find optimal posting time slots for one brand/project based on preferred posting times and that project's existing schedule. Returns non-conflicting slots sorted by engagement score.",
|
|
4610
5234
|
{
|
|
5235
|
+
project_id: z3.string().uuid().optional().describe(
|
|
5236
|
+
"Brand/project ID used for conflict detection. Defaults to the authenticated key's project or the account default."
|
|
5237
|
+
),
|
|
4611
5238
|
platforms: z3.array(
|
|
4612
5239
|
z3.enum([
|
|
4613
5240
|
"youtube",
|
|
@@ -4626,6 +5253,7 @@ Created with Social Neuron`;
|
|
|
4626
5253
|
response_format: z3.enum(["text", "json"]).default("text")
|
|
4627
5254
|
},
|
|
4628
5255
|
async ({
|
|
5256
|
+
project_id,
|
|
4629
5257
|
platforms,
|
|
4630
5258
|
count,
|
|
4631
5259
|
start_after,
|
|
@@ -4633,13 +5261,35 @@ Created with Social Neuron`;
|
|
|
4633
5261
|
response_format
|
|
4634
5262
|
}) => {
|
|
4635
5263
|
try {
|
|
5264
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
5265
|
+
if (!resolvedProjectId) {
|
|
5266
|
+
return {
|
|
5267
|
+
content: [
|
|
5268
|
+
{
|
|
5269
|
+
type: "text",
|
|
5270
|
+
text: "No project_id was provided and no default project is configured."
|
|
5271
|
+
}
|
|
5272
|
+
],
|
|
5273
|
+
isError: true
|
|
5274
|
+
};
|
|
5275
|
+
}
|
|
4636
5276
|
const startDate = start_after ? new Date(start_after) : /* @__PURE__ */ new Date();
|
|
5277
|
+
if (!Number.isFinite(startDate.getTime())) {
|
|
5278
|
+
return {
|
|
5279
|
+
content: [
|
|
5280
|
+
{ type: "text", text: "start_after must be a valid ISO datetime." }
|
|
5281
|
+
],
|
|
5282
|
+
isError: true
|
|
5283
|
+
};
|
|
5284
|
+
}
|
|
4637
5285
|
const endDate = new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1e3);
|
|
4638
5286
|
const { data: postsResult, error: postsError } = await callEdgeFunction("mcp-data", {
|
|
4639
5287
|
action: "scheduled-posts",
|
|
4640
5288
|
start_date: startDate.toISOString(),
|
|
4641
5289
|
end_date: endDate.toISOString(),
|
|
4642
|
-
statuses: ["scheduled", "draft"]
|
|
5290
|
+
statuses: ["pending", "scheduled", "draft"],
|
|
5291
|
+
projectId: resolvedProjectId,
|
|
5292
|
+
project_id: resolvedProjectId
|
|
4643
5293
|
});
|
|
4644
5294
|
const existingPosts = postsError ? [] : postsResult?.posts ?? [];
|
|
4645
5295
|
const gapMs = min_gap_hours * 60 * 60 * 1e3;
|
|
@@ -4679,19 +5329,17 @@ Created with Social Neuron`;
|
|
|
4679
5329
|
const slots = candidates.filter((s) => !s.conflict).sort((a, b) => b.engagement_score - a.engagement_score).slice(0, count);
|
|
4680
5330
|
const conflictsAvoided = candidates.filter((s) => s.conflict).length;
|
|
4681
5331
|
if (response_format === "json") {
|
|
5332
|
+
const structuredContent = asEnvelope3({
|
|
5333
|
+
slots,
|
|
5334
|
+
total_candidates: candidates.length,
|
|
5335
|
+
conflicts_avoided: conflictsAvoided
|
|
5336
|
+
});
|
|
4682
5337
|
return {
|
|
5338
|
+
structuredContent,
|
|
4683
5339
|
content: [
|
|
4684
5340
|
{
|
|
4685
5341
|
type: "text",
|
|
4686
|
-
text: JSON.stringify(
|
|
4687
|
-
asEnvelope3({
|
|
4688
|
-
slots,
|
|
4689
|
-
total_candidates: candidates.length,
|
|
4690
|
-
conflicts_avoided: conflictsAvoided
|
|
4691
|
-
}),
|
|
4692
|
-
null,
|
|
4693
|
-
2
|
|
4694
|
-
)
|
|
5342
|
+
text: JSON.stringify(structuredContent, null, 2)
|
|
4695
5343
|
}
|
|
4696
5344
|
],
|
|
4697
5345
|
isError: false
|
|
@@ -5056,7 +5704,7 @@ Created with Social Neuron`;
|
|
|
5056
5704
|
const results = [];
|
|
5057
5705
|
const buildIdempotencyKey = (post) => {
|
|
5058
5706
|
const planId = effectivePlanId ?? (typeof workingPlan.plan_id === "string" ? String(workingPlan.plan_id) : "inline");
|
|
5059
|
-
const captionHash =
|
|
5707
|
+
const captionHash = createHash("sha256").update(post.caption).digest("hex").slice(0, 16);
|
|
5060
5708
|
const raw = [
|
|
5061
5709
|
"schedule_content_plan",
|
|
5062
5710
|
planId,
|
|
@@ -5066,7 +5714,7 @@ Created with Social Neuron`;
|
|
|
5066
5714
|
captionHash,
|
|
5067
5715
|
idempotency_seed ?? ""
|
|
5068
5716
|
].join(":");
|
|
5069
|
-
return `plan-${
|
|
5717
|
+
return `plan-${createHash("sha256").update(raw).digest("hex").slice(0, 48)}`;
|
|
5070
5718
|
};
|
|
5071
5719
|
const scheduleOne = async (post) => {
|
|
5072
5720
|
if (!post.schedule_at) {
|
|
@@ -5545,7 +6193,10 @@ function registerMediaTools(server) {
|
|
|
5545
6193
|
content: [
|
|
5546
6194
|
{
|
|
5547
6195
|
type: "text",
|
|
5548
|
-
|
|
6196
|
+
// Presigned-store bodies may include provider request IDs,
|
|
6197
|
+
// bucket names, or signed URL fragments. Status is enough
|
|
6198
|
+
// for user recovery and safe diagnostics.
|
|
6199
|
+
text: `R2 upload failed (HTTP ${putResp.status}). Please retry.`
|
|
5549
6200
|
}
|
|
5550
6201
|
],
|
|
5551
6202
|
isError: true
|
|
@@ -5764,7 +6415,11 @@ function registerAnalyticsTools(server) {
|
|
|
5764
6415
|
action: "analytics",
|
|
5765
6416
|
platform: platform2,
|
|
5766
6417
|
days: lookbackDays,
|
|
5767
|
-
|
|
6418
|
+
// Fetch extra snapshots, then deduplicate to the requested post count.
|
|
6419
|
+
// The backend understands latestOnly after the paired application
|
|
6420
|
+
// deployment; older deployments safely ignore the hint.
|
|
6421
|
+
limit: Math.min(maxPosts * 5, 100),
|
|
6422
|
+
latestOnly: true,
|
|
5768
6423
|
contentId: content_id,
|
|
5769
6424
|
...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
|
|
5770
6425
|
});
|
|
@@ -5774,7 +6429,14 @@ function registerAnalyticsTools(server) {
|
|
|
5774
6429
|
isError: true
|
|
5775
6430
|
};
|
|
5776
6431
|
}
|
|
5777
|
-
const
|
|
6432
|
+
const snapshotRows = result?.rows ?? [];
|
|
6433
|
+
const seenSnapshots = /* @__PURE__ */ new Set();
|
|
6434
|
+
const rows = [...snapshotRows].sort((a, b) => b.captured_at.localeCompare(a.captured_at)).filter((row) => {
|
|
6435
|
+
const key = `${row.post_id}:${row.platform.toLowerCase()}`;
|
|
6436
|
+
if (seenSnapshots.has(key)) return false;
|
|
6437
|
+
seenSnapshots.add(key);
|
|
6438
|
+
return true;
|
|
6439
|
+
}).slice(0, maxPosts);
|
|
5778
6440
|
if (rows.length === 0) {
|
|
5779
6441
|
if (format === "json") {
|
|
5780
6442
|
const structuredContent = asEnvelope4({
|
|
@@ -5947,6 +6609,80 @@ function formatAnalytics(summary, days, format) {
|
|
|
5947
6609
|
// src/tools/brand.ts
|
|
5948
6610
|
import { z as z6 } from "zod";
|
|
5949
6611
|
init_supabase();
|
|
6612
|
+
|
|
6613
|
+
// src/lib/brandUrlInput.ts
|
|
6614
|
+
var PLATFORM_ALIASES = {
|
|
6615
|
+
instagram: "instagram",
|
|
6616
|
+
ig: "instagram",
|
|
6617
|
+
insta: "instagram",
|
|
6618
|
+
tiktok: "tiktok",
|
|
6619
|
+
tt: "tiktok",
|
|
6620
|
+
twitter: "twitter",
|
|
6621
|
+
x: "twitter",
|
|
6622
|
+
linkedin: "linkedin",
|
|
6623
|
+
li: "linkedin",
|
|
6624
|
+
youtube: "youtube",
|
|
6625
|
+
yt: "youtube"
|
|
6626
|
+
};
|
|
6627
|
+
var PROFILE_URL_BUILDERS = {
|
|
6628
|
+
instagram: (handle) => `https://instagram.com/${encodeURIComponent(handle)}`,
|
|
6629
|
+
tiktok: (handle) => `https://tiktok.com/@${encodeURIComponent(handle)}`,
|
|
6630
|
+
twitter: (handle) => `https://x.com/${encodeURIComponent(handle)}`,
|
|
6631
|
+
linkedin: (handle) => `https://linkedin.com/company/${encodeURIComponent(handle)}`,
|
|
6632
|
+
youtube: (handle) => `https://youtube.com/@${encodeURIComponent(handle)}`
|
|
6633
|
+
};
|
|
6634
|
+
var UNICODE_DOT_VARIANTS = /[。.。]/g;
|
|
6635
|
+
function canonicalizeHost(hostname) {
|
|
6636
|
+
return hostname.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, "");
|
|
6637
|
+
}
|
|
6638
|
+
function hostLooksLikeDomain(hostname) {
|
|
6639
|
+
return /\S+\.\S+/.test(canonicalizeHost(hostname));
|
|
6640
|
+
}
|
|
6641
|
+
function resolveHandle(handle, platform2) {
|
|
6642
|
+
const normalized = handle.replace(/^@/, "").trim();
|
|
6643
|
+
if (!normalized) return { url: null, handle: null, platform: null, ambiguous: false };
|
|
6644
|
+
if (!platform2) return { url: null, handle: normalized, platform: null, ambiguous: true };
|
|
6645
|
+
return {
|
|
6646
|
+
url: PROFILE_URL_BUILDERS[platform2](normalized),
|
|
6647
|
+
handle: normalized,
|
|
6648
|
+
platform: platform2,
|
|
6649
|
+
ambiguous: false
|
|
6650
|
+
};
|
|
6651
|
+
}
|
|
6652
|
+
function normalizeBrandUrlInput(raw, explicitPlatform) {
|
|
6653
|
+
const input = (raw ?? "").trim();
|
|
6654
|
+
if (!input) return { url: null, handle: null, platform: null, ambiguous: false };
|
|
6655
|
+
if (/^https?:\/\//i.test(input)) {
|
|
6656
|
+
let parsed;
|
|
6657
|
+
try {
|
|
6658
|
+
parsed = new URL(input);
|
|
6659
|
+
} catch {
|
|
6660
|
+
return { url: null, handle: null, platform: null, ambiguous: false, invalidUrl: true };
|
|
6661
|
+
}
|
|
6662
|
+
if (hostLooksLikeDomain(parsed.hostname)) {
|
|
6663
|
+
return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
|
|
6664
|
+
}
|
|
6665
|
+
return resolveHandle(canonicalizeHost(parsed.hostname), explicitPlatform);
|
|
6666
|
+
}
|
|
6667
|
+
const qualified = input.match(/^([a-z]{1,10}):\s*@?(.+)$/i);
|
|
6668
|
+
if (qualified) {
|
|
6669
|
+
const platform2 = PLATFORM_ALIASES[qualified[1].toLowerCase()];
|
|
6670
|
+
if (platform2) return resolveHandle(qualified[2], platform2);
|
|
6671
|
+
}
|
|
6672
|
+
if (input.startsWith("@")) return resolveHandle(input, explicitPlatform);
|
|
6673
|
+
if (!/\s/.test(input)) {
|
|
6674
|
+
try {
|
|
6675
|
+
const parsed = new URL(`https://${input}`);
|
|
6676
|
+
if (hostLooksLikeDomain(parsed.hostname)) {
|
|
6677
|
+
return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
|
|
6678
|
+
}
|
|
6679
|
+
} catch {
|
|
6680
|
+
}
|
|
6681
|
+
}
|
|
6682
|
+
return resolveHandle(input.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, ""), explicitPlatform);
|
|
6683
|
+
}
|
|
6684
|
+
|
|
6685
|
+
// src/tools/brand.ts
|
|
5950
6686
|
function asEnvelope5(data) {
|
|
5951
6687
|
return {
|
|
5952
6688
|
_meta: {
|
|
@@ -5961,12 +6697,31 @@ function registerBrandTools(server) {
|
|
|
5961
6697
|
"extract_brand",
|
|
5962
6698
|
"Analyze a website URL and extract brand identity data including brand name, colors, voice/tone, target audience, and logo. Uses AI-powered analysis of the page HTML. Useful for understanding a brand before generating content for it.",
|
|
5963
6699
|
{
|
|
5964
|
-
url: z6.string().
|
|
5965
|
-
'The website URL to analyze for brand identity (e.g. "https://example.com").'
|
|
6700
|
+
url: z6.string().min(1).max(2048).describe(
|
|
6701
|
+
'The website URL to analyze for brand identity (e.g. "https://example.com"). Bare handles are rejected; supply a full profile URL or use "platform:handle" shorthand (e.g. "instagram:acmefoods").'
|
|
5966
6702
|
),
|
|
5967
6703
|
response_format: z6.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
5968
6704
|
},
|
|
5969
|
-
async ({ url, response_format }) => {
|
|
6705
|
+
async ({ url: rawUrl, response_format }) => {
|
|
6706
|
+
const normalized = normalizeBrandUrlInput(rawUrl);
|
|
6707
|
+
if (normalized.invalidUrl) {
|
|
6708
|
+
return {
|
|
6709
|
+
content: [{ type: "text", text: `URL blocked: "${rawUrl}" is not a valid URL.` }],
|
|
6710
|
+
isError: true
|
|
6711
|
+
};
|
|
6712
|
+
}
|
|
6713
|
+
if (!normalized.url) {
|
|
6714
|
+
return {
|
|
6715
|
+
content: [
|
|
6716
|
+
{
|
|
6717
|
+
type: "text",
|
|
6718
|
+
text: `"${rawUrl}" looks like a handle, not a full URL. Provide a profile URL or use "platform:handle" shorthand (for example "instagram:acmefoods").`
|
|
6719
|
+
}
|
|
6720
|
+
],
|
|
6721
|
+
isError: true
|
|
6722
|
+
};
|
|
6723
|
+
}
|
|
6724
|
+
const url = normalized.url;
|
|
5970
6725
|
const ssrfCheck = await validateUrlForSSRF(url);
|
|
5971
6726
|
if (!ssrfCheck.isValid) {
|
|
5972
6727
|
return {
|
|
@@ -6750,7 +7505,7 @@ function registerRemotionTools(server) {
|
|
|
6750
7505
|
},
|
|
6751
7506
|
async ({ composition_id, output_format, props }) => {
|
|
6752
7507
|
const userId = await getDefaultUserId();
|
|
6753
|
-
const rateLimit = checkRateLimit("
|
|
7508
|
+
const rateLimit = checkRateLimit("generation", `render_demo_video:${userId}`);
|
|
6754
7509
|
if (!rateLimit.allowed) {
|
|
6755
7510
|
return {
|
|
6756
7511
|
content: [
|
|
@@ -10399,7 +11154,9 @@ function toolKnowledgeDocument(tool) {
|
|
|
10399
11154
|
function getKnowledgeDocuments() {
|
|
10400
11155
|
return [
|
|
10401
11156
|
...STATIC_KNOWLEDGE_DOCUMENTS,
|
|
10402
|
-
...TOOL_CATALOG.filter((t) => !t.internal).map(
|
|
11157
|
+
...TOOL_CATALOG.filter((t) => !t.internal && !t.hiddenFromPublicCount).map(
|
|
11158
|
+
toolKnowledgeDocument
|
|
11159
|
+
)
|
|
10403
11160
|
];
|
|
10404
11161
|
}
|
|
10405
11162
|
function tokenize(input) {
|
|
@@ -10516,7 +11273,7 @@ function registerDiscoveryTools(server) {
|
|
|
10516
11273
|
if (query) {
|
|
10517
11274
|
results = searchTools(query);
|
|
10518
11275
|
}
|
|
10519
|
-
results = results.filter((t) => !t.internal);
|
|
11276
|
+
results = results.filter((t) => !t.internal && !t.hiddenFromPublicCount);
|
|
10520
11277
|
if (module) {
|
|
10521
11278
|
const moduleTools = getToolsByModule(module);
|
|
10522
11279
|
results = results.filter((t) => moduleTools.some((mt) => mt.name === t.name));
|
|
@@ -12338,11 +13095,11 @@ function hexToLab(hex) {
|
|
|
12338
13095
|
const lb = srgbToLinear(b);
|
|
12339
13096
|
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
|
|
12340
13097
|
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
|
|
12341
|
-
const
|
|
13098
|
+
const z41 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
|
|
12342
13099
|
const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
12343
13100
|
const fx = f(x / 0.95047);
|
|
12344
13101
|
const fy = f(y / 1);
|
|
12345
|
-
const fz = f(
|
|
13102
|
+
const fz = f(z41 / 1.08883);
|
|
12346
13103
|
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
|
12347
13104
|
}
|
|
12348
13105
|
function deltaE2000(lab1, lab2) {
|
|
@@ -12575,10 +13332,10 @@ function registerBrandRuntimeTools(server) {
|
|
|
12575
13332
|
pagesScraped: meta.pagesScraped || 0
|
|
12576
13333
|
}
|
|
12577
13334
|
};
|
|
12578
|
-
const
|
|
13335
|
+
const envelope2 = asEnvelope23(runtime);
|
|
12579
13336
|
return {
|
|
12580
|
-
structuredContent:
|
|
12581
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13337
|
+
structuredContent: envelope2,
|
|
13338
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12582
13339
|
};
|
|
12583
13340
|
}
|
|
12584
13341
|
);
|
|
@@ -12718,9 +13475,9 @@ function registerBrandRuntimeTools(server) {
|
|
|
12718
13475
|
}
|
|
12719
13476
|
const profile = row.profile_data;
|
|
12720
13477
|
const checkResult = computeBrandConsistency(content, profile);
|
|
12721
|
-
const
|
|
13478
|
+
const envelope2 = asEnvelope23(checkResult);
|
|
12722
13479
|
return {
|
|
12723
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13480
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12724
13481
|
};
|
|
12725
13482
|
}
|
|
12726
13483
|
);
|
|
@@ -12752,9 +13509,9 @@ function registerBrandRuntimeTools(server) {
|
|
|
12752
13509
|
content_colors,
|
|
12753
13510
|
threshold ?? 10
|
|
12754
13511
|
);
|
|
12755
|
-
const
|
|
13512
|
+
const envelope2 = asEnvelope23(auditResult);
|
|
12756
13513
|
return {
|
|
12757
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13514
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12758
13515
|
};
|
|
12759
13516
|
}
|
|
12760
13517
|
);
|
|
@@ -12787,9 +13544,9 @@ function registerBrandRuntimeTools(server) {
|
|
|
12787
13544
|
row.profile_data.typography,
|
|
12788
13545
|
format
|
|
12789
13546
|
);
|
|
12790
|
-
const
|
|
13547
|
+
const envelope2 = asEnvelope23({ format, tokens: output });
|
|
12791
13548
|
return {
|
|
12792
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13549
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12793
13550
|
};
|
|
12794
13551
|
}
|
|
12795
13552
|
);
|
|
@@ -12945,7 +13702,7 @@ function registerCarouselTools(server) {
|
|
|
12945
13702
|
};
|
|
12946
13703
|
}
|
|
12947
13704
|
const userId = await getDefaultUserId();
|
|
12948
|
-
const rateLimit = checkRateLimit("
|
|
13705
|
+
const rateLimit = checkRateLimit("generation", `create_carousel:${userId}`);
|
|
12949
13706
|
if (!rateLimit.allowed) {
|
|
12950
13707
|
return {
|
|
12951
13708
|
content: [
|
|
@@ -13017,7 +13774,12 @@ function registerCarouselTools(server) {
|
|
|
13017
13774
|
slideNumber: slide.slideNumber,
|
|
13018
13775
|
jobId: null,
|
|
13019
13776
|
model: image_model,
|
|
13020
|
-
error: error ?? "No job ID returned"
|
|
13777
|
+
error: error ?? "No job ID returned",
|
|
13778
|
+
creditsReserved: data?.credits_reserved ?? null,
|
|
13779
|
+
creditsCharged: data?.credits_charged ?? null,
|
|
13780
|
+
creditsRefunded: data?.credits_refunded ?? null,
|
|
13781
|
+
billingStatus: data?.billing_status ?? "unknown",
|
|
13782
|
+
failureReason: data?.failure_reason ?? null
|
|
13021
13783
|
};
|
|
13022
13784
|
}
|
|
13023
13785
|
const jobId = data.asyncJobId ?? data.taskId ?? null;
|
|
@@ -13029,14 +13791,24 @@ function registerCarouselTools(server) {
|
|
|
13029
13791
|
slideNumber: slide.slideNumber,
|
|
13030
13792
|
jobId,
|
|
13031
13793
|
model: image_model,
|
|
13032
|
-
error: null
|
|
13794
|
+
error: null,
|
|
13795
|
+
creditsReserved: 0,
|
|
13796
|
+
creditsCharged: data.creditsDeducted ?? perImageCost,
|
|
13797
|
+
creditsRefunded: 0,
|
|
13798
|
+
billingStatus: "charged",
|
|
13799
|
+
failureReason: null
|
|
13033
13800
|
};
|
|
13034
13801
|
} catch (err) {
|
|
13035
13802
|
return {
|
|
13036
13803
|
slideNumber: slide.slideNumber,
|
|
13037
13804
|
jobId: null,
|
|
13038
13805
|
model: image_model,
|
|
13039
|
-
error: sanitizeError(err)
|
|
13806
|
+
error: sanitizeError(err),
|
|
13807
|
+
creditsReserved: null,
|
|
13808
|
+
creditsCharged: null,
|
|
13809
|
+
creditsRefunded: null,
|
|
13810
|
+
billingStatus: "unknown",
|
|
13811
|
+
failureReason: null
|
|
13040
13812
|
};
|
|
13041
13813
|
}
|
|
13042
13814
|
})
|
|
@@ -13061,7 +13833,8 @@ function registerCarouselTools(server) {
|
|
|
13061
13833
|
return {
|
|
13062
13834
|
...s,
|
|
13063
13835
|
imageJobId: job?.jobId ?? null,
|
|
13064
|
-
imageError: job?.error ?? null
|
|
13836
|
+
imageError: job?.error ?? null,
|
|
13837
|
+
imageBillingStatus: job?.billingStatus ?? "unknown"
|
|
13065
13838
|
};
|
|
13066
13839
|
}),
|
|
13067
13840
|
imageModel: image_model,
|
|
@@ -13073,11 +13846,19 @@ function registerCarouselTools(server) {
|
|
|
13073
13846
|
jobIds: successfulJobs.map((j) => j.jobId),
|
|
13074
13847
|
failedSlides: failedJobs.map((j) => ({
|
|
13075
13848
|
slideNumber: j.slideNumber,
|
|
13076
|
-
error: j.error
|
|
13849
|
+
error: j.error,
|
|
13850
|
+
credits_reserved: j.creditsReserved,
|
|
13851
|
+
credits_charged: j.creditsCharged,
|
|
13852
|
+
credits_refunded: j.creditsRefunded,
|
|
13853
|
+
billing_status: j.billingStatus,
|
|
13854
|
+
failure_reason: j.failureReason
|
|
13077
13855
|
})),
|
|
13078
13856
|
credits: {
|
|
13079
13857
|
textGeneration: textCredits,
|
|
13080
13858
|
imagesEstimated: successfulJobs.length * perImageCost,
|
|
13859
|
+
imagesCharged: imageJobs.reduce((sum, job) => sum + (job.creditsCharged ?? 0), 0),
|
|
13860
|
+
imagesRefunded: imageJobs.reduce((sum, job) => sum + (job.creditsRefunded ?? 0), 0),
|
|
13861
|
+
billingUnknownSlides: imageJobs.filter((job) => job.billingStatus === "unknown").length,
|
|
13081
13862
|
totalEstimated: textCredits + successfulJobs.length * perImageCost
|
|
13082
13863
|
}
|
|
13083
13864
|
}
|
|
@@ -13348,7 +14129,7 @@ function registerHyperframesTools(server) {
|
|
|
13348
14129
|
);
|
|
13349
14130
|
server.tool(
|
|
13350
14131
|
"render_hyperframes",
|
|
13351
|
-
"Render an HTML video composition (Hyperframes) to MP4.
|
|
14132
|
+
"Render an HTML video composition (Hyperframes) to MP4 \u2014 frame-accurate, no React build step. The page MUST expose window.__hf = { duration: <seconds>, seek: (t) => void }; the renderer calls seek(t) per frame (GSAP timelines work when driven from seek). Missing window.__hf causes a terminal timeout; poll check_status and verify its reported billing/refund outcome. Use list_hyperframes_blocks for the pre-built block catalog. Pass project_id to keep the render with the correct brand/project. Returns a job ID \u2014 poll with check_status.",
|
|
13352
14133
|
{
|
|
13353
14134
|
composition_html: z30.string().max(5e5).optional().describe(
|
|
13354
14135
|
"Inline HTML composition (full <html>...</html>). Max 500KB. Use composition_url for larger."
|
|
@@ -13361,7 +14142,8 @@ function registerHyperframesTools(server) {
|
|
|
13361
14142
|
duration_sec: z30.number().min(0.1).max(600).describe("Output duration in seconds. Required. Max 600 (10 min)."),
|
|
13362
14143
|
fps: z30.union([z30.literal(24), z30.literal(30), z30.literal(60)]).optional().describe("Frames per second. Default 30."),
|
|
13363
14144
|
quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master."),
|
|
13364
|
-
project_id: z30.string().optional().describe("Project ID to associate the Hyperframes render with.")
|
|
14145
|
+
project_id: z30.string().optional().describe("Project ID to associate the Hyperframes render with."),
|
|
14146
|
+
response_format: z30.enum(["text", "json"]).optional().describe("Response format. Use json for a stable job_id handoff.")
|
|
13365
14147
|
},
|
|
13366
14148
|
async ({
|
|
13367
14149
|
composition_html,
|
|
@@ -13371,7 +14153,8 @@ function registerHyperframesTools(server) {
|
|
|
13371
14153
|
duration_sec,
|
|
13372
14154
|
fps,
|
|
13373
14155
|
quality,
|
|
13374
|
-
project_id
|
|
14156
|
+
project_id,
|
|
14157
|
+
response_format
|
|
13375
14158
|
}) => {
|
|
13376
14159
|
const userId = await getDefaultUserId();
|
|
13377
14160
|
const rateLimit = checkRateLimit("generation", `render_hyperframes:${userId}`);
|
|
@@ -13422,11 +14205,23 @@ function registerHyperframesTools(server) {
|
|
|
13422
14205
|
if (error || !data?.jobId) {
|
|
13423
14206
|
throw new Error(error || data?.error || "Failed to create Hyperframes render job");
|
|
13424
14207
|
}
|
|
14208
|
+
const payload = {
|
|
14209
|
+
job_id: data.jobId,
|
|
14210
|
+
jobId: data.jobId,
|
|
14211
|
+
status: data.status,
|
|
14212
|
+
credits_cost: data.creditsCost,
|
|
14213
|
+
credits: data.creditsCost,
|
|
14214
|
+
duration_sec,
|
|
14215
|
+
fps: fps || 30,
|
|
14216
|
+
aspect_ratio: aspect_ratio || "9:16",
|
|
14217
|
+
quality: quality || "standard",
|
|
14218
|
+
project_id: project_id || null
|
|
14219
|
+
};
|
|
13425
14220
|
return {
|
|
13426
14221
|
content: [
|
|
13427
14222
|
{
|
|
13428
14223
|
type: "text",
|
|
13429
|
-
text: [
|
|
14224
|
+
text: response_format === "json" ? JSON.stringify({ data: payload }) : [
|
|
13430
14225
|
`Hyperframes render job queued.`,
|
|
13431
14226
|
` Job ID: ${data.jobId}`,
|
|
13432
14227
|
` Credits: ${data.creditsCost}`,
|
|
@@ -13443,7 +14238,7 @@ function registerHyperframesTools(server) {
|
|
|
13443
14238
|
content: [
|
|
13444
14239
|
{
|
|
13445
14240
|
type: "text",
|
|
13446
|
-
text: `Failed to queue Hyperframes render: ${err
|
|
14241
|
+
text: `Failed to queue Hyperframes render: ${sanitizeError(err)}`
|
|
13447
14242
|
}
|
|
13448
14243
|
],
|
|
13449
14244
|
isError: true
|
|
@@ -13462,7 +14257,17 @@ import {
|
|
|
13462
14257
|
import { z as z31 } from "zod";
|
|
13463
14258
|
import fs from "node:fs/promises";
|
|
13464
14259
|
import path from "node:path";
|
|
13465
|
-
|
|
14260
|
+
import { fileURLToPath } from "node:url";
|
|
14261
|
+
init_request_context();
|
|
14262
|
+
init_supabase();
|
|
14263
|
+
var CALENDAR_URI = "ui://content-calendar/v1/mcp-app.html";
|
|
14264
|
+
var CALENDAR_CSP = {
|
|
14265
|
+
// The HTML is fully self-contained. Tool calls travel over the host bridge,
|
|
14266
|
+
// not fetch/XHR, so the secure default is no network or remote resources.
|
|
14267
|
+
connectDomains: [],
|
|
14268
|
+
resourceDomains: [],
|
|
14269
|
+
frameDomains: []
|
|
14270
|
+
};
|
|
13466
14271
|
var RecentPostOutputSchema = z31.object({
|
|
13467
14272
|
id: z31.string(),
|
|
13468
14273
|
platform: z31.string(),
|
|
@@ -13475,47 +14280,119 @@ var RecentPostOutputSchema = z31.object({
|
|
|
13475
14280
|
});
|
|
13476
14281
|
function startOfCurrentWeekMonday() {
|
|
13477
14282
|
const now = /* @__PURE__ */ new Date();
|
|
13478
|
-
const day = now.
|
|
14283
|
+
const day = now.getUTCDay();
|
|
13479
14284
|
const monday = new Date(now);
|
|
13480
|
-
monday.setUTCDate(now.getUTCDate() - day +
|
|
14285
|
+
monday.setUTCDate(now.getUTCDate() - (day + 6) % 7);
|
|
14286
|
+
monday.setUTCHours(0, 0, 0, 0);
|
|
13481
14287
|
return monday.toISOString().split("T")[0];
|
|
13482
14288
|
}
|
|
14289
|
+
function endOfWeek(startDate) {
|
|
14290
|
+
const end = /* @__PURE__ */ new Date(`${startDate}T00:00:00.000Z`);
|
|
14291
|
+
end.setUTCDate(end.getUTCDate() + 7);
|
|
14292
|
+
end.setUTCMilliseconds(end.getUTCMilliseconds() - 1);
|
|
14293
|
+
return end.toISOString();
|
|
14294
|
+
}
|
|
14295
|
+
function isStrictIsoDate(value) {
|
|
14296
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
14297
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
14298
|
+
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
14299
|
+
}
|
|
14300
|
+
function publicRecentPost(value) {
|
|
14301
|
+
if (!value || typeof value !== "object") return null;
|
|
14302
|
+
const row = value;
|
|
14303
|
+
if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
|
|
14304
|
+
return null;
|
|
14305
|
+
}
|
|
14306
|
+
const optional = (name) => typeof row[name] === "string" ? row[name] : null;
|
|
14307
|
+
return {
|
|
14308
|
+
id: row.id,
|
|
14309
|
+
platform: row.platform,
|
|
14310
|
+
status: row.status,
|
|
14311
|
+
title: optional("title"),
|
|
14312
|
+
external_post_id: optional("external_post_id"),
|
|
14313
|
+
published_at: optional("published_at"),
|
|
14314
|
+
scheduled_at: optional("scheduled_at"),
|
|
14315
|
+
created_at: row.created_at
|
|
14316
|
+
};
|
|
14317
|
+
}
|
|
14318
|
+
function calendarHtmlCandidates() {
|
|
14319
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
14320
|
+
return [
|
|
14321
|
+
// Bundled HTTP entry: import.meta.url is dist/http.js.
|
|
14322
|
+
path.join(moduleDir, "apps/content-calendar/mcp-app.html"),
|
|
14323
|
+
// Source/tsx/vitest: import.meta.url is src/apps/content-calendar.ts.
|
|
14324
|
+
path.resolve(moduleDir, "../../dist/apps/content-calendar/mcp-app.html"),
|
|
14325
|
+
// Local development fallback; never the sole package path.
|
|
14326
|
+
path.resolve(process.cwd(), "dist/apps/content-calendar/mcp-app.html")
|
|
14327
|
+
];
|
|
14328
|
+
}
|
|
14329
|
+
async function readCalendarHtml() {
|
|
14330
|
+
for (const candidate of calendarHtmlCandidates()) {
|
|
14331
|
+
try {
|
|
14332
|
+
return await fs.readFile(candidate, "utf-8");
|
|
14333
|
+
} catch (error) {
|
|
14334
|
+
if (error.code !== "ENOENT") throw error;
|
|
14335
|
+
}
|
|
14336
|
+
}
|
|
14337
|
+
throw new Error("calendar_bundle_missing");
|
|
14338
|
+
}
|
|
13483
14339
|
function registerContentCalendarApp(server) {
|
|
13484
14340
|
registerAppTool(
|
|
13485
14341
|
server,
|
|
13486
14342
|
"open_content_calendar",
|
|
13487
14343
|
{
|
|
13488
14344
|
title: "Content Calendar",
|
|
13489
|
-
description: "Open
|
|
14345
|
+
description: "Open a project-scoped interactive calendar for the current week. Users can filter, inspect, quick-create, suggest a slot, and reschedule pending posts with optimistic conflict protection.",
|
|
13490
14346
|
inputSchema: {
|
|
13491
|
-
|
|
14347
|
+
project_id: z31.string().uuid().optional().describe(
|
|
14348
|
+
"Brand/project ID. Defaults to the authenticated key's project or the account default."
|
|
14349
|
+
),
|
|
14350
|
+
start_date: z31.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe(
|
|
13492
14351
|
"ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday."
|
|
13493
14352
|
)
|
|
13494
14353
|
},
|
|
13495
14354
|
outputSchema: {
|
|
13496
14355
|
start_date: z31.string(),
|
|
14356
|
+
project_id: z31.string(),
|
|
13497
14357
|
posts: z31.array(RecentPostOutputSchema),
|
|
13498
14358
|
scopes: z31.array(z31.string())
|
|
13499
14359
|
},
|
|
13500
14360
|
_meta: {
|
|
13501
14361
|
ui: {
|
|
13502
|
-
resourceUri: CALENDAR_URI
|
|
13503
|
-
csp: {
|
|
13504
|
-
"img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
|
|
13505
|
-
"connect-src": ["'self'"]
|
|
13506
|
-
}
|
|
14362
|
+
resourceUri: CALENDAR_URI
|
|
13507
14363
|
}
|
|
13508
14364
|
}
|
|
13509
14365
|
},
|
|
13510
|
-
async ({ start_date }
|
|
13511
|
-
const userScopes =
|
|
14366
|
+
async ({ project_id, start_date }) => {
|
|
14367
|
+
const userScopes = getRequestScopes() ?? getAuthenticatedScopes();
|
|
14368
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId();
|
|
14369
|
+
if (!resolvedProjectId) {
|
|
14370
|
+
return {
|
|
14371
|
+
content: [
|
|
14372
|
+
{
|
|
14373
|
+
type: "text",
|
|
14374
|
+
text: "No project_id was provided and no default project is configured."
|
|
14375
|
+
}
|
|
14376
|
+
],
|
|
14377
|
+
isError: true
|
|
14378
|
+
};
|
|
14379
|
+
}
|
|
13512
14380
|
const fromDate = start_date ?? startOfCurrentWeekMonday();
|
|
14381
|
+
if (!isStrictIsoDate(fromDate)) {
|
|
14382
|
+
return {
|
|
14383
|
+
content: [{ type: "text", text: "start_date must be a valid YYYY-MM-DD date." }],
|
|
14384
|
+
isError: true
|
|
14385
|
+
};
|
|
14386
|
+
}
|
|
13513
14387
|
const { data: result, error } = await callEdgeFunction(
|
|
13514
14388
|
"mcp-data",
|
|
13515
14389
|
{
|
|
13516
|
-
action: "
|
|
13517
|
-
|
|
13518
|
-
|
|
14390
|
+
action: "scheduled-posts",
|
|
14391
|
+
start_date: `${fromDate}T00:00:00.000Z`,
|
|
14392
|
+
end_date: endOfWeek(fromDate),
|
|
14393
|
+
statuses: ["pending", "scheduled", "draft"],
|
|
14394
|
+
projectId: resolvedProjectId,
|
|
14395
|
+
project_id: resolvedProjectId
|
|
13519
14396
|
},
|
|
13520
14397
|
{ timeoutMs: 15e3 }
|
|
13521
14398
|
);
|
|
@@ -13524,19 +14401,16 @@ function registerContentCalendarApp(server) {
|
|
|
13524
14401
|
content: [
|
|
13525
14402
|
{
|
|
13526
14403
|
type: "text",
|
|
13527
|
-
text:
|
|
14404
|
+
text: "The content calendar could not load posts. Please retry."
|
|
13528
14405
|
}
|
|
13529
14406
|
],
|
|
13530
14407
|
isError: true
|
|
13531
14408
|
};
|
|
13532
14409
|
}
|
|
13533
|
-
const posts = (result.posts ?? []).filter((
|
|
13534
|
-
const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
|
|
13535
|
-
if (!ts) return false;
|
|
13536
|
-
return ts.split("T")[0] >= fromDate;
|
|
13537
|
-
});
|
|
14410
|
+
const posts = (result.posts ?? []).map(publicRecentPost).filter((post) => post !== null);
|
|
13538
14411
|
const structuredContent = {
|
|
13539
14412
|
start_date: fromDate,
|
|
14413
|
+
project_id: resolvedProjectId,
|
|
13540
14414
|
posts,
|
|
13541
14415
|
scopes: userScopes
|
|
13542
14416
|
};
|
|
@@ -13555,34 +14429,290 @@ function registerContentCalendarApp(server) {
|
|
|
13555
14429
|
server,
|
|
13556
14430
|
CALENDAR_URI,
|
|
13557
14431
|
CALENDAR_URI,
|
|
13558
|
-
{
|
|
14432
|
+
{
|
|
14433
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
14434
|
+
description: "Self-contained Social Neuron project content calendar.",
|
|
14435
|
+
_meta: { ui: { csp: CALENDAR_CSP } }
|
|
14436
|
+
},
|
|
13559
14437
|
async () => {
|
|
13560
|
-
const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
|
|
13561
14438
|
try {
|
|
13562
|
-
const html = await
|
|
14439
|
+
const html = await readCalendarHtml();
|
|
13563
14440
|
return {
|
|
13564
|
-
contents: [
|
|
14441
|
+
contents: [
|
|
14442
|
+
{
|
|
14443
|
+
uri: CALENDAR_URI,
|
|
14444
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
14445
|
+
text: html,
|
|
14446
|
+
_meta: { ui: { csp: CALENDAR_CSP } }
|
|
14447
|
+
}
|
|
14448
|
+
]
|
|
13565
14449
|
};
|
|
13566
|
-
} catch
|
|
14450
|
+
} catch {
|
|
13567
14451
|
const errorHtml = `<!DOCTYPE html>
|
|
13568
14452
|
<html><head><title>Content Calendar \u2014 unavailable</title></head>
|
|
13569
14453
|
<body style="font-family:sans-serif;padding:24px;color:#444;">
|
|
13570
14454
|
<h2>Content Calendar app bundle missing</h2>
|
|
13571
|
-
<p>The
|
|
13572
|
-
<code>apps/content-calendar/dist/mcp-app.html</code> is not built.
|
|
13573
|
-
Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
|
|
13574
|
-
<p style="color:#999;font-size:12px;">${err.message}</p>
|
|
14455
|
+
<p>The interactive bundle is unavailable on this deployment. Please contact Social Neuron support.</p>
|
|
13575
14456
|
</body></html>`;
|
|
13576
14457
|
return {
|
|
13577
|
-
contents: [
|
|
14458
|
+
contents: [
|
|
14459
|
+
{
|
|
14460
|
+
uri: CALENDAR_URI,
|
|
14461
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
14462
|
+
text: errorHtml,
|
|
14463
|
+
_meta: { ui: { csp: CALENDAR_CSP } }
|
|
14464
|
+
}
|
|
14465
|
+
]
|
|
13578
14466
|
};
|
|
13579
14467
|
}
|
|
13580
14468
|
}
|
|
13581
14469
|
);
|
|
13582
14470
|
}
|
|
13583
14471
|
|
|
13584
|
-
// src/
|
|
14472
|
+
// src/apps/analytics-pulse.ts
|
|
14473
|
+
import {
|
|
14474
|
+
registerAppResource as registerAppResource2,
|
|
14475
|
+
registerAppTool as registerAppTool2,
|
|
14476
|
+
RESOURCE_MIME_TYPE as RESOURCE_MIME_TYPE2
|
|
14477
|
+
} from "@modelcontextprotocol/ext-apps/server";
|
|
13585
14478
|
import { z as z32 } from "zod";
|
|
14479
|
+
import fs2 from "node:fs/promises";
|
|
14480
|
+
import path2 from "node:path";
|
|
14481
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
14482
|
+
init_supabase();
|
|
14483
|
+
var ANALYTICS_URI = "ui://analytics-pulse/v1/mcp-app.html";
|
|
14484
|
+
var ANALYTICS_CSP = {
|
|
14485
|
+
connectDomains: [],
|
|
14486
|
+
resourceDomains: [],
|
|
14487
|
+
frameDomains: []
|
|
14488
|
+
};
|
|
14489
|
+
var PLATFORM_VALUES = [
|
|
14490
|
+
"youtube",
|
|
14491
|
+
"tiktok",
|
|
14492
|
+
"instagram",
|
|
14493
|
+
"twitter",
|
|
14494
|
+
"linkedin",
|
|
14495
|
+
"facebook",
|
|
14496
|
+
"threads",
|
|
14497
|
+
"bluesky"
|
|
14498
|
+
];
|
|
14499
|
+
var AnalyticsPostOutputSchema = z32.object({
|
|
14500
|
+
platform: z32.string(),
|
|
14501
|
+
title: z32.string().nullable(),
|
|
14502
|
+
views: z32.number(),
|
|
14503
|
+
likes: z32.number(),
|
|
14504
|
+
comments: z32.number(),
|
|
14505
|
+
shares: z32.number(),
|
|
14506
|
+
engagement_rate: z32.number(),
|
|
14507
|
+
captured_at: z32.string(),
|
|
14508
|
+
published_at: z32.string().nullable()
|
|
14509
|
+
});
|
|
14510
|
+
function finiteMetric(value) {
|
|
14511
|
+
const parsed = Number(value ?? 0);
|
|
14512
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
14513
|
+
}
|
|
14514
|
+
function publicAnalyticsPost(value) {
|
|
14515
|
+
if (!value || typeof value !== "object") return null;
|
|
14516
|
+
const row = value;
|
|
14517
|
+
const post = row.posts && typeof row.posts === "object" ? row.posts : {};
|
|
14518
|
+
const platform2 = typeof row.platform === "string" ? row.platform : typeof post.platform === "string" ? post.platform : null;
|
|
14519
|
+
const capturedAt = typeof row.captured_at === "string" ? row.captured_at : null;
|
|
14520
|
+
if (!platform2 || !capturedAt) return null;
|
|
14521
|
+
const views = finiteMetric(row.views);
|
|
14522
|
+
const likes = finiteMetric(row.likes);
|
|
14523
|
+
const comments = finiteMetric(row.comments);
|
|
14524
|
+
const shares = finiteMetric(row.shares);
|
|
14525
|
+
const engagement = likes + comments + shares;
|
|
14526
|
+
return {
|
|
14527
|
+
platform: platform2,
|
|
14528
|
+
title: typeof post.title === "string" ? post.title : null,
|
|
14529
|
+
views,
|
|
14530
|
+
likes,
|
|
14531
|
+
comments,
|
|
14532
|
+
shares,
|
|
14533
|
+
engagement_rate: views > 0 ? Number((engagement / views * 100).toFixed(2)) : 0,
|
|
14534
|
+
captured_at: capturedAt,
|
|
14535
|
+
published_at: typeof post.published_at === "string" ? post.published_at : null
|
|
14536
|
+
};
|
|
14537
|
+
}
|
|
14538
|
+
function latestAnalyticsRows(values) {
|
|
14539
|
+
const rows = values.filter((value) => Boolean(value) && typeof value === "object").sort((a, b) => String(b.captured_at ?? "").localeCompare(String(a.captured_at ?? "")));
|
|
14540
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14541
|
+
const latest = [];
|
|
14542
|
+
for (const row of rows) {
|
|
14543
|
+
if (typeof row.post_id !== "string" || typeof row.platform !== "string") continue;
|
|
14544
|
+
const key = `${row.post_id}:${row.platform.toLowerCase()}`;
|
|
14545
|
+
if (seen.has(key)) continue;
|
|
14546
|
+
seen.add(key);
|
|
14547
|
+
latest.push(row);
|
|
14548
|
+
}
|
|
14549
|
+
return latest;
|
|
14550
|
+
}
|
|
14551
|
+
function analyticsHtmlCandidates() {
|
|
14552
|
+
const moduleDir = path2.dirname(fileURLToPath2(import.meta.url));
|
|
14553
|
+
return [
|
|
14554
|
+
path2.join(moduleDir, "apps/analytics-pulse/mcp-app.html"),
|
|
14555
|
+
path2.resolve(moduleDir, "../../dist/apps/analytics-pulse/mcp-app.html"),
|
|
14556
|
+
path2.resolve(process.cwd(), "dist/apps/analytics-pulse/mcp-app.html")
|
|
14557
|
+
];
|
|
14558
|
+
}
|
|
14559
|
+
async function readAnalyticsHtml() {
|
|
14560
|
+
for (const candidate of analyticsHtmlCandidates()) {
|
|
14561
|
+
try {
|
|
14562
|
+
return await fs2.readFile(candidate, "utf-8");
|
|
14563
|
+
} catch (error) {
|
|
14564
|
+
if (error.code !== "ENOENT") throw error;
|
|
14565
|
+
}
|
|
14566
|
+
}
|
|
14567
|
+
throw new Error("analytics_bundle_missing");
|
|
14568
|
+
}
|
|
14569
|
+
function registerAnalyticsPulseApp(server) {
|
|
14570
|
+
registerAppTool2(
|
|
14571
|
+
server,
|
|
14572
|
+
"open_analytics_pulse",
|
|
14573
|
+
{
|
|
14574
|
+
title: "Analytics Pulse",
|
|
14575
|
+
description: "Open a project-scoped performance dashboard with views, engagement, platform mix, and top-post metrics. Use it to review results visually before planning the next content cycle.",
|
|
14576
|
+
inputSchema: {
|
|
14577
|
+
project_id: z32.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default."),
|
|
14578
|
+
platform: z32.enum(PLATFORM_VALUES).optional().describe("Optional platform filter."),
|
|
14579
|
+
days: z32.number().int().min(1).max(365).optional().describe("Lookback window. Defaults to 30 days.")
|
|
14580
|
+
},
|
|
14581
|
+
outputSchema: {
|
|
14582
|
+
project_id: z32.string(),
|
|
14583
|
+
platform: z32.string().nullable(),
|
|
14584
|
+
days: z32.number(),
|
|
14585
|
+
summary: z32.object({
|
|
14586
|
+
views: z32.number(),
|
|
14587
|
+
engagement: z32.number(),
|
|
14588
|
+
engagement_rate: z32.number(),
|
|
14589
|
+
posts: z32.number()
|
|
14590
|
+
}),
|
|
14591
|
+
platform_totals: z32.array(
|
|
14592
|
+
z32.object({
|
|
14593
|
+
platform: z32.string(),
|
|
14594
|
+
views: z32.number(),
|
|
14595
|
+
engagement: z32.number(),
|
|
14596
|
+
posts: z32.number()
|
|
14597
|
+
})
|
|
14598
|
+
),
|
|
14599
|
+
posts: z32.array(AnalyticsPostOutputSchema)
|
|
14600
|
+
},
|
|
14601
|
+
_meta: { ui: { resourceUri: ANALYTICS_URI } }
|
|
14602
|
+
},
|
|
14603
|
+
async ({ project_id, platform: platform2, days }) => {
|
|
14604
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId();
|
|
14605
|
+
if (!resolvedProjectId) {
|
|
14606
|
+
return {
|
|
14607
|
+
content: [{ type: "text", text: "No project_id was provided and no default project is configured." }],
|
|
14608
|
+
isError: true
|
|
14609
|
+
};
|
|
14610
|
+
}
|
|
14611
|
+
const lookbackDays = days ?? 30;
|
|
14612
|
+
const { data: result, error } = await callEdgeFunction(
|
|
14613
|
+
"mcp-data",
|
|
14614
|
+
{
|
|
14615
|
+
action: "analytics",
|
|
14616
|
+
projectId: resolvedProjectId,
|
|
14617
|
+
project_id: resolvedProjectId,
|
|
14618
|
+
days: lookbackDays,
|
|
14619
|
+
// The table stores cumulative snapshots. Request a wider window and
|
|
14620
|
+
// deduplicate below so refresh frequency cannot inflate the totals.
|
|
14621
|
+
limit: 100,
|
|
14622
|
+
latestOnly: true,
|
|
14623
|
+
...platform2 ? { platform: platform2 } : {}
|
|
14624
|
+
},
|
|
14625
|
+
{ timeoutMs: 15e3 }
|
|
14626
|
+
);
|
|
14627
|
+
if (error || !result?.success) {
|
|
14628
|
+
return {
|
|
14629
|
+
content: [{ type: "text", text: "The analytics dashboard could not load data. Please retry." }],
|
|
14630
|
+
isError: true
|
|
14631
|
+
};
|
|
14632
|
+
}
|
|
14633
|
+
const posts = latestAnalyticsRows(result.rows ?? []).map(publicAnalyticsPost).filter((post) => post !== null).sort((a, b) => b.views - a.views);
|
|
14634
|
+
const totals = /* @__PURE__ */ new Map();
|
|
14635
|
+
let totalViews = 0;
|
|
14636
|
+
let totalEngagement = 0;
|
|
14637
|
+
for (const post of posts) {
|
|
14638
|
+
const engagement = post.likes + post.comments + post.shares;
|
|
14639
|
+
totalViews += post.views;
|
|
14640
|
+
totalEngagement += engagement;
|
|
14641
|
+
const aggregate = totals.get(post.platform) ?? {
|
|
14642
|
+
platform: post.platform,
|
|
14643
|
+
views: 0,
|
|
14644
|
+
engagement: 0,
|
|
14645
|
+
posts: 0
|
|
14646
|
+
};
|
|
14647
|
+
aggregate.views += post.views;
|
|
14648
|
+
aggregate.engagement += engagement;
|
|
14649
|
+
aggregate.posts += 1;
|
|
14650
|
+
totals.set(post.platform, aggregate);
|
|
14651
|
+
}
|
|
14652
|
+
const structuredContent = {
|
|
14653
|
+
project_id: resolvedProjectId,
|
|
14654
|
+
platform: platform2 ?? null,
|
|
14655
|
+
days: lookbackDays,
|
|
14656
|
+
summary: {
|
|
14657
|
+
views: totalViews,
|
|
14658
|
+
engagement: totalEngagement,
|
|
14659
|
+
engagement_rate: totalViews > 0 ? Number((totalEngagement / totalViews * 100).toFixed(2)) : 0,
|
|
14660
|
+
posts: posts.length
|
|
14661
|
+
},
|
|
14662
|
+
platform_totals: [...totals.values()].sort((a, b) => b.views - a.views),
|
|
14663
|
+
posts
|
|
14664
|
+
};
|
|
14665
|
+
return {
|
|
14666
|
+
structuredContent,
|
|
14667
|
+
content: [
|
|
14668
|
+
{
|
|
14669
|
+
type: "text",
|
|
14670
|
+
text: `Loaded ${posts.length} analytics record${posts.length === 1 ? "" : "s"} for the last ${lookbackDays} days.`
|
|
14671
|
+
}
|
|
14672
|
+
]
|
|
14673
|
+
};
|
|
14674
|
+
}
|
|
14675
|
+
);
|
|
14676
|
+
registerAppResource2(
|
|
14677
|
+
server,
|
|
14678
|
+
ANALYTICS_URI,
|
|
14679
|
+
ANALYTICS_URI,
|
|
14680
|
+
{
|
|
14681
|
+
mimeType: RESOURCE_MIME_TYPE2,
|
|
14682
|
+
description: "Self-contained Social Neuron project analytics dashboard.",
|
|
14683
|
+
_meta: { ui: { csp: ANALYTICS_CSP } }
|
|
14684
|
+
},
|
|
14685
|
+
async () => {
|
|
14686
|
+
try {
|
|
14687
|
+
const html = await readAnalyticsHtml();
|
|
14688
|
+
return {
|
|
14689
|
+
contents: [
|
|
14690
|
+
{
|
|
14691
|
+
uri: ANALYTICS_URI,
|
|
14692
|
+
mimeType: RESOURCE_MIME_TYPE2,
|
|
14693
|
+
text: html,
|
|
14694
|
+
_meta: { ui: { csp: ANALYTICS_CSP } }
|
|
14695
|
+
}
|
|
14696
|
+
]
|
|
14697
|
+
};
|
|
14698
|
+
} catch {
|
|
14699
|
+
return {
|
|
14700
|
+
contents: [
|
|
14701
|
+
{
|
|
14702
|
+
uri: ANALYTICS_URI,
|
|
14703
|
+
mimeType: RESOURCE_MIME_TYPE2,
|
|
14704
|
+
text: "<!doctype html><html><body><h2>Analytics Pulse unavailable</h2><p>The interactive bundle is unavailable on this deployment. Please contact Social Neuron support.</p></body></html>",
|
|
14705
|
+
_meta: { ui: { csp: ANALYTICS_CSP } }
|
|
14706
|
+
}
|
|
14707
|
+
]
|
|
14708
|
+
};
|
|
14709
|
+
}
|
|
14710
|
+
}
|
|
14711
|
+
);
|
|
14712
|
+
}
|
|
14713
|
+
|
|
14714
|
+
// src/tools/connections.ts
|
|
14715
|
+
import { z as z33 } from "zod";
|
|
13586
14716
|
init_supabase();
|
|
13587
14717
|
var PLATFORM_ENUM4 = [
|
|
13588
14718
|
"youtube",
|
|
@@ -13610,16 +14740,16 @@ function registerConnectionTools(server) {
|
|
|
13610
14740
|
"start_platform_connection",
|
|
13611
14741
|
"Begin connecting a social platform (Instagram, TikTok, YouTube, etc.). Returns a single-use deep link the user opens in a browser to complete the one-time OAuth handshake on socialneuron.com. This is NOT another OAuth in Claude \u2014 platform connections require a browser session because the social platforms (Meta, Google, TikTok) only accept callbacks on socialneuron.com. After the user clicks the link and approves on the platform, call `wait_for_connection` to detect completion. Link expires in 2 minutes; mint a new one if needed. Use `list_connected_accounts` first to check whether the platform is already connected before calling this.",
|
|
13612
14742
|
{
|
|
13613
|
-
platform:
|
|
13614
|
-
project_id:
|
|
14743
|
+
platform: z33.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
|
|
14744
|
+
project_id: z33.string().optional().describe(
|
|
13615
14745
|
"Brand/project ID to bind the new social account to. Use this when connecting a brand-specific account."
|
|
13616
14746
|
),
|
|
13617
|
-
response_format:
|
|
14747
|
+
response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
13618
14748
|
},
|
|
13619
14749
|
async ({ platform: platform2, project_id, response_format }) => {
|
|
13620
14750
|
const format = response_format ?? "text";
|
|
13621
14751
|
const userId = await getDefaultUserId();
|
|
13622
|
-
const rl = checkRateLimit("
|
|
14752
|
+
const rl = checkRateLimit("posting", `start_platform_connection:${userId}`);
|
|
13623
14753
|
if (!rl.allowed) {
|
|
13624
14754
|
return {
|
|
13625
14755
|
content: [
|
|
@@ -13699,13 +14829,13 @@ function registerConnectionTools(server) {
|
|
|
13699
14829
|
"wait_for_connection",
|
|
13700
14830
|
"Poll until a platform connection becomes active. Use after `start_platform_connection` while the user completes the browser OAuth flow. Returns when the account row appears with status=active, or when the timeout elapses. Default timeout 120s, max 600s.",
|
|
13701
14831
|
{
|
|
13702
|
-
platform:
|
|
13703
|
-
project_id:
|
|
14832
|
+
platform: z33.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
|
|
14833
|
+
project_id: z33.string().optional().describe(
|
|
13704
14834
|
"Brand/project ID to scope the connection poll. Use the same project_id passed to start_platform_connection."
|
|
13705
14835
|
),
|
|
13706
|
-
timeout_s:
|
|
13707
|
-
poll_interval_s:
|
|
13708
|
-
response_format:
|
|
14836
|
+
timeout_s: z33.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
|
|
14837
|
+
poll_interval_s: z33.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
|
|
14838
|
+
response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
13709
14839
|
},
|
|
13710
14840
|
async ({ platform: platform2, project_id, timeout_s, poll_interval_s, response_format }) => {
|
|
13711
14841
|
const format = response_format ?? "text";
|
|
@@ -13838,7 +14968,7 @@ function registerConnectionTools(server) {
|
|
|
13838
14968
|
}
|
|
13839
14969
|
|
|
13840
14970
|
// src/tools/harness.ts
|
|
13841
|
-
import { z as
|
|
14971
|
+
import { z as z34 } from "zod";
|
|
13842
14972
|
var ALLOWED_AGENTS = [
|
|
13843
14973
|
"conductor",
|
|
13844
14974
|
"brand-brain",
|
|
@@ -13848,38 +14978,38 @@ var ALLOWED_AGENTS = [
|
|
|
13848
14978
|
"engager"
|
|
13849
14979
|
];
|
|
13850
14980
|
var writeReflectionSchema = {
|
|
13851
|
-
reflection_text:
|
|
13852
|
-
generated_by_agent:
|
|
14981
|
+
reflection_text: z34.string().min(1).max(4e3).describe("The verbal reflection text produced by the agent. 1\u20134000 characters."),
|
|
14982
|
+
generated_by_agent: z34.enum(ALLOWED_AGENTS).describe(
|
|
13853
14983
|
"Which agent produced this reflection. Must be one of: conductor, brand-brain, drafter, publisher, analyst, engager."
|
|
13854
14984
|
),
|
|
13855
|
-
provenance:
|
|
13856
|
-
content_history_id:
|
|
13857
|
-
outcome_event_id:
|
|
13858
|
-
prm_score_ids:
|
|
13859
|
-
handoff_ids:
|
|
14985
|
+
provenance: z34.object({
|
|
14986
|
+
content_history_id: z34.string().optional().describe("Related content_history row UUID."),
|
|
14987
|
+
outcome_event_id: z34.string().optional().describe("Related outcome event UUID."),
|
|
14988
|
+
prm_score_ids: z34.array(z34.string()).optional().describe("PRM score IDs that informed this reflection."),
|
|
14989
|
+
handoff_ids: z34.array(z34.string()).optional().describe("Handoff event IDs that triggered this reflection.")
|
|
13860
14990
|
}).strict().describe(
|
|
13861
14991
|
"Source evidence for this reflection. Only these four keys are accepted \u2014 unknown keys are rejected to prevent spurious provenance claims."
|
|
13862
14992
|
),
|
|
13863
|
-
brand_id:
|
|
13864
|
-
pipeline_id:
|
|
13865
|
-
post_id:
|
|
14993
|
+
brand_id: z34.string().describe("Brand profile UUID this reflection belongs to."),
|
|
14994
|
+
pipeline_id: z34.string().optional().describe("Optional: pipeline run UUID."),
|
|
14995
|
+
post_id: z34.string().optional().describe("Optional: post UUID if reflection targets a post.")
|
|
13866
14996
|
};
|
|
13867
14997
|
var readReflectionSchema = {
|
|
13868
|
-
brand_id:
|
|
13869
|
-
generated_by_agent:
|
|
14998
|
+
brand_id: z34.string().describe("Brand profile UUID to read reflections for."),
|
|
14999
|
+
generated_by_agent: z34.enum(ALLOWED_AGENTS).optional().describe(
|
|
13870
15000
|
"Optional filter: only return reflections produced by this agent. One of: conductor, brand-brain, drafter, publisher, analyst, engager."
|
|
13871
15001
|
),
|
|
13872
|
-
limit:
|
|
15002
|
+
limit: z34.number().int().min(1).max(100).optional().describe(
|
|
13873
15003
|
"Maximum number of reflections to return. Clamped to [1, 100]. Default: 50. Results are ordered by created_at DESC, then id ASC (deterministic tiebreak)."
|
|
13874
15004
|
)
|
|
13875
15005
|
};
|
|
13876
15006
|
var recordOutcomeSchema = {
|
|
13877
|
-
decision_event_id:
|
|
13878
|
-
horizon:
|
|
15007
|
+
decision_event_id: z34.string().describe("UUID of the decision_events row to record an outcome for."),
|
|
15008
|
+
horizon: z34.enum(["1h", "6h", "24h"]).describe(
|
|
13879
15009
|
"Observation horizon. Only horizon=24h with a non-null reward triggers a learning-loop update; 1h/6h are stored but inert for learning."
|
|
13880
15010
|
),
|
|
13881
|
-
reward:
|
|
13882
|
-
outcome_metrics:
|
|
15011
|
+
reward: z34.number().min(0).max(1).describe("Normalised reward signal in [0, 1]. Higher = better outcome."),
|
|
15012
|
+
outcome_metrics: z34.record(z34.string(), z34.number()).optional().describe(
|
|
13883
15013
|
'Optional map of raw metric name \u2192 value (e.g. {"likes": 42, "reach": 1200}). Stored verbatim; not used for learning directly.'
|
|
13884
15014
|
)
|
|
13885
15015
|
};
|
|
@@ -13975,7 +15105,7 @@ function registerHarnessTools(server, _ctx) {
|
|
|
13975
15105
|
}
|
|
13976
15106
|
|
|
13977
15107
|
// src/tools/hermes.ts
|
|
13978
|
-
import { z as
|
|
15108
|
+
import { z as z35 } from "zod";
|
|
13979
15109
|
function asEnvelope25(data) {
|
|
13980
15110
|
return {
|
|
13981
15111
|
_meta: {
|
|
@@ -13985,7 +15115,7 @@ function asEnvelope25(data) {
|
|
|
13985
15115
|
data
|
|
13986
15116
|
};
|
|
13987
15117
|
}
|
|
13988
|
-
var PLATFORM =
|
|
15118
|
+
var PLATFORM = z35.enum([
|
|
13989
15119
|
"instagram",
|
|
13990
15120
|
"twitter",
|
|
13991
15121
|
"linkedin",
|
|
@@ -14001,12 +15131,12 @@ function registerHermesTools(server) {
|
|
|
14001
15131
|
"Save a draft post to the SN content library. Use when an autonomous agent wants to persist a draft for review before publishing. Lands in the content library with status='draft'. The draft can then be approved/edited in the SN UI.",
|
|
14002
15132
|
{
|
|
14003
15133
|
platform: PLATFORM.describe("Target platform for the draft."),
|
|
14004
|
-
copy:
|
|
14005
|
-
project_id:
|
|
14006
|
-
media_url:
|
|
14007
|
-
hermes_run_id:
|
|
14008
|
-
source_intel_ids:
|
|
14009
|
-
response_format:
|
|
15134
|
+
copy: z35.string().min(1).max(8e3).describe("The draft post body."),
|
|
15135
|
+
project_id: z35.string().optional().describe("SN project UUID. Optional but recommended."),
|
|
15136
|
+
media_url: z35.string().url().optional().describe("Optional cover/media URL (R2 signed URL)."),
|
|
15137
|
+
hermes_run_id: z35.string().optional().describe("Agent run id, for traceability."),
|
|
15138
|
+
source_intel_ids: z35.array(z35.string()).optional().describe("Optional list of intel_signals.id rows that informed this draft."),
|
|
15139
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14010
15140
|
},
|
|
14011
15141
|
async ({ platform: platform2, copy, project_id, media_url, hermes_run_id, response_format }) => {
|
|
14012
15142
|
const { data, error } = await callEdgeFunction(
|
|
@@ -14044,15 +15174,15 @@ function registerHermesTools(server) {
|
|
|
14044
15174
|
"record_voice_lesson",
|
|
14045
15175
|
'Persist a learned voice lesson to brand_profiles.platform_voice.voice_lessons. Use after weekly reflection identifies a hook/format/CTA pattern that beats median engagement by \u226530%. Appears in SN Brand > BrandBrainPreview > "Voice lessons (auto)".',
|
|
14046
15176
|
{
|
|
14047
|
-
project_id:
|
|
14048
|
-
lesson:
|
|
14049
|
-
evidence:
|
|
14050
|
-
engagement_lift_pct:
|
|
14051
|
-
sample_size:
|
|
14052
|
-
top_examples:
|
|
15177
|
+
project_id: z35.string().describe("SN project UUID."),
|
|
15178
|
+
lesson: z35.string().min(1).max(500).describe('One-sentence rule (e.g. "lowercase IG hooks beat title-case").'),
|
|
15179
|
+
evidence: z35.object({
|
|
15180
|
+
engagement_lift_pct: z35.number().describe("Engagement lift vs baseline, in percentage points."),
|
|
15181
|
+
sample_size: z35.number().int().min(1).describe("Number of posts behind this lesson."),
|
|
15182
|
+
top_examples: z35.array(z35.string()).optional().describe("Up to 3 hook examples from the top quartile.")
|
|
14053
15183
|
}).describe("Quantitative evidence backing the lesson."),
|
|
14054
|
-
applies_to:
|
|
14055
|
-
response_format:
|
|
15184
|
+
applies_to: z35.array(PLATFORM).min(1).describe("Platforms this lesson applies to."),
|
|
15185
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14056
15186
|
},
|
|
14057
15187
|
async ({ project_id, lesson, evidence, applies_to, response_format }) => {
|
|
14058
15188
|
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
@@ -14088,10 +15218,10 @@ function registerHermesTools(server) {
|
|
|
14088
15218
|
"record_observation",
|
|
14089
15219
|
'Record an agent observation (e.g. "topic X engagement up 23% this week"). Surfaces in the analytics Playbook. Use for weekly reflection digests and mid-campaign pulse summaries.',
|
|
14090
15220
|
{
|
|
14091
|
-
summary:
|
|
14092
|
-
deltas:
|
|
14093
|
-
run_id:
|
|
14094
|
-
response_format:
|
|
15221
|
+
summary: z35.string().min(1).max(2e3).describe("One-paragraph summary, shown to the account owner."),
|
|
15222
|
+
deltas: z35.record(z35.string(), z35.union([z35.number(), z35.string(), z35.boolean()])).optional().describe("Optional structured key/value payload (e.g. {topic_x_er_pct: 23})."),
|
|
15223
|
+
run_id: z35.string().optional().describe("Agent run id for traceability."),
|
|
15224
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14095
15225
|
},
|
|
14096
15226
|
async ({ summary, deltas, run_id, response_format }) => {
|
|
14097
15227
|
const { data, error } = await callEdgeFunction("mcp-data", { action: "record-observation", summary, deltas, run_id });
|
|
@@ -14118,13 +15248,13 @@ function registerHermesTools(server) {
|
|
|
14118
15248
|
"record_intel_signal",
|
|
14119
15249
|
"Record a research/trend signal (news, HN post, competitor change, arxiv paper). Surfaces in SN Brand > Niche Intelligence. Dedupes by URL per user \u2014 safe to call multiple times for the same source.",
|
|
14120
15250
|
{
|
|
14121
|
-
source:
|
|
14122
|
-
url:
|
|
14123
|
-
topic:
|
|
14124
|
-
title:
|
|
14125
|
-
summary:
|
|
14126
|
-
score:
|
|
14127
|
-
response_format:
|
|
15251
|
+
source: z35.string().min(1).max(100).describe('Watcher name (e.g. "news-watch", "hackernews-watch").'),
|
|
15252
|
+
url: z35.string().url().max(2e3).describe("Canonical URL of the source. Used for dedupe."),
|
|
15253
|
+
topic: z35.string().max(200).optional().describe("Best-fit topic key."),
|
|
15254
|
+
title: z35.string().max(500).optional(),
|
|
15255
|
+
summary: z35.string().max(4e3).optional().describe("One-paragraph summary of the signal."),
|
|
15256
|
+
score: z35.number().optional().describe("Relevance score from the watcher (0\u201310 typical)."),
|
|
15257
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14128
15258
|
},
|
|
14129
15259
|
async ({ source, url, topic, title, summary, score, response_format }) => {
|
|
14130
15260
|
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
@@ -14162,16 +15292,16 @@ function registerHermesTools(server) {
|
|
|
14162
15292
|
"record_campaign_spend",
|
|
14163
15293
|
"Log a campaign cost line. Use when an autonomous agent incurs spend that should be attributed to a campaign \u2014 drafts, renders, paid amp. Read aggregate via get_active_campaigns.",
|
|
14164
15294
|
{
|
|
14165
|
-
campaign_id:
|
|
14166
|
-
category:
|
|
15295
|
+
campaign_id: z35.string().min(1).max(200).describe("Campaign identifier (slug)."),
|
|
15296
|
+
category: z35.enum([
|
|
14167
15297
|
"hermes_drafts",
|
|
14168
15298
|
"carousel_renders",
|
|
14169
15299
|
"analytics_pulls",
|
|
14170
15300
|
"paid_amplification",
|
|
14171
15301
|
"other"
|
|
14172
15302
|
]).describe("Cost category."),
|
|
14173
|
-
amount_usd:
|
|
14174
|
-
response_format:
|
|
15303
|
+
amount_usd: z35.number().min(0).describe("Amount in USD; supports 4 decimal places."),
|
|
15304
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14175
15305
|
},
|
|
14176
15306
|
async ({ campaign_id, category, amount_usd, response_format }) => {
|
|
14177
15307
|
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
@@ -14206,7 +15336,7 @@ function registerHermesTools(server) {
|
|
|
14206
15336
|
"get_active_campaigns",
|
|
14207
15337
|
"List currently-running campaigns with thesis, budget, hero format, and current spend. Use to bias drafts toward active campaign themes.",
|
|
14208
15338
|
{
|
|
14209
|
-
response_format:
|
|
15339
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14210
15340
|
},
|
|
14211
15341
|
async ({ response_format }) => {
|
|
14212
15342
|
const { data, error } = await callEdgeFunction("mcp-data", { action: "get-active-campaigns" });
|
|
@@ -14251,7 +15381,7 @@ ${"=".repeat(40)}
|
|
|
14251
15381
|
}
|
|
14252
15382
|
|
|
14253
15383
|
// src/tools/skills.ts
|
|
14254
|
-
import { z as
|
|
15384
|
+
import { z as z36 } from "zod";
|
|
14255
15385
|
|
|
14256
15386
|
// src/lib/skills-manifest.ts
|
|
14257
15387
|
var SKILLS_MANIFEST = [
|
|
@@ -14280,6 +15410,19 @@ function listSkills(opts) {
|
|
|
14280
15410
|
}
|
|
14281
15411
|
|
|
14282
15412
|
// src/tools/skills.ts
|
|
15413
|
+
function renderCatalogRow(row) {
|
|
15414
|
+
const lines = [];
|
|
15415
|
+
const platform2 = row.platform ? ` \xB7 ${row.platform}` : "";
|
|
15416
|
+
lines.push(`${row.slug} (${row.kind}${platform2})`);
|
|
15417
|
+
const desc = row.frontmatter && typeof row.frontmatter.description === "string" ? row.frontmatter.description : null;
|
|
15418
|
+
if (desc) lines.push(` ${desc}`);
|
|
15419
|
+
const lock = row.locked ? ` \u{1F512} (upgrade to unlock)` : "";
|
|
15420
|
+
lines.push(` Tier: ${row.tier_minimum}${lock}`);
|
|
15421
|
+
lines.push(
|
|
15422
|
+
` Model: ${row.model_id ?? "n/a"} \xB7 ${row.body_chars} chars` + (row.updated_at ? ` \xB7 updated ${row.updated_at}` : "")
|
|
15423
|
+
);
|
|
15424
|
+
return lines.join("\n");
|
|
15425
|
+
}
|
|
14283
15426
|
function asEnvelope26(data) {
|
|
14284
15427
|
return {
|
|
14285
15428
|
_meta: {
|
|
@@ -14289,7 +15432,14 @@ function asEnvelope26(data) {
|
|
|
14289
15432
|
data
|
|
14290
15433
|
};
|
|
14291
15434
|
}
|
|
14292
|
-
var STUDIO_VALUES = [
|
|
15435
|
+
var STUDIO_VALUES = [
|
|
15436
|
+
"video",
|
|
15437
|
+
"avatar",
|
|
15438
|
+
"carousel",
|
|
15439
|
+
"voice",
|
|
15440
|
+
"caption",
|
|
15441
|
+
"edit"
|
|
15442
|
+
];
|
|
14293
15443
|
function renderSkillSummary(skill) {
|
|
14294
15444
|
const lines = [];
|
|
14295
15445
|
lines.push(`${skill.name} (${skill.id})`);
|
|
@@ -14308,19 +15458,88 @@ function registerSkillsTools(server) {
|
|
|
14308
15458
|
"list_skills",
|
|
14309
15459
|
'List Social Neuron content workflow skills available to the authenticated user. A skill is a brand-locked multi-step pipeline (research \u2192 hook \u2192 script \u2192 visuals \u2192 voice \u2192 captions \u2192 assembly \u2192 quality gate \u2192 schedule) inspired by documented viral patterns (MrBeast 3-second hook, Hormozi pattern interrupt, etc.). Use this tool when the user asks "what can SN do", "what skills are available", "show me viral templates", or before calling run_skill so you can pick the right one.',
|
|
14310
15460
|
{
|
|
14311
|
-
studio:
|
|
14312
|
-
|
|
14313
|
-
|
|
15461
|
+
studio: z36.enum(STUDIO_VALUES).optional().describe(
|
|
15462
|
+
"Filter to one studio (video, avatar, carousel, voice, caption, edit)."
|
|
15463
|
+
),
|
|
15464
|
+
featured_only: z36.boolean().optional().describe(
|
|
15465
|
+
"Return only featured (recommended) skills. Defaults to false."
|
|
15466
|
+
),
|
|
15467
|
+
response_format: z36.enum(["text", "json"]).optional().describe(
|
|
15468
|
+
"Response format. Defaults to text \u2014 human-readable summary."
|
|
15469
|
+
)
|
|
14314
15470
|
},
|
|
14315
15471
|
async ({ studio, featured_only, response_format }) => {
|
|
14316
|
-
const skills = listSkills({ studio, featuredOnly: featured_only });
|
|
14317
15472
|
const format = response_format ?? "text";
|
|
15473
|
+
const { data: efData, error: efError } = await callEdgeFunction("mcp-data", { action: "get-skills" });
|
|
15474
|
+
const catalogRows = efData?.skills;
|
|
15475
|
+
if (!efError && Array.isArray(catalogRows) && catalogRows.length > 0) {
|
|
15476
|
+
const workflows = listSkills({ studio, featuredOnly: featured_only });
|
|
15477
|
+
const filteredGuideRows = catalogRows.filter(
|
|
15478
|
+
(row) => !studio && (!featured_only || row.frontmatter?.featured === true)
|
|
15479
|
+
);
|
|
15480
|
+
const guides = filteredGuideRows.map((row) => ({
|
|
15481
|
+
...row,
|
|
15482
|
+
use_with: "get_skill"
|
|
15483
|
+
}));
|
|
15484
|
+
const total = guides.length + workflows.length;
|
|
15485
|
+
if (format === "json") {
|
|
15486
|
+
return {
|
|
15487
|
+
content: [
|
|
15488
|
+
{
|
|
15489
|
+
type: "text",
|
|
15490
|
+
text: JSON.stringify(
|
|
15491
|
+
asEnvelope26({
|
|
15492
|
+
count: total,
|
|
15493
|
+
guides,
|
|
15494
|
+
workflows: workflows.map((w) => ({
|
|
15495
|
+
...w,
|
|
15496
|
+
use_with: "run_skill"
|
|
15497
|
+
}))
|
|
15498
|
+
}),
|
|
15499
|
+
null,
|
|
15500
|
+
2
|
|
15501
|
+
)
|
|
15502
|
+
}
|
|
15503
|
+
]
|
|
15504
|
+
};
|
|
15505
|
+
}
|
|
15506
|
+
const guideBlocks = filteredGuideRows.map(
|
|
15507
|
+
(row) => `${renderCatalogRow(row)}
|
|
15508
|
+
\u2192 Read it: get_skill(slug: "${row.slug}")`
|
|
15509
|
+
).join("\n\n");
|
|
15510
|
+
const workflowBlocks = workflows.map(renderSkillSummary).join("\n\n");
|
|
15511
|
+
const header2 = `${total} skill${total === 1 ? "" : "s"} available (${guides.length} guide${guides.length === 1 ? "" : "s"} \xB7 ${workflows.length} workflow${workflows.length === 1 ? "" : "s"})
|
|
15512
|
+
${"=".repeat(40)}`;
|
|
15513
|
+
const sections = [
|
|
15514
|
+
guides.length > 0 ? `GUIDES \u2014 living how-to documents. Fetch with get_skill(slug).
|
|
15515
|
+
|
|
15516
|
+
${guideBlocks}` : "",
|
|
15517
|
+
workflows.length > 0 ? `WORKFLOWS \u2014 executable content pipelines. Launch with run_skill(skill_id).
|
|
15518
|
+
|
|
15519
|
+
${workflowBlocks}` : ""
|
|
15520
|
+
].filter(Boolean);
|
|
15521
|
+
return {
|
|
15522
|
+
content: [
|
|
15523
|
+
{
|
|
15524
|
+
type: "text",
|
|
15525
|
+
text: `${header2}
|
|
15526
|
+
|
|
15527
|
+
${sections.join("\n\n")}`
|
|
15528
|
+
}
|
|
15529
|
+
]
|
|
15530
|
+
};
|
|
15531
|
+
}
|
|
15532
|
+
const skills = listSkills({ studio, featuredOnly: featured_only });
|
|
14318
15533
|
if (format === "json") {
|
|
14319
15534
|
return {
|
|
14320
15535
|
content: [
|
|
14321
15536
|
{
|
|
14322
15537
|
type: "text",
|
|
14323
|
-
text: JSON.stringify(
|
|
15538
|
+
text: JSON.stringify(
|
|
15539
|
+
asEnvelope26({ count: skills.length, skills }),
|
|
15540
|
+
null,
|
|
15541
|
+
2
|
|
15542
|
+
)
|
|
14324
15543
|
}
|
|
14325
15544
|
]
|
|
14326
15545
|
};
|
|
@@ -14330,7 +15549,9 @@ function registerSkillsTools(server) {
|
|
|
14330
15549
|
content: [
|
|
14331
15550
|
{
|
|
14332
15551
|
type: "text",
|
|
14333
|
-
text: "No skills match the filter." + (studio ? ` Studio "${studio}" has no skills yet.` : "") + "\n\nAvailable studios with skills: " + Array.from(new Set(SKILLS_MANIFEST.map((s) => s.studio))).join(
|
|
15552
|
+
text: "No skills match the filter." + (studio ? ` Studio "${studio}" has no skills yet.` : "") + "\n\nAvailable studios with skills: " + Array.from(new Set(SKILLS_MANIFEST.map((s) => s.studio))).join(
|
|
15553
|
+
", "
|
|
15554
|
+
)
|
|
14334
15555
|
}
|
|
14335
15556
|
]
|
|
14336
15557
|
};
|
|
@@ -14349,18 +15570,22 @@ ${blocks}` }]
|
|
|
14349
15570
|
"run_skill",
|
|
14350
15571
|
"Run a Social Neuron workflow skill end-to-end (brand-locked content production). Returns a structured run preview with the exact step plan, credit cost, and a deep-link to launch the run in the SN dashboard. A future release executes in-process so you can stream step-by-step progress back to the user. Call list_skills first to discover available skill ids.",
|
|
14351
15572
|
{
|
|
14352
|
-
skill_id:
|
|
15573
|
+
skill_id: z36.string().describe(
|
|
14353
15574
|
'The id of the skill to run (e.g. "skill-brand-locked-viral-hook-reel"). Use list_skills to discover available ids.'
|
|
14354
15575
|
),
|
|
14355
|
-
topic:
|
|
14356
|
-
|
|
15576
|
+
topic: z36.string().min(1).describe(
|
|
15577
|
+
'What the content is about (e.g. "why we built Social Neuron").'
|
|
15578
|
+
),
|
|
15579
|
+
audience: z36.string().optional().describe(
|
|
14357
15580
|
'Who the content is for (e.g. "first-time founders"). Defaults to brand persona.'
|
|
14358
15581
|
),
|
|
14359
|
-
hook:
|
|
15582
|
+
hook: z36.string().optional().describe(
|
|
14360
15583
|
"Optional explicit hook. If omitted, the skill drafts and scores 3-5 candidates."
|
|
14361
15584
|
),
|
|
14362
|
-
cta:
|
|
14363
|
-
|
|
15585
|
+
cta: z36.string().optional().describe(
|
|
15586
|
+
"Optional call-to-action override. Defaults to brand standard CTA."
|
|
15587
|
+
),
|
|
15588
|
+
response_format: z36.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
|
|
14364
15589
|
},
|
|
14365
15590
|
async ({ skill_id, topic, audience, hook, cta, response_format }) => {
|
|
14366
15591
|
const skill = getSkill(skill_id);
|
|
@@ -14428,10 +15653,78 @@ ${blocks}` }]
|
|
|
14428
15653
|
};
|
|
14429
15654
|
}
|
|
14430
15655
|
);
|
|
15656
|
+
server.tool(
|
|
15657
|
+
"get_skill",
|
|
15658
|
+
`Fetch the full body of a single Social Neuron skill by slug \u2014 the hand-maintained strategy/specs plus the machine-maintained "what's working now" compiled section. Use this after list_skills when the user wants the actual playbook for a platform (e.g. "show me the TikTok skill"). Returns the skill body, compiled section, tier, and linked recipe slug (if any).`,
|
|
15659
|
+
{
|
|
15660
|
+
slug: z36.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9-]*$/).describe(
|
|
15661
|
+
'The skill slug (e.g. "tiktok-content"). Use list_skills to discover slugs.'
|
|
15662
|
+
),
|
|
15663
|
+
response_format: z36.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
|
|
15664
|
+
},
|
|
15665
|
+
async ({ slug, response_format }) => {
|
|
15666
|
+
const format = response_format ?? "text";
|
|
15667
|
+
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
15668
|
+
action: "get-skill",
|
|
15669
|
+
slug
|
|
15670
|
+
});
|
|
15671
|
+
if (error) {
|
|
15672
|
+
return toolError(
|
|
15673
|
+
"upstream_error",
|
|
15674
|
+
"The skill catalogue could not be loaded. Please retry."
|
|
15675
|
+
);
|
|
15676
|
+
}
|
|
15677
|
+
const skill = data?.skill;
|
|
15678
|
+
if (!skill) {
|
|
15679
|
+
return toolError("not_found", `No skill found with slug "${slug}".`, {
|
|
15680
|
+
recover_with: ["Call list_skills to see available skills."]
|
|
15681
|
+
});
|
|
15682
|
+
}
|
|
15683
|
+
if (skill.locked) {
|
|
15684
|
+
return toolError(
|
|
15685
|
+
"permission_denied",
|
|
15686
|
+
"This skill requires a higher plan tier.",
|
|
15687
|
+
{
|
|
15688
|
+
details: { slug: skill.slug, tier_minimum: skill.tier_minimum },
|
|
15689
|
+
recover_with: [
|
|
15690
|
+
"Upgrade the account or choose an unlocked skill from list_skills."
|
|
15691
|
+
]
|
|
15692
|
+
}
|
|
15693
|
+
);
|
|
15694
|
+
}
|
|
15695
|
+
if (format === "json") {
|
|
15696
|
+
return {
|
|
15697
|
+
content: [
|
|
15698
|
+
{
|
|
15699
|
+
type: "text",
|
|
15700
|
+
text: JSON.stringify(asEnvelope26(skill), null, 2)
|
|
15701
|
+
}
|
|
15702
|
+
]
|
|
15703
|
+
};
|
|
15704
|
+
}
|
|
15705
|
+
const platform2 = skill.platform ? ` \xB7 ${skill.platform}` : "";
|
|
15706
|
+
const lock = skill.locked ? " \u{1F512} (upgrade to unlock)" : "";
|
|
15707
|
+
const header = [
|
|
15708
|
+
`${skill.slug} (${skill.kind}${platform2})`,
|
|
15709
|
+
"=".repeat(40),
|
|
15710
|
+
`Tier: ${skill.tier_minimum}${lock} \xB7 version ${skill.version}` + (skill.recipe_slug ? ` \xB7 recipe: ${skill.recipe_slug}` : ""),
|
|
15711
|
+
""
|
|
15712
|
+
].join("\n");
|
|
15713
|
+
const compiled = skill.compiled_section ? `
|
|
15714
|
+
|
|
15715
|
+
--- What's working now ---
|
|
15716
|
+
${skill.compiled_section}` : "";
|
|
15717
|
+
return {
|
|
15718
|
+
content: [
|
|
15719
|
+
{ type: "text", text: `${header}${skill.body}${compiled}` }
|
|
15720
|
+
]
|
|
15721
|
+
};
|
|
15722
|
+
}
|
|
15723
|
+
);
|
|
14431
15724
|
}
|
|
14432
15725
|
|
|
14433
15726
|
// src/tools/loopPulse.ts
|
|
14434
|
-
import { z as
|
|
15727
|
+
import { z as z37 } from "zod";
|
|
14435
15728
|
function asEnvelope27(data) {
|
|
14436
15729
|
return {
|
|
14437
15730
|
_meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
|
|
@@ -14443,7 +15736,7 @@ function registerLoopPulseTools(server) {
|
|
|
14443
15736
|
"get_loop_pulse",
|
|
14444
15737
|
'Read the dynamic loop-health KPIs for the Social Neuron growth loop over the last 7 days. Returns reflection coverage, decision coverage, visual gate pass rate, learning-update application rate, per-platform learning uptake, autopilot lag, and pattern aggregation counts \u2014 each with a status ("ok" / "warn" / "bad") and a why-line explaining what the metric measures. Use this to decide whether the loop is closing or where it is stuck before recommending next moves.',
|
|
14445
15738
|
{
|
|
14446
|
-
response_format:
|
|
15739
|
+
response_format: z37.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
|
|
14447
15740
|
},
|
|
14448
15741
|
async ({ response_format }) => {
|
|
14449
15742
|
const format = response_format ?? "text";
|
|
@@ -14486,7 +15779,7 @@ function registerLoopPulseTools(server) {
|
|
|
14486
15779
|
}
|
|
14487
15780
|
|
|
14488
15781
|
// src/tools/banditState.ts
|
|
14489
|
-
import { z as
|
|
15782
|
+
import { z as z38 } from "zod";
|
|
14490
15783
|
init_supabase();
|
|
14491
15784
|
function asEnvelope28(data) {
|
|
14492
15785
|
return {
|
|
@@ -14499,8 +15792,8 @@ function registerBanditStateTools(server) {
|
|
|
14499
15792
|
"get_bandit_state",
|
|
14500
15793
|
"Read the current content learning state for a project. Returns top-K arms per (arm_type, platform) with expected performance and uncertainty. Use this to reason about which hook family / format / timing slot currently performs best on each platform before recommending next moves. Arm types: hook_family, length_bucket (xs/s/m/l/xl by platform), posting_time_bucket (morning/midday/evening/late), content_format (video/carousel/image/caption/text/avatar/storyboard).",
|
|
14501
15794
|
{
|
|
14502
|
-
project_id:
|
|
14503
|
-
platform:
|
|
15795
|
+
project_id: z38.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
|
|
15796
|
+
platform: z38.enum([
|
|
14504
15797
|
"instagram",
|
|
14505
15798
|
"tiktok",
|
|
14506
15799
|
"youtube",
|
|
@@ -14512,7 +15805,7 @@ function registerBanditStateTools(server) {
|
|
|
14512
15805
|
]).optional().describe(
|
|
14513
15806
|
"Lowercase platform name. Omit to return both platform-scoped and legacy platform-agnostic arms."
|
|
14514
15807
|
),
|
|
14515
|
-
arm_type:
|
|
15808
|
+
arm_type: z38.enum([
|
|
14516
15809
|
"hook_family",
|
|
14517
15810
|
"hook_type",
|
|
14518
15811
|
"format",
|
|
@@ -14526,8 +15819,8 @@ function registerBanditStateTools(server) {
|
|
|
14526
15819
|
"posting_time_bucket",
|
|
14527
15820
|
"content_format"
|
|
14528
15821
|
]).optional().describe("Arm dimension. Omit to return all types grouped."),
|
|
14529
|
-
top_k:
|
|
14530
|
-
response_format:
|
|
15822
|
+
top_k: z38.number().min(1).max(50).optional().describe("Max arms per (arm_type) group. Default 5."),
|
|
15823
|
+
response_format: z38.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
|
|
14531
15824
|
},
|
|
14532
15825
|
async ({ project_id, platform: platform2, arm_type, top_k, response_format }) => {
|
|
14533
15826
|
const format = response_format ?? "text";
|
|
@@ -14582,12 +15875,128 @@ function registerBanditStateTools(server) {
|
|
|
14582
15875
|
);
|
|
14583
15876
|
}
|
|
14584
15877
|
|
|
15878
|
+
// src/tools/lifecycle.ts
|
|
15879
|
+
import { z as z39 } from "zod";
|
|
15880
|
+
init_supabase();
|
|
15881
|
+
function envelope(data) {
|
|
15882
|
+
return {
|
|
15883
|
+
_meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
|
|
15884
|
+
data
|
|
15885
|
+
};
|
|
15886
|
+
}
|
|
15887
|
+
function lifecycleFailure(error) {
|
|
15888
|
+
if (/not_found|not found/i.test(error)) {
|
|
15889
|
+
return toolError("not_found", "The requested object was not found in this project.");
|
|
15890
|
+
}
|
|
15891
|
+
if (/not_cancellable|publishing_in_progress|schedule_conflict|post_in_progress/i.test(error)) {
|
|
15892
|
+
return toolError("validation_error", "The object can no longer be cancelled in its current state.", {
|
|
15893
|
+
recover_with: ["Refresh its status before deciding the next action."]
|
|
15894
|
+
});
|
|
15895
|
+
}
|
|
15896
|
+
return toolError("upstream_error", "The lifecycle operation could not be completed. Please retry.");
|
|
15897
|
+
}
|
|
15898
|
+
async function projectContext(projectId) {
|
|
15899
|
+
return projectId ?? await getDefaultProjectId();
|
|
15900
|
+
}
|
|
15901
|
+
async function invokeLifecycle(action, projectId, identifiers) {
|
|
15902
|
+
const resolvedProjectId = await projectContext(projectId);
|
|
15903
|
+
if (!resolvedProjectId) {
|
|
15904
|
+
return toolError("validation_error", "A project_id is required because no default project is configured.");
|
|
15905
|
+
}
|
|
15906
|
+
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
15907
|
+
action,
|
|
15908
|
+
projectId: resolvedProjectId,
|
|
15909
|
+
project_id: resolvedProjectId,
|
|
15910
|
+
...identifiers
|
|
15911
|
+
});
|
|
15912
|
+
if (error) return lifecycleFailure(error);
|
|
15913
|
+
if (!data?.success) return toolError("upstream_error", "The lifecycle operation returned no result.");
|
|
15914
|
+
const result = envelope(data);
|
|
15915
|
+
return {
|
|
15916
|
+
structuredContent: result,
|
|
15917
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
15918
|
+
};
|
|
15919
|
+
}
|
|
15920
|
+
var PROJECT_ID = z39.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default.");
|
|
15921
|
+
var CONFIRM = z39.literal(true).describe("Must be true only after the user explicitly confirms this destructive action.");
|
|
15922
|
+
function registerLifecycleTools(server) {
|
|
15923
|
+
server.tool(
|
|
15924
|
+
"cancel_async_job",
|
|
15925
|
+
"Cancel an owned pending async generation/render job before a worker starts it. If credits were already debited, the backend attempts an idempotent refund and reports the result. Processing or terminal jobs are not cancellable.",
|
|
15926
|
+
{
|
|
15927
|
+
job_id: z39.string().uuid().describe("Owned async_jobs ID returned by a generation tool."),
|
|
15928
|
+
project_id: PROJECT_ID,
|
|
15929
|
+
confirm: CONFIRM
|
|
15930
|
+
},
|
|
15931
|
+
async ({ job_id, project_id }) => invokeLifecycle("cancel-async-job", project_id, { job_id })
|
|
15932
|
+
);
|
|
15933
|
+
server.tool(
|
|
15934
|
+
"cancel_scheduled_post",
|
|
15935
|
+
"Cancel an owned draft, pending, or scheduled post before publishing starts. This closes its pending schedule job first; it refuses once a worker has claimed the publication.",
|
|
15936
|
+
{
|
|
15937
|
+
post_id: z39.string().uuid().describe("Owned scheduled post ID."),
|
|
15938
|
+
project_id: PROJECT_ID,
|
|
15939
|
+
confirm: CONFIRM
|
|
15940
|
+
},
|
|
15941
|
+
async ({ post_id, project_id }) => invokeLifecycle("cancel-scheduled-post", project_id, { post_id })
|
|
15942
|
+
);
|
|
15943
|
+
server.tool(
|
|
15944
|
+
"delete_carousel",
|
|
15945
|
+
"Delete an owned carousel content-history record from one project. Stored media is retained until the normal retention cleanup; this does not delete already-published platform posts.",
|
|
15946
|
+
{
|
|
15947
|
+
content_id: z39.string().uuid().describe("Owned carousel content_history ID."),
|
|
15948
|
+
project_id: PROJECT_ID,
|
|
15949
|
+
confirm: CONFIRM
|
|
15950
|
+
},
|
|
15951
|
+
async ({ content_id, project_id }) => invokeLifecycle("delete-carousel", project_id, { content_id })
|
|
15952
|
+
);
|
|
15953
|
+
server.tool(
|
|
15954
|
+
"delete_content_plan",
|
|
15955
|
+
"Permanently delete an owned content plan in one project. This does not cancel posts that were already scheduled from the plan.",
|
|
15956
|
+
{
|
|
15957
|
+
plan_id: z39.string().uuid().describe("Owned content plan ID."),
|
|
15958
|
+
project_id: PROJECT_ID,
|
|
15959
|
+
confirm: CONFIRM
|
|
15960
|
+
},
|
|
15961
|
+
async ({ plan_id, project_id }) => invokeLifecycle("delete-content-plan", project_id, { plan_id })
|
|
15962
|
+
);
|
|
15963
|
+
server.tool(
|
|
15964
|
+
"delete_autopilot_config",
|
|
15965
|
+
"Permanently delete an owned autopilot configuration in one project. Historical runs and already-published posts are retained.",
|
|
15966
|
+
{
|
|
15967
|
+
config_id: z39.string().uuid().describe("Owned autopilot configuration ID."),
|
|
15968
|
+
project_id: PROJECT_ID,
|
|
15969
|
+
confirm: CONFIRM
|
|
15970
|
+
},
|
|
15971
|
+
async ({ config_id, project_id }) => invokeLifecycle("delete-autopilot-config", project_id, { config_id })
|
|
15972
|
+
);
|
|
15973
|
+
}
|
|
15974
|
+
|
|
14585
15975
|
// src/lib/register-tools.ts
|
|
15976
|
+
var BASE64_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["file_data", "fileData"]);
|
|
15977
|
+
var DATA_URI_PREFIX2 = /^data:[\w.+-]+\/[\w.+-]+;base64,/;
|
|
15978
|
+
var STRICT_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
15979
|
+
function redactBase64Payloads(args) {
|
|
15980
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
|
|
15981
|
+
let changed = false;
|
|
15982
|
+
const out = { ...args };
|
|
15983
|
+
for (const key of BASE64_PAYLOAD_KEYS) {
|
|
15984
|
+
const value = out[key];
|
|
15985
|
+
if (typeof value !== "string" || value.length < 64) continue;
|
|
15986
|
+
const body = value.replace(DATA_URI_PREFIX2, "");
|
|
15987
|
+
if (STRICT_BASE64.test(body)) {
|
|
15988
|
+
out[key] = `[base64:${body.length} chars omitted from prose scan]`;
|
|
15989
|
+
changed = true;
|
|
15990
|
+
}
|
|
15991
|
+
}
|
|
15992
|
+
return changed ? out : args;
|
|
15993
|
+
}
|
|
14586
15994
|
function wrapToolWithScanner(toolName, handler) {
|
|
14587
15995
|
return async function scannerWrappedHandler(...handlerArgs) {
|
|
14588
15996
|
const args = handlerArgs[0];
|
|
14589
15997
|
const ctx = handlerArgs[1];
|
|
14590
|
-
const
|
|
15998
|
+
const scanArgs = redactBase64Payloads(args);
|
|
15999
|
+
const inputText = scanArgs === void 0 ? "{}" : typeof scanArgs === "string" ? scanArgs : JSON.stringify(scanArgs);
|
|
14591
16000
|
const inputScan = scan(inputText, {
|
|
14592
16001
|
mode: "block",
|
|
14593
16002
|
source: "mcp_tool_input",
|
|
@@ -14615,6 +16024,16 @@ function wrapToolWithScanner(toolName, handler) {
|
|
|
14615
16024
|
source: "mcp_tool_output",
|
|
14616
16025
|
user_id: ctx?.userId
|
|
14617
16026
|
});
|
|
16027
|
+
if (!outputScan.passed) {
|
|
16028
|
+
try {
|
|
16029
|
+
ctx?.logScan?.(toolName, "output", outputScan);
|
|
16030
|
+
} catch {
|
|
16031
|
+
}
|
|
16032
|
+
return toolError(
|
|
16033
|
+
"server_error",
|
|
16034
|
+
"The response exceeded the safe output limit and was not returned."
|
|
16035
|
+
);
|
|
16036
|
+
}
|
|
14618
16037
|
if (outputScan.sanitized_text !== void 0) {
|
|
14619
16038
|
try {
|
|
14620
16039
|
ctx?.logScan?.(toolName, "output", outputScan);
|
|
@@ -14623,7 +16042,10 @@ function wrapToolWithScanner(toolName, handler) {
|
|
|
14623
16042
|
try {
|
|
14624
16043
|
return JSON.parse(outputScan.sanitized_text);
|
|
14625
16044
|
} catch {
|
|
14626
|
-
return
|
|
16045
|
+
return toolError(
|
|
16046
|
+
"server_error",
|
|
16047
|
+
"The response could not be returned safely. Please retry or contact support."
|
|
16048
|
+
);
|
|
14627
16049
|
}
|
|
14628
16050
|
}
|
|
14629
16051
|
return result;
|
|
@@ -14673,8 +16095,7 @@ function applyScopeEnforcement(server, scopeResolver) {
|
|
|
14673
16095
|
details: {
|
|
14674
16096
|
source: "wrapper",
|
|
14675
16097
|
// A thrown exception escaped the handler — an unclassified fault.
|
|
14676
|
-
error_type: "server_error"
|
|
14677
|
-
exception: err instanceof Error ? err.message.slice(0, 200) : "unknown"
|
|
16098
|
+
error_type: "server_error"
|
|
14678
16099
|
}
|
|
14679
16100
|
});
|
|
14680
16101
|
throw err;
|
|
@@ -14807,24 +16228,26 @@ function registerAllTools(server, options) {
|
|
|
14807
16228
|
registerSkillsTools(server);
|
|
14808
16229
|
registerLoopPulseTools(server);
|
|
14809
16230
|
registerBanditStateTools(server);
|
|
16231
|
+
registerLifecycleTools(server);
|
|
14810
16232
|
if (!options?.skipApps) {
|
|
14811
16233
|
registerContentCalendarApp(server);
|
|
16234
|
+
registerAnalyticsPulseApp(server);
|
|
14812
16235
|
}
|
|
14813
16236
|
applyAnnotations(server);
|
|
14814
16237
|
}
|
|
14815
16238
|
|
|
14816
16239
|
// src/prompts.ts
|
|
14817
|
-
import { z as
|
|
16240
|
+
import { z as z40 } from "zod";
|
|
14818
16241
|
function registerPrompts(server) {
|
|
14819
16242
|
server.prompt(
|
|
14820
16243
|
"create_weekly_content_plan",
|
|
14821
16244
|
"Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
|
|
14822
16245
|
{
|
|
14823
|
-
niche:
|
|
14824
|
-
platforms:
|
|
16246
|
+
niche: z40.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
|
|
16247
|
+
platforms: z40.string().optional().describe(
|
|
14825
16248
|
'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
|
|
14826
16249
|
),
|
|
14827
|
-
tone:
|
|
16250
|
+
tone: z40.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
|
|
14828
16251
|
},
|
|
14829
16252
|
({ niche, platforms, tone }) => {
|
|
14830
16253
|
const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
|
|
@@ -14866,8 +16289,8 @@ After building the plan, use \`save_content_plan\` to save it.`
|
|
|
14866
16289
|
"analyze_top_content",
|
|
14867
16290
|
"Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
|
|
14868
16291
|
{
|
|
14869
|
-
timeframe:
|
|
14870
|
-
platform:
|
|
16292
|
+
timeframe: z40.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
|
|
16293
|
+
platform: z40.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
|
|
14871
16294
|
},
|
|
14872
16295
|
({ timeframe, platform: platform2 }) => {
|
|
14873
16296
|
const period = timeframe || "30 days";
|
|
@@ -14904,10 +16327,10 @@ Format as a clear, actionable performance report.`
|
|
|
14904
16327
|
"repurpose_content",
|
|
14905
16328
|
"Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
|
|
14906
16329
|
{
|
|
14907
|
-
source:
|
|
16330
|
+
source: z40.string().describe(
|
|
14908
16331
|
"The source content to repurpose \u2014 a URL, transcript, or the content text itself"
|
|
14909
16332
|
),
|
|
14910
|
-
target_platforms:
|
|
16333
|
+
target_platforms: z40.string().optional().describe(
|
|
14911
16334
|
'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
|
|
14912
16335
|
)
|
|
14913
16336
|
},
|
|
@@ -14949,9 +16372,9 @@ For each piece, include the platform, format, character count, and suggested pos
|
|
|
14949
16372
|
"setup_brand_voice",
|
|
14950
16373
|
"Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
|
|
14951
16374
|
{
|
|
14952
|
-
brand_name:
|
|
14953
|
-
industry:
|
|
14954
|
-
website:
|
|
16375
|
+
brand_name: z40.string().describe("Your brand or business name"),
|
|
16376
|
+
industry: z40.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
|
|
16377
|
+
website: z40.string().optional().describe("Your website URL for context")
|
|
14955
16378
|
},
|
|
14956
16379
|
({ brand_name, industry, website }) => {
|
|
14957
16380
|
const industryContext = industry ? ` in the ${industry} space` : "";
|
|
@@ -15314,11 +16737,7 @@ init_request_context();
|
|
|
15314
16737
|
|
|
15315
16738
|
// src/lib/discovery-catalog.ts
|
|
15316
16739
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15317
|
-
var PUBLIC_SCHEMA_OMIT_PROPERTIES = {
|
|
15318
|
-
// Internal lineage fields are accepted by the runtime for Social Neuron's own
|
|
15319
|
-
// automation, but should not be advertised in public MCP/OpenAPI discovery.
|
|
15320
|
-
schedule_post: ["origin", "hermes_run_id"]
|
|
15321
|
-
};
|
|
16740
|
+
var PUBLIC_SCHEMA_OMIT_PROPERTIES = {};
|
|
15322
16741
|
var cached = /* @__PURE__ */ new Map();
|
|
15323
16742
|
function buildDiscoveryCatalog(profile = "full") {
|
|
15324
16743
|
const existing = cached.get(profile);
|
|
@@ -15445,7 +16864,9 @@ function buildOpenApiDocument() {
|
|
|
15445
16864
|
async function computeOpenApiDocument() {
|
|
15446
16865
|
const discovery = await buildDiscoveryCatalog();
|
|
15447
16866
|
const schemaByName = new Map(discovery.map((t) => [t.name, t.inputSchema]));
|
|
15448
|
-
const publicTools = TOOL_CATALOG.filter(
|
|
16867
|
+
const publicTools = TOOL_CATALOG.filter(
|
|
16868
|
+
(t) => !t.localOnly && !t.internal && !t.hiddenFromPublicCount
|
|
16869
|
+
);
|
|
15449
16870
|
const paths = {};
|
|
15450
16871
|
for (const tool of publicTools) {
|
|
15451
16872
|
const scope = TOOL_SCOPES[tool.name];
|
|
@@ -15528,7 +16949,11 @@ function getCallHandler() {
|
|
|
15528
16949
|
return cachedCallHandler;
|
|
15529
16950
|
}
|
|
15530
16951
|
function restToolNames() {
|
|
15531
|
-
return new Set(
|
|
16952
|
+
return new Set(
|
|
16953
|
+
TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal && !t.hiddenFromPublicCount).map(
|
|
16954
|
+
(t) => t.name
|
|
16955
|
+
)
|
|
16956
|
+
);
|
|
15532
16957
|
}
|
|
15533
16958
|
async function invokeToolRest(name, args) {
|
|
15534
16959
|
const handler = getCallHandler();
|
|
@@ -15603,7 +17028,7 @@ function httpStatusForResult(result) {
|
|
|
15603
17028
|
}
|
|
15604
17029
|
|
|
15605
17030
|
// src/lib/token-verifier.ts
|
|
15606
|
-
import { createHash as
|
|
17031
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
15607
17032
|
import * as jose from "jose";
|
|
15608
17033
|
var jwks = null;
|
|
15609
17034
|
function getJWKS(supabaseUrl) {
|
|
@@ -15614,10 +17039,21 @@ function getJWKS(supabaseUrl) {
|
|
|
15614
17039
|
return jwks;
|
|
15615
17040
|
}
|
|
15616
17041
|
var tokenValidationCache = /* @__PURE__ */ new Map();
|
|
15617
|
-
var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS =
|
|
17042
|
+
var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS = 6e4;
|
|
15618
17043
|
var DEFAULT_MCP_RESOURCE = "https://mcp.socialneuron.com";
|
|
17044
|
+
var VALID_MCP_SCOPES = new Set(getAllScopes());
|
|
17045
|
+
function validScopes(value) {
|
|
17046
|
+
if (!Array.isArray(value)) return [];
|
|
17047
|
+
return Array.from(
|
|
17048
|
+
new Set(
|
|
17049
|
+
value.filter(
|
|
17050
|
+
(scope) => typeof scope === "string" && VALID_MCP_SCOPES.has(scope)
|
|
17051
|
+
)
|
|
17052
|
+
)
|
|
17053
|
+
);
|
|
17054
|
+
}
|
|
15619
17055
|
function cacheKey(token) {
|
|
15620
|
-
return
|
|
17056
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
15621
17057
|
}
|
|
15622
17058
|
function evictFromCache(token) {
|
|
15623
17059
|
tokenValidationCache.delete(cacheKey(token));
|
|
@@ -15652,6 +17088,7 @@ async function verifyCachedOpaqueToken(token, validate, ttlMs) {
|
|
|
15652
17088
|
}
|
|
15653
17089
|
function createTokenVerifier(options) {
|
|
15654
17090
|
const { supabaseUrl, supabaseAnonKey } = options;
|
|
17091
|
+
const allowSupabaseSessionTokens = options.allowSupabaseSessionTokens ?? process.env.MCP_ALLOW_SUPABASE_SESSION_TOKENS === "true";
|
|
15655
17092
|
const expectedResource = normalizeResource(
|
|
15656
17093
|
options.resource ?? process.env.MCP_RESOURCE_URL ?? process.env.MCP_SERVER_URL
|
|
15657
17094
|
) ?? DEFAULT_MCP_RESOURCE;
|
|
@@ -15667,7 +17104,10 @@ function createTokenVerifier(options) {
|
|
|
15667
17104
|
CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS
|
|
15668
17105
|
);
|
|
15669
17106
|
}
|
|
15670
|
-
|
|
17107
|
+
if (!allowSupabaseSessionTokens) {
|
|
17108
|
+
throw new Error("Unsupported access token");
|
|
17109
|
+
}
|
|
17110
|
+
return verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey);
|
|
15671
17111
|
}
|
|
15672
17112
|
};
|
|
15673
17113
|
}
|
|
@@ -15675,7 +17115,10 @@ function normalizeResource(value) {
|
|
|
15675
17115
|
if (!value) return void 0;
|
|
15676
17116
|
try {
|
|
15677
17117
|
const parsed = new URL(value);
|
|
15678
|
-
|
|
17118
|
+
parsed.hash = "";
|
|
17119
|
+
parsed.search = "";
|
|
17120
|
+
if (parsed.pathname !== "/") parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
17121
|
+
return parsed.toString().replace(/\/$/, "");
|
|
15679
17122
|
} catch {
|
|
15680
17123
|
return value.replace(/\/$/, "");
|
|
15681
17124
|
}
|
|
@@ -15689,7 +17132,7 @@ function audienceIncludesExpected(audience, expectedResource) {
|
|
|
15689
17132
|
}
|
|
15690
17133
|
return false;
|
|
15691
17134
|
}
|
|
15692
|
-
async function verifySupabaseJwt(token, supabaseUrl) {
|
|
17135
|
+
async function verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey) {
|
|
15693
17136
|
const jwksKeySet = getJWKS(supabaseUrl);
|
|
15694
17137
|
const { payload } = await jose.jwtVerify(token, jwksKeySet, {
|
|
15695
17138
|
issuer: `${supabaseUrl}/auth/v1`,
|
|
@@ -15701,14 +17144,41 @@ async function verifySupabaseJwt(token, supabaseUrl) {
|
|
|
15701
17144
|
if (!userId) {
|
|
15702
17145
|
throw new Error("JWT missing sub claim");
|
|
15703
17146
|
}
|
|
15704
|
-
const
|
|
15705
|
-
const
|
|
17147
|
+
const controller = new AbortController();
|
|
17148
|
+
const timer = setTimeout(() => controller.abort(), 1e4);
|
|
17149
|
+
let entitlements;
|
|
17150
|
+
try {
|
|
17151
|
+
const response = await fetch(
|
|
17152
|
+
`${supabaseUrl}/functions/v1/mcp-auth?action=validate-user-token`,
|
|
17153
|
+
{
|
|
17154
|
+
method: "POST",
|
|
17155
|
+
headers: {
|
|
17156
|
+
Authorization: `Bearer ${supabaseAnonKey}`,
|
|
17157
|
+
"Content-Type": "application/json"
|
|
17158
|
+
},
|
|
17159
|
+
body: JSON.stringify({ access_token: token }),
|
|
17160
|
+
signal: controller.signal
|
|
17161
|
+
}
|
|
17162
|
+
);
|
|
17163
|
+
if (!response.ok) throw new Error("JWT entitlement validation unavailable");
|
|
17164
|
+
entitlements = await response.json();
|
|
17165
|
+
} catch (error) {
|
|
17166
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
17167
|
+
throw new Error("JWT entitlement validation timed out");
|
|
17168
|
+
}
|
|
17169
|
+
throw error;
|
|
17170
|
+
} finally {
|
|
17171
|
+
clearTimeout(timer);
|
|
17172
|
+
}
|
|
17173
|
+
if (!entitlements.valid || entitlements.userId !== userId) {
|
|
17174
|
+
throw new Error("JWT is not authorized for MCP");
|
|
17175
|
+
}
|
|
15706
17176
|
return {
|
|
15707
17177
|
token,
|
|
15708
17178
|
clientId: payload.client_id ?? "supabase-oauth",
|
|
15709
|
-
scopes,
|
|
17179
|
+
scopes: validScopes(entitlements.scopes),
|
|
15710
17180
|
expiresAt: payload.exp,
|
|
15711
|
-
extra: { userId }
|
|
17181
|
+
extra: { userId, tier: entitlements.tier }
|
|
15712
17182
|
};
|
|
15713
17183
|
}
|
|
15714
17184
|
async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
|
|
@@ -15740,12 +17210,12 @@ async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
|
|
|
15740
17210
|
throw new Error("API key expired");
|
|
15741
17211
|
}
|
|
15742
17212
|
const projectId = data.projectId ?? data.project_id ?? null;
|
|
15743
|
-
const extra = { userId: data.userId
|
|
17213
|
+
const extra = { userId: data.userId };
|
|
15744
17214
|
if (projectId) extra.projectId = projectId;
|
|
15745
17215
|
return {
|
|
15746
17216
|
token: apiKey,
|
|
15747
17217
|
clientId: "api-key",
|
|
15748
|
-
scopes: data.scopes
|
|
17218
|
+
scopes: validScopes(data.scopes),
|
|
15749
17219
|
expiresAt,
|
|
15750
17220
|
extra
|
|
15751
17221
|
};
|
|
@@ -15791,9 +17261,9 @@ async function verifyConnectorToken(accessToken, supabaseUrl, supabaseAnonKey, e
|
|
|
15791
17261
|
return {
|
|
15792
17262
|
token: accessToken,
|
|
15793
17263
|
clientId: data.clientId ?? "connector-oauth",
|
|
15794
|
-
scopes: data.scopes
|
|
17264
|
+
scopes: validScopes(data.scopes),
|
|
15795
17265
|
expiresAt,
|
|
15796
|
-
extra: { userId: data.userId,
|
|
17266
|
+
extra: { userId: data.userId, resource: expectedResource }
|
|
15797
17267
|
};
|
|
15798
17268
|
} catch (err) {
|
|
15799
17269
|
if (err instanceof Error && err.name === "AbortError") {
|
|
@@ -15940,7 +17410,7 @@ function createClientsStore() {
|
|
|
15940
17410
|
function markUnavailable(reason) {
|
|
15941
17411
|
if (supabaseAvailable) {
|
|
15942
17412
|
console.error(
|
|
15943
|
-
`[oauth] persistent client store unavailable: ${reason}
|
|
17413
|
+
`[oauth] persistent client store unavailable: ${sanitizeError(reason)} Falling back to in-memory only for this process. Run the mcp_oauth_clients migration to enable persistence.`
|
|
15944
17414
|
);
|
|
15945
17415
|
supabaseAvailable = false;
|
|
15946
17416
|
}
|
|
@@ -15969,7 +17439,7 @@ function createClientsStore() {
|
|
|
15969
17439
|
const { error: deleteError } = await supabase.from("mcp_oauth_clients").delete().eq("client_id", clientId);
|
|
15970
17440
|
if (deleteError) {
|
|
15971
17441
|
console.error(
|
|
15972
|
-
`[oauth] failed to remove client with disallowed redirect URI: ${deleteError
|
|
17442
|
+
`[oauth] failed to remove client with disallowed redirect URI: ${sanitizeError(deleteError)}`
|
|
15973
17443
|
);
|
|
15974
17444
|
}
|
|
15975
17445
|
return void 0;
|
|
@@ -16030,7 +17500,11 @@ function createOAuthProvider(options) {
|
|
|
16030
17500
|
const { supabaseUrl, supabaseAnonKey } = options;
|
|
16031
17501
|
const appBaseUrl = options.appBaseUrl ?? "https://www.socialneuron.com";
|
|
16032
17502
|
const clientsStore = createClientsStore();
|
|
16033
|
-
const tokenVerifier2 = createTokenVerifier({
|
|
17503
|
+
const tokenVerifier2 = createTokenVerifier({
|
|
17504
|
+
supabaseUrl,
|
|
17505
|
+
supabaseAnonKey,
|
|
17506
|
+
resource: options.resource
|
|
17507
|
+
});
|
|
16034
17508
|
return {
|
|
16035
17509
|
get clientsStore() {
|
|
16036
17510
|
return clientsStore;
|
|
@@ -16103,12 +17577,11 @@ function createOAuthProvider(options) {
|
|
|
16103
17577
|
}
|
|
16104
17578
|
clearTimeout(timer);
|
|
16105
17579
|
if (!response.ok) {
|
|
16106
|
-
|
|
16107
|
-
throw new Error(err.error ?? `HTTP ${response.status}`);
|
|
17580
|
+
throw new Error(`Authorization code exchange failed (HTTP ${response.status})`);
|
|
16108
17581
|
}
|
|
16109
17582
|
const data = await response.json();
|
|
16110
17583
|
if (!data.access_token) {
|
|
16111
|
-
throw new Error(
|
|
17584
|
+
throw new Error("No access token returned from exchange");
|
|
16112
17585
|
}
|
|
16113
17586
|
return {
|
|
16114
17587
|
access_token: data.access_token,
|
|
@@ -16140,12 +17613,11 @@ function createOAuthProvider(options) {
|
|
|
16140
17613
|
}
|
|
16141
17614
|
);
|
|
16142
17615
|
if (!response.ok) {
|
|
16143
|
-
|
|
16144
|
-
throw new Error(err.error ?? `HTTP ${response.status}`);
|
|
17616
|
+
throw new Error(`Refresh token exchange failed (HTTP ${response.status})`);
|
|
16145
17617
|
}
|
|
16146
17618
|
const data = await response.json();
|
|
16147
17619
|
if (!data.access_token) {
|
|
16148
|
-
throw new Error(
|
|
17620
|
+
throw new Error("No access token returned from refresh");
|
|
16149
17621
|
}
|
|
16150
17622
|
return {
|
|
16151
17623
|
access_token: data.access_token,
|
|
@@ -16189,8 +17661,7 @@ function createOAuthProvider(options) {
|
|
|
16189
17661
|
throw new Error(`Token revocation failed: HTTP ${response.status}`);
|
|
16190
17662
|
}
|
|
16191
17663
|
} catch (err) {
|
|
16192
|
-
|
|
16193
|
-
console.error(`[oauth] Token revocation call failed: ${msg}`);
|
|
17664
|
+
console.error(`[oauth] Token revocation call failed: ${sanitizeError(err)}`);
|
|
16194
17665
|
} finally {
|
|
16195
17666
|
clearTimeout(timer);
|
|
16196
17667
|
}
|
|
@@ -16202,6 +17673,10 @@ function createOAuthProvider(options) {
|
|
|
16202
17673
|
init_posthog();
|
|
16203
17674
|
|
|
16204
17675
|
// src/lib/protected-resource-metadata.ts
|
|
17676
|
+
var PROTECTED_RESOURCE_METADATA_PATHS = [
|
|
17677
|
+
"/.well-known/oauth-protected-resource",
|
|
17678
|
+
"/.well-known/oauth-protected-resource/mcp"
|
|
17679
|
+
];
|
|
16205
17680
|
function buildProtectedResourceMetadata(options) {
|
|
16206
17681
|
return {
|
|
16207
17682
|
resource: options.resourceUrl,
|
|
@@ -16249,18 +17724,16 @@ function parseAllowedOrigins(raw) {
|
|
|
16249
17724
|
}
|
|
16250
17725
|
function buildOriginPolicy(input) {
|
|
16251
17726
|
const envOrigins = parseAllowedOrigins(input.allowedOriginsEnv);
|
|
16252
|
-
|
|
16253
|
-
|
|
16254
|
-
}
|
|
16255
|
-
const fallback = new Set(PRODUCTION_FALLBACK_ORIGINS);
|
|
17727
|
+
const source = envOrigins.size > 0 ? "env" : "fallback";
|
|
17728
|
+
const allowedOrigins = source === "env" ? envOrigins : new Set(PRODUCTION_FALLBACK_ORIGINS);
|
|
16256
17729
|
for (const configuredUrl of input.configuredUrls ?? []) {
|
|
16257
17730
|
const normalized = normalizeOrigin(configuredUrl);
|
|
16258
|
-
if (normalized)
|
|
17731
|
+
if (normalized) allowedOrigins.add(normalized);
|
|
16259
17732
|
}
|
|
16260
|
-
if (input.nodeEnv !== "production") {
|
|
16261
|
-
for (const origin of DEVELOPMENT_FALLBACK_ORIGINS)
|
|
17733
|
+
if (source === "fallback" && input.nodeEnv !== "production") {
|
|
17734
|
+
for (const origin of DEVELOPMENT_FALLBACK_ORIGINS) allowedOrigins.add(origin);
|
|
16262
17735
|
}
|
|
16263
|
-
return { allowedOrigins
|
|
17736
|
+
return { allowedOrigins, source };
|
|
16264
17737
|
}
|
|
16265
17738
|
function validateBrowserOrigin(originHeader, policy) {
|
|
16266
17739
|
if (originHeader === void 0) return { allowed: true, origin: null };
|
|
@@ -16272,6 +17745,20 @@ function validateBrowserOrigin(originHeader, policy) {
|
|
|
16272
17745
|
return { allowed: true, origin: normalized };
|
|
16273
17746
|
}
|
|
16274
17747
|
|
|
17748
|
+
// src/lib/session-lru.ts
|
|
17749
|
+
function findOldestIdleSessionId(sessions2, userId) {
|
|
17750
|
+
let candidateId = null;
|
|
17751
|
+
let candidateActivity = Number.POSITIVE_INFINITY;
|
|
17752
|
+
for (const [sessionId, entry] of sessions2) {
|
|
17753
|
+
if (userId !== void 0 && entry.userId !== userId) continue;
|
|
17754
|
+
if (entry.activeRequests > 0) continue;
|
|
17755
|
+
if (entry.lastActivity >= candidateActivity) continue;
|
|
17756
|
+
candidateId = sessionId;
|
|
17757
|
+
candidateActivity = entry.lastActivity;
|
|
17758
|
+
}
|
|
17759
|
+
return candidateId;
|
|
17760
|
+
}
|
|
17761
|
+
|
|
16275
17762
|
// src/http.ts
|
|
16276
17763
|
process.env.MCP_TRANSPORT = "http";
|
|
16277
17764
|
var PORT = parseInt(process.env.PORT ?? "8080", 10);
|
|
@@ -16281,9 +17768,19 @@ var MCP_SERVER_URL = process.env.MCP_SERVER_URL ?? `http://localhost:${PORT}/mcp
|
|
|
16281
17768
|
var APP_BASE_URL = process.env.APP_BASE_URL ?? "https://www.socialneuron.com";
|
|
16282
17769
|
var NODE_ENV = process.env.NODE_ENV ?? "development";
|
|
16283
17770
|
var TOOL_PROFILE = resolveToolProfile(process.env.MCP_TOOL_PROFILE);
|
|
17771
|
+
var TRUSTED_BROWSER_MCP_CLIENTS = [
|
|
17772
|
+
"https://claude.ai",
|
|
17773
|
+
"https://claude.com",
|
|
17774
|
+
"https://chatgpt.com",
|
|
17775
|
+
"https://chat.openai.com"
|
|
17776
|
+
];
|
|
16284
17777
|
var ORIGIN_POLICY = buildOriginPolicy({
|
|
16285
17778
|
allowedOriginsEnv: process.env.ALLOWED_ORIGINS,
|
|
16286
|
-
configuredUrls: [
|
|
17779
|
+
configuredUrls: [
|
|
17780
|
+
APP_BASE_URL,
|
|
17781
|
+
MCP_SERVER_URL,
|
|
17782
|
+
...TRUSTED_BROWSER_MCP_CLIENTS
|
|
17783
|
+
],
|
|
16287
17784
|
nodeEnv: NODE_ENV
|
|
16288
17785
|
});
|
|
16289
17786
|
function deriveOAuthIssuerUrl() {
|
|
@@ -16308,6 +17805,18 @@ function deriveOAuthIssuerUrl() {
|
|
|
16308
17805
|
return "https://mcp.socialneuron.com";
|
|
16309
17806
|
}
|
|
16310
17807
|
var OAUTH_ISSUER_URL = deriveOAuthIssuerUrl();
|
|
17808
|
+
function logOperationalError(label, error) {
|
|
17809
|
+
const category = error === void 0 ? "An internal error occurred." : sanitizeError(error);
|
|
17810
|
+
console.error(`[MCP HTTP] ${label}: ${category}`);
|
|
17811
|
+
}
|
|
17812
|
+
function safeEndpointForLog(value) {
|
|
17813
|
+
try {
|
|
17814
|
+
const url = new URL(value);
|
|
17815
|
+
return `${url.origin}${url.pathname}`;
|
|
17816
|
+
} catch {
|
|
17817
|
+
return "[invalid endpoint configuration]";
|
|
17818
|
+
}
|
|
17819
|
+
}
|
|
16311
17820
|
if (!SUPABASE_URL2 || !SUPABASE_ANON_KEY) {
|
|
16312
17821
|
console.error("[MCP HTTP] Missing SUPABASE_URL or SUPABASE_ANON_KEY");
|
|
16313
17822
|
process.exit(1);
|
|
@@ -16319,17 +17828,19 @@ if (SUPABASE_SERVICE_ROLE_KEY && SUPABASE_SERVICE_ROLE_KEY.length < 100) {
|
|
|
16319
17828
|
);
|
|
16320
17829
|
}
|
|
16321
17830
|
process.on("uncaughtException", (err) => {
|
|
16322
|
-
|
|
17831
|
+
logOperationalError("Uncaught exception", err);
|
|
16323
17832
|
process.exit(1);
|
|
16324
17833
|
});
|
|
16325
17834
|
process.on("unhandledRejection", (reason) => {
|
|
16326
|
-
|
|
16327
|
-
console.error(`[MCP HTTP] Unhandled rejection: ${message}`);
|
|
17835
|
+
logOperationalError("Unhandled rejection", reason);
|
|
16328
17836
|
process.exit(1);
|
|
16329
17837
|
});
|
|
16330
17838
|
var tokenVerifier = createTokenVerifier({
|
|
16331
17839
|
supabaseUrl: SUPABASE_URL2,
|
|
16332
|
-
supabaseAnonKey: SUPABASE_ANON_KEY
|
|
17840
|
+
supabaseAnonKey: SUPABASE_ANON_KEY,
|
|
17841
|
+
// Match RFC 9728 metadata exactly. Opaque connector tokens minted for the
|
|
17842
|
+
// issuer origin must not be replayable at the more-specific /mcp resource.
|
|
17843
|
+
resource: MCP_SERVER_URL
|
|
16333
17844
|
});
|
|
16334
17845
|
initPostHog();
|
|
16335
17846
|
var MAX_SESSIONS = 500;
|
|
@@ -16343,15 +17854,46 @@ function countUserSessions(userId) {
|
|
|
16343
17854
|
}
|
|
16344
17855
|
return count;
|
|
16345
17856
|
}
|
|
17857
|
+
async function closeSessionEntry(sessionId, entry) {
|
|
17858
|
+
sessions.delete(sessionId);
|
|
17859
|
+
for (const close of [
|
|
17860
|
+
() => entry.transport.close(),
|
|
17861
|
+
() => entry.server.close()
|
|
17862
|
+
]) {
|
|
17863
|
+
try {
|
|
17864
|
+
await close();
|
|
17865
|
+
} catch {
|
|
17866
|
+
}
|
|
17867
|
+
}
|
|
17868
|
+
}
|
|
17869
|
+
async function reclaimOldestIdleSession(userId) {
|
|
17870
|
+
const sessionId = findOldestIdleSessionId(sessions, userId);
|
|
17871
|
+
if (!sessionId) return false;
|
|
17872
|
+
const entry = sessions.get(sessionId);
|
|
17873
|
+
if (!entry) return false;
|
|
17874
|
+
await closeSessionEntry(sessionId, entry);
|
|
17875
|
+
console.log(
|
|
17876
|
+
`[MCP HTTP] Reclaimed one idle ${userId ? "user" : "global"} session.`
|
|
17877
|
+
);
|
|
17878
|
+
return true;
|
|
17879
|
+
}
|
|
17880
|
+
async function runInSession(entry, callback) {
|
|
17881
|
+
entry.activeRequests += 1;
|
|
17882
|
+
entry.lastActivity = Date.now();
|
|
17883
|
+
try {
|
|
17884
|
+
return await callback();
|
|
17885
|
+
} finally {
|
|
17886
|
+
entry.activeRequests = Math.max(0, entry.activeRequests - 1);
|
|
17887
|
+
entry.lastActivity = Date.now();
|
|
17888
|
+
}
|
|
17889
|
+
}
|
|
16346
17890
|
var cleanupInterval = setInterval(
|
|
16347
17891
|
() => {
|
|
16348
17892
|
const now = Date.now();
|
|
16349
17893
|
for (const [sessionId, entry] of sessions) {
|
|
16350
|
-
if (now - entry.lastActivity > SESSION_TIMEOUT_MS) {
|
|
16351
|
-
entry
|
|
16352
|
-
|
|
16353
|
-
sessions.delete(sessionId);
|
|
16354
|
-
console.log(`[MCP HTTP] Cleaned up stale session: ${sessionId}`);
|
|
17894
|
+
if (now - entry.lastActivity > SESSION_TIMEOUT_MS && entry.activeRequests === 0) {
|
|
17895
|
+
void closeSessionEntry(sessionId, entry);
|
|
17896
|
+
console.log("[MCP HTTP] Cleaned up one stale session.");
|
|
16355
17897
|
}
|
|
16356
17898
|
}
|
|
16357
17899
|
},
|
|
@@ -16374,7 +17916,7 @@ setInterval(() => {
|
|
|
16374
17916
|
}
|
|
16375
17917
|
}, IP_RATE_CLEANUP_INTERVAL).unref();
|
|
16376
17918
|
app.use((req, res, next) => {
|
|
16377
|
-
if (req.path === "/health" || req.path
|
|
17919
|
+
if (req.path === "/health" || PROTECTED_RESOURCE_METADATA_PATHS.includes(req.path) || req.path === "/.well-known/oauth-authorization-server" || req.path === "/config")
|
|
16378
17920
|
return next();
|
|
16379
17921
|
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
16380
17922
|
const now = Date.now();
|
|
@@ -16445,7 +17987,8 @@ app.use((req, res, next) => {
|
|
|
16445
17987
|
var oauthProvider = createOAuthProvider({
|
|
16446
17988
|
supabaseUrl: SUPABASE_URL2,
|
|
16447
17989
|
supabaseAnonKey: SUPABASE_ANON_KEY,
|
|
16448
|
-
appBaseUrl: APP_BASE_URL
|
|
17990
|
+
appBaseUrl: APP_BASE_URL,
|
|
17991
|
+
resource: MCP_SERVER_URL
|
|
16449
17992
|
});
|
|
16450
17993
|
var SCOPES_SUPPORTED = [
|
|
16451
17994
|
"mcp:full",
|
|
@@ -16456,7 +17999,7 @@ var SCOPES_SUPPORTED = [
|
|
|
16456
17999
|
"mcp:comments",
|
|
16457
18000
|
"mcp:autopilot"
|
|
16458
18001
|
];
|
|
16459
|
-
app.get(
|
|
18002
|
+
app.get(PROTECTED_RESOURCE_METADATA_PATHS, (_req, res) => {
|
|
16460
18003
|
res.json(
|
|
16461
18004
|
buildProtectedResourceMetadata({
|
|
16462
18005
|
resourceUrl: MCP_SERVER_URL,
|
|
@@ -16512,7 +18055,7 @@ app.use((req, _res, next) => {
|
|
|
16512
18055
|
app.use((req, res, next) => {
|
|
16513
18056
|
authRouter(req, res, (err) => {
|
|
16514
18057
|
if (err) {
|
|
16515
|
-
|
|
18058
|
+
logOperationalError("Auth router error", err);
|
|
16516
18059
|
}
|
|
16517
18060
|
next(err);
|
|
16518
18061
|
});
|
|
@@ -16545,8 +18088,8 @@ async function authenticateRequest(req, res, next) {
|
|
|
16545
18088
|
projectId: authInfo.extra?.projectId ?? null
|
|
16546
18089
|
};
|
|
16547
18090
|
next();
|
|
16548
|
-
} catch
|
|
16549
|
-
const message =
|
|
18091
|
+
} catch {
|
|
18092
|
+
const message = "The access token is invalid, expired, or not authorized for MCP.";
|
|
16550
18093
|
res.setHeader(
|
|
16551
18094
|
"WWW-Authenticate",
|
|
16552
18095
|
buildWwwAuthenticateHeader({
|
|
@@ -16654,11 +18197,7 @@ app.get(
|
|
|
16654
18197
|
status: "ok",
|
|
16655
18198
|
version: MCP_VERSION,
|
|
16656
18199
|
transport: "streamable-http",
|
|
16657
|
-
|
|
16658
|
-
sessionCap: MAX_SESSIONS,
|
|
16659
|
-
uptime: Math.floor(process.uptime()),
|
|
16660
|
-
memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
|
16661
|
-
env: NODE_ENV
|
|
18200
|
+
uptime: Math.floor(process.uptime())
|
|
16662
18201
|
});
|
|
16663
18202
|
}
|
|
16664
18203
|
);
|
|
@@ -16710,7 +18249,8 @@ app.post(
|
|
|
16710
18249
|
});
|
|
16711
18250
|
return;
|
|
16712
18251
|
}
|
|
16713
|
-
const
|
|
18252
|
+
const category = rateLimitCategoryForTool(name);
|
|
18253
|
+
const rl = checkRateLimit(category, `rest:${auth.userId}`);
|
|
16714
18254
|
if (!rl.allowed) {
|
|
16715
18255
|
res.setHeader("Retry-After", String(rl.retryAfter));
|
|
16716
18256
|
res.status(429).json({
|
|
@@ -16777,7 +18317,9 @@ app.post(
|
|
|
16777
18317
|
app.post("/mcp", async (req, res) => {
|
|
16778
18318
|
const auth = req.auth;
|
|
16779
18319
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
16780
|
-
const
|
|
18320
|
+
const requestedTool = req.body?.method === "tools/call" && typeof req.body?.params?.name === "string" ? req.body.params.name : void 0;
|
|
18321
|
+
const category = rateLimitCategoryForTool(requestedTool);
|
|
18322
|
+
const rl = checkRateLimit(category, `mcp:${auth.userId}`);
|
|
16781
18323
|
if (!rl.allowed) {
|
|
16782
18324
|
res.setHeader("Retry-After", String(rl.retryAfter));
|
|
16783
18325
|
res.status(429).json({
|
|
@@ -16797,31 +18339,33 @@ app.post("/mcp", async (req, res) => {
|
|
|
16797
18339
|
});
|
|
16798
18340
|
return;
|
|
16799
18341
|
}
|
|
16800
|
-
|
|
16801
|
-
|
|
16802
|
-
|
|
16803
|
-
|
|
16804
|
-
|
|
16805
|
-
|
|
16806
|
-
|
|
16807
|
-
|
|
16808
|
-
|
|
16809
|
-
|
|
16810
|
-
|
|
18342
|
+
await runInSession(
|
|
18343
|
+
entry,
|
|
18344
|
+
() => requestContext.run(
|
|
18345
|
+
{
|
|
18346
|
+
userId: auth.userId,
|
|
18347
|
+
scopes: auth.scopes,
|
|
18348
|
+
token: auth.token,
|
|
18349
|
+
creditsUsed: 0,
|
|
18350
|
+
assetsGenerated: 0,
|
|
18351
|
+
projectId: auth.projectId
|
|
18352
|
+
},
|
|
18353
|
+
() => entry.transport.handleRequest(req, res, req.body)
|
|
18354
|
+
)
|
|
16811
18355
|
);
|
|
16812
18356
|
return;
|
|
16813
18357
|
}
|
|
16814
|
-
if (
|
|
18358
|
+
if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER && !await reclaimOldestIdleSession(auth.userId)) {
|
|
16815
18359
|
res.status(429).json({
|
|
16816
18360
|
error: "too_many_sessions",
|
|
16817
|
-
error_description: `
|
|
18361
|
+
error_description: `Per-user session limit reached (${MAX_SESSIONS_PER_USER}); all sessions are active. Close an existing client and retry.`
|
|
16818
18362
|
});
|
|
16819
18363
|
return;
|
|
16820
18364
|
}
|
|
16821
|
-
if (
|
|
18365
|
+
if (sessions.size >= MAX_SESSIONS && !await reclaimOldestIdleSession()) {
|
|
16822
18366
|
res.status(429).json({
|
|
16823
18367
|
error: "too_many_sessions",
|
|
16824
|
-
error_description: `
|
|
18368
|
+
error_description: `Server session limit reached (${MAX_SESSIONS}); all sessions are active. Try again later.`
|
|
16825
18369
|
});
|
|
16826
18370
|
return;
|
|
16827
18371
|
}
|
|
@@ -16836,14 +18380,17 @@ app.post("/mcp", async (req, res) => {
|
|
|
16836
18380
|
});
|
|
16837
18381
|
registerPrompts(server);
|
|
16838
18382
|
registerResources(server);
|
|
18383
|
+
let initializedSessionId = null;
|
|
16839
18384
|
const transport = new StreamableHTTPServerTransport({
|
|
16840
18385
|
sessionIdGenerator: () => randomUUID4(),
|
|
16841
18386
|
onsessioninitialized: (sessionId) => {
|
|
18387
|
+
initializedSessionId = sessionId;
|
|
16842
18388
|
sessions.set(sessionId, {
|
|
16843
18389
|
transport,
|
|
16844
18390
|
server,
|
|
16845
18391
|
lastActivity: Date.now(),
|
|
16846
|
-
userId: auth.userId
|
|
18392
|
+
userId: auth.userId,
|
|
18393
|
+
activeRequests: 1
|
|
16847
18394
|
});
|
|
16848
18395
|
}
|
|
16849
18396
|
});
|
|
@@ -16853,20 +18400,29 @@ app.post("/mcp", async (req, res) => {
|
|
|
16853
18400
|
}
|
|
16854
18401
|
};
|
|
16855
18402
|
await server.connect(transport);
|
|
16856
|
-
|
|
16857
|
-
|
|
16858
|
-
|
|
16859
|
-
|
|
16860
|
-
|
|
16861
|
-
|
|
16862
|
-
|
|
16863
|
-
|
|
16864
|
-
|
|
16865
|
-
|
|
16866
|
-
|
|
18403
|
+
try {
|
|
18404
|
+
await requestContext.run(
|
|
18405
|
+
{
|
|
18406
|
+
userId: auth.userId,
|
|
18407
|
+
scopes: auth.scopes,
|
|
18408
|
+
token: auth.token,
|
|
18409
|
+
creditsUsed: 0,
|
|
18410
|
+
assetsGenerated: 0,
|
|
18411
|
+
projectId: auth.projectId
|
|
18412
|
+
},
|
|
18413
|
+
() => transport.handleRequest(req, res, req.body)
|
|
18414
|
+
);
|
|
18415
|
+
} finally {
|
|
18416
|
+
if (initializedSessionId) {
|
|
18417
|
+
const entry = sessions.get(initializedSessionId);
|
|
18418
|
+
if (entry) {
|
|
18419
|
+
entry.activeRequests = Math.max(0, entry.activeRequests - 1);
|
|
18420
|
+
entry.lastActivity = Date.now();
|
|
18421
|
+
}
|
|
18422
|
+
}
|
|
18423
|
+
}
|
|
16867
18424
|
} catch (err) {
|
|
16868
|
-
|
|
16869
|
-
console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
|
|
18425
|
+
logOperationalError("POST /mcp error", err);
|
|
16870
18426
|
if (!res.headersSent) {
|
|
16871
18427
|
res.status(500).json({
|
|
16872
18428
|
jsonrpc: "2.0",
|
|
@@ -16886,19 +18442,21 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
|
|
|
16886
18442
|
res.status(403).json({ error: "Session belongs to another user" });
|
|
16887
18443
|
return;
|
|
16888
18444
|
}
|
|
16889
|
-
entry.lastActivity = Date.now();
|
|
16890
18445
|
res.setHeader("X-Accel-Buffering", "no");
|
|
16891
18446
|
res.setHeader("Cache-Control", "no-cache");
|
|
16892
|
-
await
|
|
16893
|
-
|
|
16894
|
-
|
|
16895
|
-
|
|
16896
|
-
|
|
16897
|
-
|
|
16898
|
-
|
|
16899
|
-
|
|
16900
|
-
|
|
16901
|
-
|
|
18447
|
+
await runInSession(
|
|
18448
|
+
entry,
|
|
18449
|
+
() => requestContext.run(
|
|
18450
|
+
{
|
|
18451
|
+
userId: req.auth.userId,
|
|
18452
|
+
scopes: req.auth.scopes,
|
|
18453
|
+
token: req.auth.token,
|
|
18454
|
+
creditsUsed: 0,
|
|
18455
|
+
assetsGenerated: 0,
|
|
18456
|
+
projectId: req.auth.projectId
|
|
18457
|
+
},
|
|
18458
|
+
() => entry.transport.handleRequest(req, res)
|
|
18459
|
+
)
|
|
16902
18460
|
);
|
|
16903
18461
|
});
|
|
16904
18462
|
app.delete(
|
|
@@ -16915,18 +18473,13 @@ app.delete(
|
|
|
16915
18473
|
res.status(403).json({ error: "Session belongs to another user" });
|
|
16916
18474
|
return;
|
|
16917
18475
|
}
|
|
16918
|
-
await entry
|
|
16919
|
-
await entry.server.close();
|
|
16920
|
-
sessions.delete(sessionId);
|
|
18476
|
+
await closeSessionEntry(sessionId, entry);
|
|
16921
18477
|
res.status(200).json({ status: "session_closed" });
|
|
16922
18478
|
}
|
|
16923
18479
|
);
|
|
16924
18480
|
app.use(
|
|
16925
18481
|
(err, _req, res, _next) => {
|
|
16926
|
-
|
|
16927
|
-
"[MCP HTTP] Unhandled Express error:",
|
|
16928
|
-
err.stack || err.message || err
|
|
16929
|
-
);
|
|
18482
|
+
logOperationalError("Unhandled Express error", err);
|
|
16930
18483
|
if (res.headersSent) return;
|
|
16931
18484
|
const e = err;
|
|
16932
18485
|
const status = e.status ?? e.statusCode;
|
|
@@ -16945,9 +18498,8 @@ var httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
|
16945
18498
|
`[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`
|
|
16946
18499
|
);
|
|
16947
18500
|
console.log(`[MCP HTTP] Health: http://localhost:${PORT}/health`);
|
|
16948
|
-
console.log(`[MCP HTTP] MCP endpoint: ${MCP_SERVER_URL}`);
|
|
18501
|
+
console.log(`[MCP HTTP] MCP endpoint: ${safeEndpointForLog(MCP_SERVER_URL)}`);
|
|
16949
18502
|
console.log(`[MCP HTTP] Tool profile: ${TOOL_PROFILE}`);
|
|
16950
|
-
console.log(`[MCP HTTP] Environment: ${NODE_ENV}`);
|
|
16951
18503
|
});
|
|
16952
18504
|
async function shutdown(signal) {
|
|
16953
18505
|
console.log(`[MCP HTTP] ${signal} received, shutting down...`);
|