@socialneuron/mcp-server 1.8.0 → 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 +39 -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 +2086 -480
- package/dist/index.js +1868 -373
- package/dist/sn.js +1893 -398
- package/package.json +15 -12
- 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: [
|
|
@@ -2717,7 +3057,7 @@ function registerContentTools(server) {
|
|
|
2717
3057
|
);
|
|
2718
3058
|
server.tool(
|
|
2719
3059
|
"generate_image",
|
|
2720
|
-
"Start an async AI image generation job \u2014 returns a job_id immediately. Poll with check_status every 5-15s until complete. Costs
|
|
3060
|
+
"Start an async AI image generation job \u2014 returns a job_id immediately. Poll with check_status every 5-15s until complete. Costs 15-50 credits depending on model. Use for social media posts, carousel slides, or as input to generate_video (image-to-video). Pass project_id so the asset is stored with the correct brand/project.",
|
|
2721
3061
|
{
|
|
2722
3062
|
prompt: z2.string().max(2e3).describe(
|
|
2723
3063
|
"Text prompt describing the image to generate. Be specific about style, composition, colors, lighting, and subject matter."
|
|
@@ -2739,9 +3079,10 @@ function registerContentTools(server) {
|
|
|
2739
3079
|
image_url: z2.string().optional().describe(
|
|
2740
3080
|
"Reference image URL for image-to-image generation. Required for ideogram model. Optional for others."
|
|
2741
3081
|
),
|
|
3082
|
+
project_id: z2.string().optional().describe("Project ID to associate the generated image with."),
|
|
2742
3083
|
response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
2743
3084
|
},
|
|
2744
|
-
async ({ prompt, model, aspect_ratio, image_url, response_format }) => {
|
|
3085
|
+
async ({ prompt, model, aspect_ratio, image_url, project_id, response_format }) => {
|
|
2745
3086
|
const format = response_format ?? "text";
|
|
2746
3087
|
const userId = await getDefaultUserId();
|
|
2747
3088
|
const assetBudget = checkAssetBudget();
|
|
@@ -2759,7 +3100,7 @@ function registerContentTools(server) {
|
|
|
2759
3100
|
isError: true
|
|
2760
3101
|
};
|
|
2761
3102
|
}
|
|
2762
|
-
const rateLimit = checkRateLimit("
|
|
3103
|
+
const rateLimit = checkRateLimit("generation", `generate_image:${userId}`);
|
|
2763
3104
|
if (!rateLimit.allowed) {
|
|
2764
3105
|
return {
|
|
2765
3106
|
content: [
|
|
@@ -2777,7 +3118,8 @@ function registerContentTools(server) {
|
|
|
2777
3118
|
prompt,
|
|
2778
3119
|
model,
|
|
2779
3120
|
aspectRatio: aspect_ratio ?? "1:1",
|
|
2780
|
-
imageUrl: image_url
|
|
3121
|
+
imageUrl: image_url,
|
|
3122
|
+
...project_id && { projectId: project_id }
|
|
2781
3123
|
},
|
|
2782
3124
|
{ timeoutMs: 3e4 }
|
|
2783
3125
|
);
|
|
@@ -2816,7 +3158,8 @@ function registerContentTools(server) {
|
|
|
2816
3158
|
jobId,
|
|
2817
3159
|
taskId: data.taskId,
|
|
2818
3160
|
asyncJobId: data.asyncJobId,
|
|
2819
|
-
model: data.model
|
|
3161
|
+
model: data.model,
|
|
3162
|
+
projectId: project_id ?? null
|
|
2820
3163
|
}),
|
|
2821
3164
|
null,
|
|
2822
3165
|
2
|
|
@@ -2897,20 +3240,28 @@ function registerContentTools(server) {
|
|
|
2897
3240
|
}
|
|
2898
3241
|
);
|
|
2899
3242
|
if (liveStatus) {
|
|
3243
|
+
const livePayload = buildCheckStatusPayload(job, liveStatus);
|
|
2900
3244
|
const lines2 = [
|
|
2901
3245
|
`Job: ${job.id}`,
|
|
2902
3246
|
`Type: ${job.job_type}`,
|
|
2903
3247
|
`Model: ${job.model}`,
|
|
2904
|
-
`Status: ${
|
|
2905
|
-
`Progress: ${
|
|
3248
|
+
`Status: ${livePayload.status}`,
|
|
3249
|
+
`Progress: ${livePayload.progress}%`
|
|
2906
3250
|
];
|
|
2907
|
-
if (
|
|
2908
|
-
lines2.push(`Result URL: ${
|
|
3251
|
+
if (livePayload.result_url) {
|
|
3252
|
+
lines2.push(`Result URL: ${livePayload.result_url}`);
|
|
2909
3253
|
}
|
|
2910
|
-
if (
|
|
2911
|
-
lines2.push(`Error: ${
|
|
3254
|
+
if (livePayload.error) {
|
|
3255
|
+
lines2.push(`Error: ${livePayload.error}`);
|
|
2912
3256
|
}
|
|
3257
|
+
const fallbackDisclosure2 = buildFallbackDisclosureLine(job.result_metadata);
|
|
3258
|
+
if (fallbackDisclosure2) lines2.push(fallbackDisclosure2);
|
|
2913
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
|
+
}
|
|
2914
3265
|
lines2.push(`Created: ${job.created_at}`);
|
|
2915
3266
|
if (format === "json") {
|
|
2916
3267
|
return {
|
|
@@ -2928,7 +3279,7 @@ function registerContentTools(server) {
|
|
|
2928
3279
|
// `job` + `liveStatus`; do not duplicate them here.
|
|
2929
3280
|
asEnvelope2({
|
|
2930
3281
|
...liveStatus,
|
|
2931
|
-
...
|
|
3282
|
+
...livePayload
|
|
2932
3283
|
}),
|
|
2933
3284
|
null,
|
|
2934
3285
|
2
|
|
@@ -2971,7 +3322,14 @@ function registerContentTools(server) {
|
|
|
2971
3322
|
if (job.error_message) {
|
|
2972
3323
|
lines.push(`Error: ${job.error_message}`);
|
|
2973
3324
|
}
|
|
3325
|
+
const fallbackDisclosure = buildFallbackDisclosureLine(job.result_metadata);
|
|
3326
|
+
if (fallbackDisclosure) lines.push(fallbackDisclosure);
|
|
2974
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
|
+
}
|
|
2975
3333
|
lines.push(`Created: ${job.created_at}`);
|
|
2976
3334
|
if (job.completed_at) {
|
|
2977
3335
|
lines.push(`Completed: ${job.completed_at}`);
|
|
@@ -2997,7 +3355,7 @@ function registerContentTools(server) {
|
|
|
2997
3355
|
);
|
|
2998
3356
|
server.tool(
|
|
2999
3357
|
"create_storyboard",
|
|
3000
|
-
"Plan a multi-scene video storyboard with AI-generated prompts, durations, captions, and voiceover text per frame. Use before generate_video or generate_image to create cohesive multi-shot content. Include brand_context from get_brand_profile for consistent
|
|
3358
|
+
"Plan a multi-scene video storyboard with AI-generated prompts, durations, captions, and voiceover text per frame. Use before generate_video or generate_image to create cohesive multi-shot content. Include brand_context from get_brand_profile and project_id for consistent, project-scoped production. Costs 10 credits.",
|
|
3001
3359
|
{
|
|
3002
3360
|
concept: z2.string().max(2e3).describe(
|
|
3003
3361
|
'The video concept/idea. Include: hook, key messages, target audience, and desired outcome (e.g., "TikTok ad for VPN app targeting privacy-conscious millennials, hook with shocking stat about data leaks").'
|
|
@@ -3021,6 +3379,7 @@ function registerContentTools(server) {
|
|
|
3021
3379
|
style: z2.string().optional().describe(
|
|
3022
3380
|
'Visual style direction (e.g., "cinematic", "anime", "documentary", "motion graphics").'
|
|
3023
3381
|
),
|
|
3382
|
+
project_id: z2.string().optional().describe("Project ID for brand-scoped generation and attribution."),
|
|
3024
3383
|
response_format: z2.enum(["text", "json"]).optional().describe(
|
|
3025
3384
|
"Response format. Defaults to json for structured storyboard data."
|
|
3026
3385
|
)
|
|
@@ -3032,6 +3391,7 @@ function registerContentTools(server) {
|
|
|
3032
3391
|
target_duration,
|
|
3033
3392
|
num_scenes,
|
|
3034
3393
|
style,
|
|
3394
|
+
project_id,
|
|
3035
3395
|
response_format
|
|
3036
3396
|
}) => {
|
|
3037
3397
|
const format = response_format ?? "json";
|
|
@@ -3114,7 +3474,8 @@ Return ONLY valid JSON in this exact format:
|
|
|
3114
3474
|
prompt: storyboardPrompt,
|
|
3115
3475
|
type: "storyboard",
|
|
3116
3476
|
model: "gemini-2.5-flash",
|
|
3117
|
-
responseFormat: "json"
|
|
3477
|
+
responseFormat: "json",
|
|
3478
|
+
...project_id && { projectId: project_id }
|
|
3118
3479
|
},
|
|
3119
3480
|
{ timeoutMs: 6e4 }
|
|
3120
3481
|
);
|
|
@@ -3129,7 +3490,18 @@ Return ONLY valid JSON in this exact format:
|
|
|
3129
3490
|
isError: true
|
|
3130
3491
|
};
|
|
3131
3492
|
}
|
|
3132
|
-
const rawContent = data?.content
|
|
3493
|
+
const rawContent = data?.content?.trim() || data?.text?.trim() || "";
|
|
3494
|
+
if (!rawContent) {
|
|
3495
|
+
return {
|
|
3496
|
+
content: [
|
|
3497
|
+
{
|
|
3498
|
+
type: "text",
|
|
3499
|
+
text: "Storyboard generation failed: the AI service returned an empty response."
|
|
3500
|
+
}
|
|
3501
|
+
],
|
|
3502
|
+
isError: true
|
|
3503
|
+
};
|
|
3504
|
+
}
|
|
3133
3505
|
addCreditsUsed(estimatedCost);
|
|
3134
3506
|
if (format === "json") {
|
|
3135
3507
|
try {
|
|
@@ -3165,16 +3537,17 @@ Return ONLY valid JSON in this exact format:
|
|
|
3165
3537
|
);
|
|
3166
3538
|
server.tool(
|
|
3167
3539
|
"generate_voiceover",
|
|
3168
|
-
"Generate a voiceover audio file for video narration. Returns an R2-hosted audio URL. Use after create_storyboard to add narration to each scene, or standalone for podcast intros and ad reads. Costs
|
|
3540
|
+
"Generate a voiceover audio file for video narration. Returns an R2-hosted audio URL. Use after create_storyboard to add narration to each scene, or standalone for podcast intros and ad reads. Pass project_id to keep the asset with the correct brand/project. Costs 15 credits per generation.",
|
|
3169
3541
|
{
|
|
3170
3542
|
text: z2.string().max(5e3).describe("The script/text to convert to speech."),
|
|
3171
3543
|
voice: z2.enum(["rachel", "domi"]).optional().describe(
|
|
3172
3544
|
"Voice selection. rachel=warm female, domi=confident female. Defaults to rachel."
|
|
3173
3545
|
),
|
|
3174
3546
|
speed: z2.number().min(0.5).max(2).optional().describe("Speech speed multiplier. 1.0 is normal. Defaults to 1.0."),
|
|
3547
|
+
project_id: z2.string().optional().describe("Project ID to associate the generated voiceover with."),
|
|
3175
3548
|
response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
|
|
3176
3549
|
},
|
|
3177
|
-
async ({ text, voice, speed, response_format }) => {
|
|
3550
|
+
async ({ text, voice, speed, project_id, response_format }) => {
|
|
3178
3551
|
const format = response_format ?? "text";
|
|
3179
3552
|
const userId = await getDefaultUserId();
|
|
3180
3553
|
const estimatedCost = 15;
|
|
@@ -3186,7 +3559,7 @@ Return ONLY valid JSON in this exact format:
|
|
|
3186
3559
|
};
|
|
3187
3560
|
}
|
|
3188
3561
|
const rateLimit = checkRateLimit(
|
|
3189
|
-
"
|
|
3562
|
+
"generation",
|
|
3190
3563
|
`generate_voiceover:${userId}`
|
|
3191
3564
|
);
|
|
3192
3565
|
if (!rateLimit.allowed) {
|
|
@@ -3209,7 +3582,8 @@ Return ONLY valid JSON in this exact format:
|
|
|
3209
3582
|
{
|
|
3210
3583
|
text,
|
|
3211
3584
|
voiceId: ELEVENLABS_VOICE_IDS[voice ?? "rachel"],
|
|
3212
|
-
speed: speed ?? 1
|
|
3585
|
+
speed: speed ?? 1,
|
|
3586
|
+
...project_id && { projectId: project_id }
|
|
3213
3587
|
},
|
|
3214
3588
|
{ timeoutMs: 6e4 }
|
|
3215
3589
|
);
|
|
@@ -3245,7 +3619,8 @@ Return ONLY valid JSON in this exact format:
|
|
|
3245
3619
|
asEnvelope2({
|
|
3246
3620
|
audioUrl: data.audioUrl,
|
|
3247
3621
|
durationSeconds: data.durationSeconds,
|
|
3248
|
-
voice: voice ?? "rachel"
|
|
3622
|
+
voice: voice ?? "rachel",
|
|
3623
|
+
projectId: project_id ?? null
|
|
3249
3624
|
}),
|
|
3250
3625
|
null,
|
|
3251
3626
|
2
|
|
@@ -3351,7 +3726,7 @@ Return ONLY valid JSON in this exact format:
|
|
|
3351
3726
|
}
|
|
3352
3727
|
const userId = await getDefaultUserId();
|
|
3353
3728
|
const rateLimit = checkRateLimit(
|
|
3354
|
-
"
|
|
3729
|
+
"generation",
|
|
3355
3730
|
`generate_carousel:${userId}`
|
|
3356
3731
|
);
|
|
3357
3732
|
if (!rateLimit.allowed) {
|
|
@@ -3453,7 +3828,7 @@ Return ONLY valid JSON in this exact format:
|
|
|
3453
3828
|
|
|
3454
3829
|
// src/tools/distribution.ts
|
|
3455
3830
|
import { z as z3 } from "zod";
|
|
3456
|
-
import { createHash
|
|
3831
|
+
import { createHash } from "node:crypto";
|
|
3457
3832
|
|
|
3458
3833
|
// src/lib/sanitize-error.ts
|
|
3459
3834
|
var ERROR_PATTERNS = [
|
|
@@ -3492,9 +3867,6 @@ var ERROR_PATTERNS = [
|
|
|
3492
3867
|
];
|
|
3493
3868
|
function sanitizeError(error) {
|
|
3494
3869
|
const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
|
|
3495
|
-
if (process.env.NODE_ENV !== "production") {
|
|
3496
|
-
console.error("[Error]", msg);
|
|
3497
|
-
}
|
|
3498
3870
|
for (const [pattern, userMessage] of ERROR_PATTERNS) {
|
|
3499
3871
|
if (pattern.test(msg)) {
|
|
3500
3872
|
return userMessage;
|
|
@@ -3665,6 +4037,7 @@ function evaluateQuality(input) {
|
|
|
3665
4037
|
const firstLine = caption.split("\n")[0]?.trim() ?? "";
|
|
3666
4038
|
const hashtags = countHashtags(caption);
|
|
3667
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;
|
|
3668
4041
|
const blockedTerms = [
|
|
3669
4042
|
...(input.brandAvoidPatterns ?? []).map((t) => t.trim()).filter(Boolean),
|
|
3670
4043
|
...(input.customBannedTerms ?? []).map((t) => t.trim()).filter(Boolean)
|
|
@@ -3674,11 +4047,12 @@ function evaluateQuality(input) {
|
|
|
3674
4047
|
if (firstLine.length >= 20 && firstLine.length <= 120) hookScore += 1;
|
|
3675
4048
|
if (/[!?]/.test(firstLine) || /\b\d+(\.\d+)?\b/.test(firstLine)) hookScore += 1;
|
|
3676
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);
|
|
3677
4051
|
categories.push({
|
|
3678
4052
|
name: "Hook Strength",
|
|
3679
4053
|
score: Math.min(5, hookScore),
|
|
3680
4054
|
maxScore: 5,
|
|
3681
|
-
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."
|
|
3682
4056
|
});
|
|
3683
4057
|
let clarityScore = 2;
|
|
3684
4058
|
if (caption.length >= 80 && caption.length <= 1200) clarityScore += 2;
|
|
@@ -3704,7 +4078,8 @@ function evaluateQuality(input) {
|
|
|
3704
4078
|
detail: "Length, title, and hashtag usage should match target platforms."
|
|
3705
4079
|
});
|
|
3706
4080
|
let brandScore = 3;
|
|
3707
|
-
const
|
|
4081
|
+
const rawBrandKeyword = input.brandKeyword ?? process.env.SOCIALNEURON_BRAND_KEYWORD?.trim();
|
|
4082
|
+
const brandKeyword = rawBrandKeyword ? rawBrandKeyword.slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : null;
|
|
3708
4083
|
if (brandKeyword && new RegExp("\\b" + brandKeyword + "\\b", "i").test(title + " " + caption))
|
|
3709
4084
|
brandScore += 1;
|
|
3710
4085
|
if (!/\b(you|your|customer|audience)\b/i.test(caption)) brandScore -= 1;
|
|
@@ -3715,11 +4090,12 @@ function evaluateQuality(input) {
|
|
|
3715
4090
|
brandScore -= Math.min(2, matched.length);
|
|
3716
4091
|
}
|
|
3717
4092
|
}
|
|
4093
|
+
if (isShortFormX) brandScore = Math.max(brandScore, 3);
|
|
3718
4094
|
categories.push({
|
|
3719
4095
|
name: "Brand Alignment",
|
|
3720
4096
|
score: Math.max(0, Math.min(5, brandScore)),
|
|
3721
4097
|
maxScore: 5,
|
|
3722
|
-
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."
|
|
3723
4099
|
});
|
|
3724
4100
|
let noveltyScore = 2;
|
|
3725
4101
|
if (/\b(case study|framework|workflow|playbook|breakdown|behind the scenes)\b/i.test(caption))
|
|
@@ -3730,21 +4106,23 @@ function evaluateQuality(input) {
|
|
|
3730
4106
|
const matched = blockedTerms.filter((term) => lowerCombined.includes(term.toLowerCase()));
|
|
3731
4107
|
if (matched.length > 0) noveltyScore -= Math.min(2, matched.length);
|
|
3732
4108
|
}
|
|
4109
|
+
if (isShortFormX) noveltyScore = Math.max(noveltyScore, 3);
|
|
3733
4110
|
categories.push({
|
|
3734
4111
|
name: "Novelty",
|
|
3735
4112
|
score: Math.max(0, Math.min(5, noveltyScore)),
|
|
3736
4113
|
maxScore: 5,
|
|
3737
|
-
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."
|
|
3738
4115
|
});
|
|
3739
4116
|
let ctaScore = 2;
|
|
3740
4117
|
if (/\b(comment|reply|share|save|follow|subscribe|click|try|book|download)\b/i.test(caption))
|
|
3741
4118
|
ctaScore += 2;
|
|
3742
4119
|
if (/\?$/.test(firstLine)) ctaScore += 1;
|
|
4120
|
+
if (isShortFormX) ctaScore = Math.max(ctaScore, 3);
|
|
3743
4121
|
categories.push({
|
|
3744
4122
|
name: "CTA Strength",
|
|
3745
4123
|
score: Math.min(5, ctaScore),
|
|
3746
4124
|
maxScore: 5,
|
|
3747
|
-
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."
|
|
3748
4126
|
});
|
|
3749
4127
|
let safetyScore = 5;
|
|
3750
4128
|
if (/\b(guarantee|guaranteed|no risk|risk-free|always works|100%)\b/i.test(caption))
|
|
@@ -3802,7 +4180,9 @@ var PLATFORM_CASE_MAP = {
|
|
|
3802
4180
|
threads: "Threads",
|
|
3803
4181
|
bluesky: "Bluesky"
|
|
3804
4182
|
};
|
|
3805
|
-
var TIKTOK_AUDIT_APPROVED = false
|
|
4183
|
+
var TIKTOK_AUDIT_APPROVED = !["false", "0", "no"].includes(
|
|
4184
|
+
(process.env.TIKTOK_AUDIT_APPROVED ?? "").toLowerCase()
|
|
4185
|
+
);
|
|
3806
4186
|
var MCP_NOT_LIVE_FOR_POSTING = /* @__PURE__ */ new Set(["LinkedIn", "Pinterest", "Reddit"]);
|
|
3807
4187
|
function asEnvelope3(data) {
|
|
3808
4188
|
return {
|
|
@@ -3813,6 +4193,106 @@ function asEnvelope3(data) {
|
|
|
3813
4193
|
data
|
|
3814
4194
|
};
|
|
3815
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
|
+
}
|
|
3816
4296
|
function accountEffectiveStatus(account) {
|
|
3817
4297
|
return account.effective_status || account.status;
|
|
3818
4298
|
}
|
|
@@ -3831,22 +4311,11 @@ function requestedAccountIdForPlatform(accountId, accountIds, platform2) {
|
|
|
3831
4311
|
if (!accountIds) return void 0;
|
|
3832
4312
|
return accountIds[platform2] || accountIds[platform2.toLowerCase()];
|
|
3833
4313
|
}
|
|
3834
|
-
function isAlreadyR2Signed(url) {
|
|
3835
|
-
try {
|
|
3836
|
-
const u = new URL(url);
|
|
3837
|
-
return u.searchParams.has("X-Amz-Signature");
|
|
3838
|
-
} catch {
|
|
3839
|
-
return false;
|
|
3840
|
-
}
|
|
3841
|
-
}
|
|
3842
4314
|
async function rehostExternalUrl(mediaUrl, projectId) {
|
|
3843
4315
|
const ssrf = await validateUrlForSSRF(mediaUrl);
|
|
3844
4316
|
if (!ssrf.isValid) {
|
|
3845
4317
|
return { error: ssrf.error ?? "URL rejected by SSRF check" };
|
|
3846
4318
|
}
|
|
3847
|
-
if (isAlreadyR2Signed(mediaUrl)) {
|
|
3848
|
-
return { signedUrl: mediaUrl, r2Key: "" };
|
|
3849
|
-
}
|
|
3850
4319
|
const { data, error } = await callEdgeFunction(
|
|
3851
4320
|
"upload-to-r2",
|
|
3852
4321
|
{ url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
|
|
@@ -3865,19 +4334,19 @@ function registerDistributionTools(server) {
|
|
|
3865
4334
|
media_url: z3.string().optional().describe(
|
|
3866
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."
|
|
3867
4336
|
),
|
|
3868
|
-
media_urls: z3.array(z3.string()).optional().describe(
|
|
4337
|
+
media_urls: z3.array(z3.string().url()).min(2).max(10).optional().describe(
|
|
3869
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."
|
|
3870
4339
|
),
|
|
3871
4340
|
r2_key: z3.string().optional().describe(
|
|
3872
4341
|
"R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
|
|
3873
4342
|
),
|
|
3874
|
-
r2_keys: z3.array(z3.string()).optional().describe(
|
|
4343
|
+
r2_keys: z3.array(z3.string()).min(2).max(10).optional().describe(
|
|
3875
4344
|
"Array of R2 object keys for carousel posts. Each is signed on demand. Alternative to media_urls."
|
|
3876
4345
|
),
|
|
3877
4346
|
job_id: z3.string().optional().describe(
|
|
3878
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."
|
|
3879
4348
|
),
|
|
3880
|
-
job_ids: z3.array(z3.string()).optional().describe(
|
|
4349
|
+
job_ids: z3.array(z3.string()).min(2).max(10).optional().describe(
|
|
3881
4350
|
"Array of async job IDs for carousel posts. Each resolved to its R2 key. Alternative to media_urls/r2_keys."
|
|
3882
4351
|
),
|
|
3883
4352
|
platform_metadata: z3.object({
|
|
@@ -3907,7 +4376,10 @@ function registerDistributionTools(server) {
|
|
|
3907
4376
|
category_id: z3.string().optional(),
|
|
3908
4377
|
tags: z3.array(z3.string()).optional(),
|
|
3909
4378
|
made_for_kids: z3.boolean().optional(),
|
|
3910
|
-
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
|
+
)
|
|
3911
4383
|
}).optional(),
|
|
3912
4384
|
facebook: z3.object({
|
|
3913
4385
|
page_id: z3.string().optional().describe("Facebook Page ID to post to."),
|
|
@@ -3990,14 +4462,8 @@ function registerDistributionTools(server) {
|
|
|
3990
4462
|
auto_rehost: z3.boolean().optional().describe(
|
|
3991
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."
|
|
3992
4464
|
),
|
|
3993
|
-
|
|
3994
|
-
"
|
|
3995
|
-
),
|
|
3996
|
-
origin: z3.enum(["human", "hermes", "user"]).optional().describe(
|
|
3997
|
-
"Originator lineage for the post. Marks who scheduled it; use the agent value when calling from an autonomous workflow, otherwise default 'human'."
|
|
3998
|
-
),
|
|
3999
|
-
hermes_run_id: z3.string().optional().describe(
|
|
4000
|
-
"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."
|
|
4001
4467
|
)
|
|
4002
4468
|
},
|
|
4003
4469
|
async ({
|
|
@@ -4020,9 +4486,7 @@ function registerDistributionTools(server) {
|
|
|
4020
4486
|
platform_metadata,
|
|
4021
4487
|
account_id,
|
|
4022
4488
|
account_ids,
|
|
4023
|
-
|
|
4024
|
-
origin,
|
|
4025
|
-
hermes_run_id
|
|
4489
|
+
idempotency_key
|
|
4026
4490
|
}) => {
|
|
4027
4491
|
const format = response_format ?? "text";
|
|
4028
4492
|
if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
|
|
@@ -4051,6 +4515,8 @@ function registerDistributionTools(server) {
|
|
|
4051
4515
|
}
|
|
4052
4516
|
let resolvedMediaUrl = media_url;
|
|
4053
4517
|
let resolvedMediaUrls = media_urls;
|
|
4518
|
+
let resolvedMediaUrlIsTrustedR2 = false;
|
|
4519
|
+
let resolvedMediaUrlsAreTrustedR2 = (media_urls ?? []).map(() => false);
|
|
4054
4520
|
const signR2Key = async (key) => {
|
|
4055
4521
|
const cleanKey = key.startsWith("r2://") ? key.slice(5) : key;
|
|
4056
4522
|
const { data: signData } = await callEdgeFunction(
|
|
@@ -4068,8 +4534,11 @@ function registerDistributionTools(server) {
|
|
|
4068
4534
|
);
|
|
4069
4535
|
const resultUrl = jobData?.job?.result_url;
|
|
4070
4536
|
if (!resultUrl) return null;
|
|
4071
|
-
if (!resultUrl.startsWith("http"))
|
|
4072
|
-
|
|
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 };
|
|
4073
4542
|
};
|
|
4074
4543
|
try {
|
|
4075
4544
|
if (r2_key && !resolvedMediaUrl) {
|
|
@@ -4086,6 +4555,7 @@ function registerDistributionTools(server) {
|
|
|
4086
4555
|
};
|
|
4087
4556
|
}
|
|
4088
4557
|
resolvedMediaUrl = signed;
|
|
4558
|
+
resolvedMediaUrlIsTrustedR2 = true;
|
|
4089
4559
|
} else if (job_id && !resolvedMediaUrl && !r2_key) {
|
|
4090
4560
|
const resolved = await resolveJobId(job_id);
|
|
4091
4561
|
if (!resolved) {
|
|
@@ -4099,7 +4569,8 @@ function registerDistributionTools(server) {
|
|
|
4099
4569
|
isError: true
|
|
4100
4570
|
};
|
|
4101
4571
|
}
|
|
4102
|
-
resolvedMediaUrl = resolved;
|
|
4572
|
+
resolvedMediaUrl = resolved.url;
|
|
4573
|
+
resolvedMediaUrlIsTrustedR2 = resolved.trustedR2;
|
|
4103
4574
|
}
|
|
4104
4575
|
if (r2_keys && r2_keys.length > 0 && !resolvedMediaUrls) {
|
|
4105
4576
|
const signed = await Promise.all(r2_keys.map(signR2Key));
|
|
@@ -4116,6 +4587,7 @@ function registerDistributionTools(server) {
|
|
|
4116
4587
|
};
|
|
4117
4588
|
}
|
|
4118
4589
|
resolvedMediaUrls = signed;
|
|
4590
|
+
resolvedMediaUrlsAreTrustedR2 = signed.map(() => true);
|
|
4119
4591
|
} else if (job_ids && job_ids.length > 0 && !resolvedMediaUrls && !r2_keys) {
|
|
4120
4592
|
const resolved = await Promise.all(job_ids.map(resolveJobId));
|
|
4121
4593
|
const failIdx = resolved.findIndex((r) => !r);
|
|
@@ -4130,10 +4602,12 @@ function registerDistributionTools(server) {
|
|
|
4130
4602
|
isError: true
|
|
4131
4603
|
};
|
|
4132
4604
|
}
|
|
4133
|
-
|
|
4605
|
+
const resolvedJobs = resolved;
|
|
4606
|
+
resolvedMediaUrls = resolvedJobs.map((item) => item.url);
|
|
4607
|
+
resolvedMediaUrlsAreTrustedR2 = resolvedJobs.map((item) => item.trustedR2);
|
|
4134
4608
|
}
|
|
4135
4609
|
const shouldRehost = auto_rehost !== false;
|
|
4136
|
-
if (shouldRehost && resolvedMediaUrl) {
|
|
4610
|
+
if (shouldRehost && resolvedMediaUrl && !resolvedMediaUrlIsTrustedR2) {
|
|
4137
4611
|
const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
|
|
4138
4612
|
if ("error" in rehost) {
|
|
4139
4613
|
return {
|
|
@@ -4147,10 +4621,13 @@ function registerDistributionTools(server) {
|
|
|
4147
4621
|
};
|
|
4148
4622
|
}
|
|
4149
4623
|
resolvedMediaUrl = rehost.signedUrl;
|
|
4624
|
+
resolvedMediaUrlIsTrustedR2 = true;
|
|
4150
4625
|
}
|
|
4151
4626
|
if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
|
|
4152
4627
|
const rehosted = await Promise.all(
|
|
4153
|
-
resolvedMediaUrls.map(
|
|
4628
|
+
resolvedMediaUrls.map(
|
|
4629
|
+
(u, index) => resolvedMediaUrlsAreTrustedR2[index] ? Promise.resolve({ signedUrl: u, r2Key: "" }) : rehostExternalUrl(u, project_id)
|
|
4630
|
+
)
|
|
4154
4631
|
);
|
|
4155
4632
|
const failIdx = rehosted.findIndex((r) => "error" in r);
|
|
4156
4633
|
if (failIdx !== -1) {
|
|
@@ -4166,6 +4643,7 @@ function registerDistributionTools(server) {
|
|
|
4166
4643
|
};
|
|
4167
4644
|
}
|
|
4168
4645
|
resolvedMediaUrls = rehosted.map((r) => r.signedUrl);
|
|
4646
|
+
resolvedMediaUrlsAreTrustedR2 = resolvedMediaUrls.map(() => true);
|
|
4169
4647
|
}
|
|
4170
4648
|
} catch (resolveErr) {
|
|
4171
4649
|
return {
|
|
@@ -4178,6 +4656,42 @@ function registerDistributionTools(server) {
|
|
|
4178
4656
|
isError: true
|
|
4179
4657
|
};
|
|
4180
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
|
+
}
|
|
4181
4695
|
const normalizedPlatforms = platforms.map(
|
|
4182
4696
|
(p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p
|
|
4183
4697
|
);
|
|
@@ -4303,12 +4817,32 @@ Created with Social Neuron`;
|
|
|
4303
4817
|
};
|
|
4304
4818
|
return true;
|
|
4305
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
|
+
}
|
|
4306
4840
|
const { data, error } = await callEdgeFunction(
|
|
4307
4841
|
"schedule-post",
|
|
4308
4842
|
{
|
|
4309
4843
|
mediaUrl: resolvedMediaUrl,
|
|
4310
4844
|
mediaUrls: resolvedMediaUrls,
|
|
4311
|
-
mediaType:
|
|
4845
|
+
mediaType: resolvedMediaType ?? void 0,
|
|
4312
4846
|
caption: finalCaption,
|
|
4313
4847
|
platforms: normalizedPlatforms,
|
|
4314
4848
|
title,
|
|
@@ -4321,14 +4855,10 @@ Created with Social Neuron`;
|
|
|
4321
4855
|
normalizedPlatformMetadata
|
|
4322
4856
|
)
|
|
4323
4857
|
} : {},
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
//
|
|
4328
|
-
// against the posts.origin CHECK constraint and persists hermesRunId when
|
|
4329
|
-
// origin === 'hermes'.
|
|
4330
|
-
...origin ? { origin } : {},
|
|
4331
|
-
...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.
|
|
4332
4862
|
},
|
|
4333
4863
|
{ timeoutMs: 3e4 }
|
|
4334
4864
|
);
|
|
@@ -4395,76 +4925,173 @@ Created with Social Neuron`;
|
|
|
4395
4925
|
}
|
|
4396
4926
|
);
|
|
4397
4927
|
server.tool(
|
|
4398
|
-
"
|
|
4399
|
-
"
|
|
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.",
|
|
4400
4930
|
{
|
|
4401
|
-
|
|
4402
|
-
|
|
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."
|
|
4403
4934
|
),
|
|
4404
|
-
|
|
4405
|
-
|
|
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")
|
|
4406
4940
|
},
|
|
4407
|
-
async ({
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
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()) {
|
|
4415
4950
|
return {
|
|
4416
4951
|
content: [
|
|
4417
4952
|
{
|
|
4418
4953
|
type: "text",
|
|
4419
|
-
text:
|
|
4954
|
+
text: "scheduled_at must be a valid future ISO datetime with timezone."
|
|
4420
4955
|
}
|
|
4421
4956
|
],
|
|
4422
4957
|
isError: true
|
|
4423
4958
|
};
|
|
4424
4959
|
}
|
|
4425
|
-
const
|
|
4426
|
-
if (
|
|
4427
|
-
if (format === "json") {
|
|
4428
|
-
return {
|
|
4429
|
-
content: [
|
|
4430
|
-
{
|
|
4431
|
-
type: "text",
|
|
4432
|
-
text: JSON.stringify(asEnvelope3({ accounts: [] }), null, 2)
|
|
4433
|
-
}
|
|
4434
|
-
]
|
|
4435
|
-
};
|
|
4436
|
-
}
|
|
4960
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
4961
|
+
if (!resolvedProjectId) {
|
|
4437
4962
|
return {
|
|
4438
4963
|
content: [
|
|
4439
4964
|
{
|
|
4440
4965
|
type: "text",
|
|
4441
|
-
text: "No
|
|
4966
|
+
text: "No project_id was provided and no default project is configured."
|
|
4442
4967
|
}
|
|
4443
|
-
]
|
|
4968
|
+
],
|
|
4969
|
+
isError: true
|
|
4444
4970
|
};
|
|
4445
4971
|
}
|
|
4446
|
-
const
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
];
|
|
4450
|
-
for (const account of accounts) {
|
|
4451
|
-
const name = account.username || "(unnamed)";
|
|
4452
|
-
const platformLower = account.platform.toLowerCase();
|
|
4453
|
-
const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
|
|
4454
|
-
const status = accountEffectiveStatus(account);
|
|
4455
|
-
lines.push(
|
|
4456
|
-
` ${platformLower}: ${name} | id=${account.id} | ${project} | status=${status} (connected ${account.created_at.split("T")[0]})`
|
|
4457
|
-
);
|
|
4458
|
-
}
|
|
4459
|
-
if (format === "json") {
|
|
4972
|
+
const userId = await getDefaultUserId();
|
|
4973
|
+
const rateLimit = checkRateLimit("posting", `reschedule_post:${userId}`);
|
|
4974
|
+
if (!rateLimit.allowed) {
|
|
4460
4975
|
return {
|
|
4461
4976
|
content: [
|
|
4462
4977
|
{
|
|
4463
4978
|
type: "text",
|
|
4464
|
-
text:
|
|
4979
|
+
text: `Rate limit exceeded. Retry in ~${rateLimit.retryAfter}s.`
|
|
4465
4980
|
}
|
|
4466
|
-
]
|
|
4467
|
-
|
|
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
|
+
);
|
|
5020
|
+
server.tool(
|
|
5021
|
+
"list_connected_accounts",
|
|
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.",
|
|
5023
|
+
{
|
|
5024
|
+
project_id: z3.string().optional().describe(
|
|
5025
|
+
"Brand/project ID to scope connected accounts. Use the same project_id when calling schedule_post."
|
|
5026
|
+
),
|
|
5027
|
+
include_all: z3.boolean().optional().describe("If true, include expired or inactive accounts as well as usable accounts."),
|
|
5028
|
+
response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
5029
|
+
},
|
|
5030
|
+
async ({ project_id, include_all, response_format }) => {
|
|
5031
|
+
const format = response_format ?? "text";
|
|
5032
|
+
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
5033
|
+
action: "connected-accounts",
|
|
5034
|
+
...project_id ? { projectId: project_id, project_id } : {},
|
|
5035
|
+
...include_all ? { includeAll: true } : {}
|
|
5036
|
+
});
|
|
5037
|
+
if (efError || !result?.success) {
|
|
5038
|
+
return {
|
|
5039
|
+
content: [
|
|
5040
|
+
{
|
|
5041
|
+
type: "text",
|
|
5042
|
+
text: `Failed to list connected accounts: ${efError || result?.error || "Unknown error"}`
|
|
5043
|
+
}
|
|
5044
|
+
],
|
|
5045
|
+
isError: true
|
|
5046
|
+
};
|
|
5047
|
+
}
|
|
5048
|
+
const accounts = (result.accounts ?? []).map(publicConnectedAccount).filter((account) => account !== null);
|
|
5049
|
+
if (accounts.length === 0) {
|
|
5050
|
+
if (format === "json") {
|
|
5051
|
+
const structuredContent = asEnvelope3({ accounts: [] });
|
|
5052
|
+
return {
|
|
5053
|
+
structuredContent,
|
|
5054
|
+
content: [
|
|
5055
|
+
{
|
|
5056
|
+
type: "text",
|
|
5057
|
+
text: JSON.stringify(structuredContent, null, 2)
|
|
5058
|
+
}
|
|
5059
|
+
]
|
|
5060
|
+
};
|
|
5061
|
+
}
|
|
5062
|
+
return {
|
|
5063
|
+
content: [
|
|
5064
|
+
{
|
|
5065
|
+
type: "text",
|
|
5066
|
+
text: "No connected social media accounts found. Connect platforms in Social Neuron Settings > Connections."
|
|
5067
|
+
}
|
|
5068
|
+
]
|
|
5069
|
+
};
|
|
5070
|
+
}
|
|
5071
|
+
const lines = [
|
|
5072
|
+
`${accounts.length} connected account(s)${project_id ? ` for project ${project_id}` : ""}:`,
|
|
5073
|
+
""
|
|
5074
|
+
];
|
|
5075
|
+
for (const account of accounts) {
|
|
5076
|
+
const name = account.username || "(unnamed)";
|
|
5077
|
+
const platformLower = account.platform.toLowerCase();
|
|
5078
|
+
const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
|
|
5079
|
+
const status = accountEffectiveStatus(account);
|
|
5080
|
+
lines.push(
|
|
5081
|
+
` ${platformLower}: ${name} | id=${account.id} | ${project} | status=${status} (connected ${account.created_at.split("T")[0]})`
|
|
5082
|
+
);
|
|
5083
|
+
}
|
|
5084
|
+
if (format === "json") {
|
|
5085
|
+
const structuredContent = asEnvelope3({ accounts });
|
|
5086
|
+
return {
|
|
5087
|
+
structuredContent,
|
|
5088
|
+
content: [
|
|
5089
|
+
{
|
|
5090
|
+
type: "text",
|
|
5091
|
+
text: JSON.stringify(structuredContent, null, 2)
|
|
5092
|
+
}
|
|
5093
|
+
]
|
|
5094
|
+
};
|
|
4468
5095
|
}
|
|
4469
5096
|
return {
|
|
4470
5097
|
content: [{ type: "text", text: lines.join("\n") }]
|
|
@@ -4475,6 +5102,9 @@ Created with Social Neuron`;
|
|
|
4475
5102
|
"list_recent_posts",
|
|
4476
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.",
|
|
4477
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
|
+
),
|
|
4478
5108
|
platform: z3.enum([
|
|
4479
5109
|
"youtube",
|
|
4480
5110
|
"tiktok",
|
|
@@ -4490,13 +5120,27 @@ Created with Social Neuron`;
|
|
|
4490
5120
|
limit: z3.number().min(1).max(50).optional().describe("Maximum number of posts to return. Defaults to 20."),
|
|
4491
5121
|
response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
4492
5122
|
},
|
|
4493
|
-
async ({ platform: platform2, status, days, limit, response_format }) => {
|
|
5123
|
+
async ({ project_id, platform: platform2, status, days, limit, response_format }) => {
|
|
4494
5124
|
const format = response_format ?? "text";
|
|
4495
5125
|
const lookbackDays = days ?? 7;
|
|
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
|
+
}
|
|
4496
5138
|
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
4497
5139
|
action: "recent-posts",
|
|
4498
5140
|
days: lookbackDays,
|
|
4499
5141
|
limit: limit ?? 20,
|
|
5142
|
+
projectId: resolvedProjectId,
|
|
5143
|
+
project_id: resolvedProjectId,
|
|
4500
5144
|
...platform2 ? { platform: platform2 } : {},
|
|
4501
5145
|
...status ? { status } : {}
|
|
4502
5146
|
});
|
|
@@ -4511,7 +5155,7 @@ Created with Social Neuron`;
|
|
|
4511
5155
|
isError: true
|
|
4512
5156
|
};
|
|
4513
5157
|
}
|
|
4514
|
-
const rows = result.posts ?? [];
|
|
5158
|
+
const rows = (result.posts ?? []).map(publicPostRecord).filter((post) => post !== null);
|
|
4515
5159
|
if (rows.length === 0) {
|
|
4516
5160
|
if (format === "json") {
|
|
4517
5161
|
const structuredContent2 = asEnvelope3({ posts: [] });
|
|
@@ -4586,8 +5230,11 @@ Created with Social Neuron`;
|
|
|
4586
5230
|
};
|
|
4587
5231
|
server.tool(
|
|
4588
5232
|
"find_next_slots",
|
|
4589
|
-
"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.",
|
|
4590
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
|
+
),
|
|
4591
5238
|
platforms: z3.array(
|
|
4592
5239
|
z3.enum([
|
|
4593
5240
|
"youtube",
|
|
@@ -4606,6 +5253,7 @@ Created with Social Neuron`;
|
|
|
4606
5253
|
response_format: z3.enum(["text", "json"]).default("text")
|
|
4607
5254
|
},
|
|
4608
5255
|
async ({
|
|
5256
|
+
project_id,
|
|
4609
5257
|
platforms,
|
|
4610
5258
|
count,
|
|
4611
5259
|
start_after,
|
|
@@ -4613,13 +5261,35 @@ Created with Social Neuron`;
|
|
|
4613
5261
|
response_format
|
|
4614
5262
|
}) => {
|
|
4615
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
|
+
}
|
|
4616
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
|
+
}
|
|
4617
5285
|
const endDate = new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1e3);
|
|
4618
5286
|
const { data: postsResult, error: postsError } = await callEdgeFunction("mcp-data", {
|
|
4619
5287
|
action: "scheduled-posts",
|
|
4620
5288
|
start_date: startDate.toISOString(),
|
|
4621
5289
|
end_date: endDate.toISOString(),
|
|
4622
|
-
statuses: ["scheduled", "draft"]
|
|
5290
|
+
statuses: ["pending", "scheduled", "draft"],
|
|
5291
|
+
projectId: resolvedProjectId,
|
|
5292
|
+
project_id: resolvedProjectId
|
|
4623
5293
|
});
|
|
4624
5294
|
const existingPosts = postsError ? [] : postsResult?.posts ?? [];
|
|
4625
5295
|
const gapMs = min_gap_hours * 60 * 60 * 1e3;
|
|
@@ -4659,19 +5329,17 @@ Created with Social Neuron`;
|
|
|
4659
5329
|
const slots = candidates.filter((s) => !s.conflict).sort((a, b) => b.engagement_score - a.engagement_score).slice(0, count);
|
|
4660
5330
|
const conflictsAvoided = candidates.filter((s) => s.conflict).length;
|
|
4661
5331
|
if (response_format === "json") {
|
|
5332
|
+
const structuredContent = asEnvelope3({
|
|
5333
|
+
slots,
|
|
5334
|
+
total_candidates: candidates.length,
|
|
5335
|
+
conflicts_avoided: conflictsAvoided
|
|
5336
|
+
});
|
|
4662
5337
|
return {
|
|
5338
|
+
structuredContent,
|
|
4663
5339
|
content: [
|
|
4664
5340
|
{
|
|
4665
5341
|
type: "text",
|
|
4666
|
-
text: JSON.stringify(
|
|
4667
|
-
asEnvelope3({
|
|
4668
|
-
slots,
|
|
4669
|
-
total_candidates: candidates.length,
|
|
4670
|
-
conflicts_avoided: conflictsAvoided
|
|
4671
|
-
}),
|
|
4672
|
-
null,
|
|
4673
|
-
2
|
|
4674
|
-
)
|
|
5342
|
+
text: JSON.stringify(structuredContent, null, 2)
|
|
4675
5343
|
}
|
|
4676
5344
|
],
|
|
4677
5345
|
isError: false
|
|
@@ -5036,7 +5704,7 @@ Created with Social Neuron`;
|
|
|
5036
5704
|
const results = [];
|
|
5037
5705
|
const buildIdempotencyKey = (post) => {
|
|
5038
5706
|
const planId = effectivePlanId ?? (typeof workingPlan.plan_id === "string" ? String(workingPlan.plan_id) : "inline");
|
|
5039
|
-
const captionHash =
|
|
5707
|
+
const captionHash = createHash("sha256").update(post.caption).digest("hex").slice(0, 16);
|
|
5040
5708
|
const raw = [
|
|
5041
5709
|
"schedule_content_plan",
|
|
5042
5710
|
planId,
|
|
@@ -5046,7 +5714,7 @@ Created with Social Neuron`;
|
|
|
5046
5714
|
captionHash,
|
|
5047
5715
|
idempotency_seed ?? ""
|
|
5048
5716
|
].join(":");
|
|
5049
|
-
return `plan-${
|
|
5717
|
+
return `plan-${createHash("sha256").update(raw).digest("hex").slice(0, 48)}`;
|
|
5050
5718
|
};
|
|
5051
5719
|
const scheduleOne = async (post) => {
|
|
5052
5720
|
if (!post.schedule_at) {
|
|
@@ -5525,7 +6193,10 @@ function registerMediaTools(server) {
|
|
|
5525
6193
|
content: [
|
|
5526
6194
|
{
|
|
5527
6195
|
type: "text",
|
|
5528
|
-
|
|
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.`
|
|
5529
6200
|
}
|
|
5530
6201
|
],
|
|
5531
6202
|
isError: true
|
|
@@ -5713,7 +6384,7 @@ function asEnvelope4(data) {
|
|
|
5713
6384
|
function registerAnalyticsTools(server) {
|
|
5714
6385
|
server.tool(
|
|
5715
6386
|
"fetch_analytics",
|
|
5716
|
-
"Get post performance metrics \u2014 views, likes, comments, shares, and engagement rate. Filter by platform, time range (default 30 days), or specific content_id. Call refresh_platform_analytics first if data seems stale. Results sorted by most recent capture.",
|
|
6387
|
+
"Get project-scoped post performance metrics \u2014 views, likes, comments, shares, and engagement rate. Filter by platform, time range (default 30 days), or specific content_id. Call refresh_platform_analytics first if data seems stale. Results sorted by most recent capture.",
|
|
5717
6388
|
{
|
|
5718
6389
|
platform: z5.enum([
|
|
5719
6390
|
"youtube",
|
|
@@ -5731,19 +6402,26 @@ function registerAnalyticsTools(server) {
|
|
|
5731
6402
|
content_id: z5.string().uuid().optional().describe(
|
|
5732
6403
|
"Filter to a specific content_history ID to see performance of one piece of content."
|
|
5733
6404
|
),
|
|
6405
|
+
project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
|
|
5734
6406
|
limit: z5.number().min(1).max(100).optional().describe("Maximum number of posts to return. Defaults to 20."),
|
|
5735
6407
|
response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
5736
6408
|
},
|
|
5737
|
-
async ({ platform: platform2, days, content_id, limit, response_format }) => {
|
|
6409
|
+
async ({ platform: platform2, days, content_id, project_id, limit, response_format }) => {
|
|
5738
6410
|
const format = response_format ?? "text";
|
|
5739
6411
|
const lookbackDays = days ?? 30;
|
|
5740
6412
|
const maxPosts = limit ?? 20;
|
|
6413
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
5741
6414
|
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
5742
6415
|
action: "analytics",
|
|
5743
6416
|
platform: platform2,
|
|
5744
6417
|
days: lookbackDays,
|
|
5745
|
-
|
|
5746
|
-
|
|
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,
|
|
6423
|
+
contentId: content_id,
|
|
6424
|
+
...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
|
|
5747
6425
|
});
|
|
5748
6426
|
if (efError) {
|
|
5749
6427
|
return {
|
|
@@ -5751,7 +6429,14 @@ function registerAnalyticsTools(server) {
|
|
|
5751
6429
|
isError: true
|
|
5752
6430
|
};
|
|
5753
6431
|
}
|
|
5754
|
-
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);
|
|
5755
6440
|
if (rows.length === 0) {
|
|
5756
6441
|
if (format === "json") {
|
|
5757
6442
|
const structuredContent = asEnvelope4({
|
|
@@ -5813,14 +6498,19 @@ function registerAnalyticsTools(server) {
|
|
|
5813
6498
|
);
|
|
5814
6499
|
server.tool(
|
|
5815
6500
|
"refresh_platform_analytics",
|
|
5816
|
-
"Queue analytics refresh jobs for
|
|
6501
|
+
"Queue analytics refresh jobs for posts from the last 7 days in one project across its connected platforms. Call this before fetch_analytics if you need fresh data. Returns immediately \u2014 data updates asynchronously over the next 1-5 minutes.",
|
|
5817
6502
|
{
|
|
6503
|
+
project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
|
|
5818
6504
|
response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
5819
6505
|
},
|
|
5820
|
-
async ({ response_format }) => {
|
|
6506
|
+
async ({ project_id, response_format }) => {
|
|
5821
6507
|
const format = response_format ?? "text";
|
|
5822
6508
|
const userId = await getDefaultUserId();
|
|
5823
|
-
const
|
|
6509
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
6510
|
+
const rateLimit = checkRateLimit(
|
|
6511
|
+
"posting",
|
|
6512
|
+
`refresh_platform_analytics:${userId}:${resolvedProjectId ?? "default"}`
|
|
6513
|
+
);
|
|
5824
6514
|
if (!rateLimit.allowed) {
|
|
5825
6515
|
return {
|
|
5826
6516
|
content: [
|
|
@@ -5832,7 +6522,10 @@ function registerAnalyticsTools(server) {
|
|
|
5832
6522
|
isError: true
|
|
5833
6523
|
};
|
|
5834
6524
|
}
|
|
5835
|
-
const { data, error } = await callEdgeFunction("fetch-analytics", {
|
|
6525
|
+
const { data, error } = await callEdgeFunction("fetch-analytics", {
|
|
6526
|
+
userId,
|
|
6527
|
+
...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
|
|
6528
|
+
});
|
|
5836
6529
|
if (error) {
|
|
5837
6530
|
return {
|
|
5838
6531
|
content: [{ type: "text", text: `Error refreshing analytics: ${error}` }],
|
|
@@ -5861,7 +6554,8 @@ function registerAnalyticsTools(server) {
|
|
|
5861
6554
|
success: true,
|
|
5862
6555
|
postsProcessed: result.postsProcessed,
|
|
5863
6556
|
queued,
|
|
5864
|
-
errored
|
|
6557
|
+
errored,
|
|
6558
|
+
projectId: resolvedProjectId ?? null
|
|
5865
6559
|
});
|
|
5866
6560
|
return {
|
|
5867
6561
|
structuredContent,
|
|
@@ -5915,6 +6609,80 @@ function formatAnalytics(summary, days, format) {
|
|
|
5915
6609
|
// src/tools/brand.ts
|
|
5916
6610
|
import { z as z6 } from "zod";
|
|
5917
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
|
|
5918
6686
|
function asEnvelope5(data) {
|
|
5919
6687
|
return {
|
|
5920
6688
|
_meta: {
|
|
@@ -5929,12 +6697,31 @@ function registerBrandTools(server) {
|
|
|
5929
6697
|
"extract_brand",
|
|
5930
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.",
|
|
5931
6699
|
{
|
|
5932
|
-
url: z6.string().
|
|
5933
|
-
'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").'
|
|
5934
6702
|
),
|
|
5935
6703
|
response_format: z6.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
5936
6704
|
},
|
|
5937
|
-
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;
|
|
5938
6725
|
const ssrfCheck = await validateUrlForSSRF(url);
|
|
5939
6726
|
if (!ssrfCheck.isValid) {
|
|
5940
6727
|
return {
|
|
@@ -6718,7 +7505,7 @@ function registerRemotionTools(server) {
|
|
|
6718
7505
|
},
|
|
6719
7506
|
async ({ composition_id, output_format, props }) => {
|
|
6720
7507
|
const userId = await getDefaultUserId();
|
|
6721
|
-
const rateLimit = checkRateLimit("
|
|
7508
|
+
const rateLimit = checkRateLimit("generation", `render_demo_video:${userId}`);
|
|
6722
7509
|
if (!rateLimit.allowed) {
|
|
6723
7510
|
return {
|
|
6724
7511
|
content: [
|
|
@@ -6913,6 +7700,7 @@ function registerRemotionTools(server) {
|
|
|
6913
7700
|
|
|
6914
7701
|
// src/tools/insights.ts
|
|
6915
7702
|
import { z as z9 } from "zod";
|
|
7703
|
+
init_supabase();
|
|
6916
7704
|
var MAX_INSIGHT_AGE_DAYS = 30;
|
|
6917
7705
|
var PLATFORM_ENUM = [
|
|
6918
7706
|
"youtube",
|
|
@@ -6937,22 +7725,25 @@ function asEnvelope6(data) {
|
|
|
6937
7725
|
function registerInsightsTools(server) {
|
|
6938
7726
|
server.tool(
|
|
6939
7727
|
"get_performance_insights",
|
|
6940
|
-
"Query performance insights derived from post analytics. Returns metrics like engagement rate, view velocity, and click rate aggregated over time. Use this to understand what content is performing well.",
|
|
7728
|
+
"Query project-scoped performance insights derived from post analytics. Returns metrics like engagement rate, view velocity, and click rate aggregated over time. Use this to understand what content is performing well.",
|
|
6941
7729
|
{
|
|
6942
7730
|
insight_type: z9.enum(["top_hooks", "optimal_timing", "best_models", "competitor_patterns"]).optional().describe("Filter to a specific insight type."),
|
|
6943
7731
|
days: z9.number().min(1).max(90).optional().describe("Number of days to look back. Defaults to 30. Max 90."),
|
|
6944
7732
|
limit: z9.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
|
|
7733
|
+
project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
|
|
6945
7734
|
response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
6946
7735
|
},
|
|
6947
|
-
async ({ insight_type, days, limit, response_format }) => {
|
|
7736
|
+
async ({ insight_type, days, limit, project_id, response_format }) => {
|
|
6948
7737
|
const format = response_format ?? "text";
|
|
6949
7738
|
const lookbackDays = days ?? 30;
|
|
6950
7739
|
const maxRows = limit ?? 10;
|
|
6951
7740
|
const effectiveDays = Math.min(lookbackDays, MAX_INSIGHT_AGE_DAYS);
|
|
7741
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
6952
7742
|
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
6953
7743
|
action: "performance-insights",
|
|
6954
7744
|
days: effectiveDays,
|
|
6955
|
-
limit: maxRows
|
|
7745
|
+
limit: maxRows,
|
|
7746
|
+
...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
|
|
6956
7747
|
});
|
|
6957
7748
|
if (efError || !result?.success) {
|
|
6958
7749
|
return {
|
|
@@ -7039,19 +7830,22 @@ function registerInsightsTools(server) {
|
|
|
7039
7830
|
);
|
|
7040
7831
|
server.tool(
|
|
7041
7832
|
"get_best_posting_times",
|
|
7042
|
-
"Analyze post analytics data to find the best times to post for maximum engagement. Returns the top 5 time slots (day of week + hour) ranked by average engagement.",
|
|
7833
|
+
"Analyze project-scoped post analytics data to find the best times to post for maximum engagement. Returns the top 5 time slots (day of week + hour) ranked by average engagement.",
|
|
7043
7834
|
{
|
|
7044
7835
|
platform: z9.enum(PLATFORM_ENUM).optional().describe("Filter to a specific platform."),
|
|
7045
7836
|
days: z9.number().min(1).max(90).optional().describe("Number of days to analyze. Defaults to 30. Max 90."),
|
|
7837
|
+
project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
|
|
7046
7838
|
response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
|
|
7047
7839
|
},
|
|
7048
|
-
async ({ platform: platform2, days, response_format }) => {
|
|
7840
|
+
async ({ platform: platform2, days, project_id, response_format }) => {
|
|
7049
7841
|
const format = response_format ?? "text";
|
|
7050
7842
|
const lookbackDays = days ?? 30;
|
|
7843
|
+
const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
|
|
7051
7844
|
const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
|
|
7052
7845
|
action: "best-posting-times",
|
|
7053
7846
|
days: lookbackDays,
|
|
7054
|
-
platform: platform2 ?? void 0
|
|
7847
|
+
platform: platform2 ?? void 0,
|
|
7848
|
+
...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
|
|
7055
7849
|
});
|
|
7056
7850
|
if (efError || !result?.success) {
|
|
7057
7851
|
return {
|
|
@@ -8144,7 +8938,7 @@ Schedule: ${JSON.stringify(updated.schedule_config)}`
|
|
|
8144
8938
|
}
|
|
8145
8939
|
const statusData = {
|
|
8146
8940
|
activeConfigs: result?.activeConfigs ?? 0,
|
|
8147
|
-
recentRuns: [],
|
|
8941
|
+
recentRuns: result?.recentRuns ?? result?.recent_runs ?? [],
|
|
8148
8942
|
pendingApprovals: result?.pendingApprovals ?? 0
|
|
8149
8943
|
};
|
|
8150
8944
|
if (format === "json") {
|
|
@@ -8166,8 +8960,20 @@ ${"=".repeat(40)}
|
|
|
8166
8960
|
text += `Pending Approvals: ${statusData.pendingApprovals}
|
|
8167
8961
|
|
|
8168
8962
|
`;
|
|
8169
|
-
|
|
8963
|
+
if (statusData.recentRuns.length === 0) {
|
|
8964
|
+
text += `No recent runs.
|
|
8170
8965
|
`;
|
|
8966
|
+
} else {
|
|
8967
|
+
text += `Recent Runs (${statusData.recentRuns.length}):
|
|
8968
|
+
`;
|
|
8969
|
+
for (const run of statusData.recentRuns.slice(0, 10)) {
|
|
8970
|
+
const id = String(run.id ?? run.run_id ?? "unknown");
|
|
8971
|
+
const status = String(run.status ?? "unknown");
|
|
8972
|
+
const credits = run.credits_used ?? run.creditsUsed;
|
|
8973
|
+
text += ` ${id}: ${status}${credits == null ? "" : ` (${String(credits)} credits)`}
|
|
8974
|
+
`;
|
|
8975
|
+
}
|
|
8976
|
+
}
|
|
8171
8977
|
return {
|
|
8172
8978
|
content: [{ type: "text", text }]
|
|
8173
8979
|
};
|
|
@@ -10348,7 +11154,9 @@ function toolKnowledgeDocument(tool) {
|
|
|
10348
11154
|
function getKnowledgeDocuments() {
|
|
10349
11155
|
return [
|
|
10350
11156
|
...STATIC_KNOWLEDGE_DOCUMENTS,
|
|
10351
|
-
...TOOL_CATALOG.filter((t) => !t.internal).map(
|
|
11157
|
+
...TOOL_CATALOG.filter((t) => !t.internal && !t.hiddenFromPublicCount).map(
|
|
11158
|
+
toolKnowledgeDocument
|
|
11159
|
+
)
|
|
10352
11160
|
];
|
|
10353
11161
|
}
|
|
10354
11162
|
function tokenize(input) {
|
|
@@ -10465,7 +11273,7 @@ function registerDiscoveryTools(server) {
|
|
|
10465
11273
|
if (query) {
|
|
10466
11274
|
results = searchTools(query);
|
|
10467
11275
|
}
|
|
10468
|
-
results = results.filter((t) => !t.internal);
|
|
11276
|
+
results = results.filter((t) => !t.internal && !t.hiddenFromPublicCount);
|
|
10469
11277
|
if (module) {
|
|
10470
11278
|
const moduleTools = getToolsByModule(module);
|
|
10471
11279
|
results = results.filter((t) => moduleTools.some((mt) => mt.name === t.name));
|
|
@@ -12287,11 +13095,11 @@ function hexToLab(hex) {
|
|
|
12287
13095
|
const lb = srgbToLinear(b);
|
|
12288
13096
|
const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
|
|
12289
13097
|
const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
|
|
12290
|
-
const
|
|
13098
|
+
const z41 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
|
|
12291
13099
|
const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
12292
13100
|
const fx = f(x / 0.95047);
|
|
12293
13101
|
const fy = f(y / 1);
|
|
12294
|
-
const fz = f(
|
|
13102
|
+
const fz = f(z41 / 1.08883);
|
|
12295
13103
|
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
|
|
12296
13104
|
}
|
|
12297
13105
|
function deltaE2000(lab1, lab2) {
|
|
@@ -12524,10 +13332,10 @@ function registerBrandRuntimeTools(server) {
|
|
|
12524
13332
|
pagesScraped: meta.pagesScraped || 0
|
|
12525
13333
|
}
|
|
12526
13334
|
};
|
|
12527
|
-
const
|
|
13335
|
+
const envelope2 = asEnvelope23(runtime);
|
|
12528
13336
|
return {
|
|
12529
|
-
structuredContent:
|
|
12530
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13337
|
+
structuredContent: envelope2,
|
|
13338
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12531
13339
|
};
|
|
12532
13340
|
}
|
|
12533
13341
|
);
|
|
@@ -12667,9 +13475,9 @@ function registerBrandRuntimeTools(server) {
|
|
|
12667
13475
|
}
|
|
12668
13476
|
const profile = row.profile_data;
|
|
12669
13477
|
const checkResult = computeBrandConsistency(content, profile);
|
|
12670
|
-
const
|
|
13478
|
+
const envelope2 = asEnvelope23(checkResult);
|
|
12671
13479
|
return {
|
|
12672
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13480
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12673
13481
|
};
|
|
12674
13482
|
}
|
|
12675
13483
|
);
|
|
@@ -12701,9 +13509,9 @@ function registerBrandRuntimeTools(server) {
|
|
|
12701
13509
|
content_colors,
|
|
12702
13510
|
threshold ?? 10
|
|
12703
13511
|
);
|
|
12704
|
-
const
|
|
13512
|
+
const envelope2 = asEnvelope23(auditResult);
|
|
12705
13513
|
return {
|
|
12706
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13514
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12707
13515
|
};
|
|
12708
13516
|
}
|
|
12709
13517
|
);
|
|
@@ -12736,9 +13544,9 @@ function registerBrandRuntimeTools(server) {
|
|
|
12736
13544
|
row.profile_data.typography,
|
|
12737
13545
|
format
|
|
12738
13546
|
);
|
|
12739
|
-
const
|
|
13547
|
+
const envelope2 = asEnvelope23({ format, tokens: output });
|
|
12740
13548
|
return {
|
|
12741
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
13549
|
+
content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
|
|
12742
13550
|
};
|
|
12743
13551
|
}
|
|
12744
13552
|
);
|
|
@@ -12894,7 +13702,7 @@ function registerCarouselTools(server) {
|
|
|
12894
13702
|
};
|
|
12895
13703
|
}
|
|
12896
13704
|
const userId = await getDefaultUserId();
|
|
12897
|
-
const rateLimit = checkRateLimit("
|
|
13705
|
+
const rateLimit = checkRateLimit("generation", `create_carousel:${userId}`);
|
|
12898
13706
|
if (!rateLimit.allowed) {
|
|
12899
13707
|
return {
|
|
12900
13708
|
content: [
|
|
@@ -12966,7 +13774,12 @@ function registerCarouselTools(server) {
|
|
|
12966
13774
|
slideNumber: slide.slideNumber,
|
|
12967
13775
|
jobId: null,
|
|
12968
13776
|
model: image_model,
|
|
12969
|
-
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
|
|
12970
13783
|
};
|
|
12971
13784
|
}
|
|
12972
13785
|
const jobId = data.asyncJobId ?? data.taskId ?? null;
|
|
@@ -12978,14 +13791,24 @@ function registerCarouselTools(server) {
|
|
|
12978
13791
|
slideNumber: slide.slideNumber,
|
|
12979
13792
|
jobId,
|
|
12980
13793
|
model: image_model,
|
|
12981
|
-
error: null
|
|
13794
|
+
error: null,
|
|
13795
|
+
creditsReserved: 0,
|
|
13796
|
+
creditsCharged: data.creditsDeducted ?? perImageCost,
|
|
13797
|
+
creditsRefunded: 0,
|
|
13798
|
+
billingStatus: "charged",
|
|
13799
|
+
failureReason: null
|
|
12982
13800
|
};
|
|
12983
13801
|
} catch (err) {
|
|
12984
13802
|
return {
|
|
12985
13803
|
slideNumber: slide.slideNumber,
|
|
12986
13804
|
jobId: null,
|
|
12987
13805
|
model: image_model,
|
|
12988
|
-
error: sanitizeError(err)
|
|
13806
|
+
error: sanitizeError(err),
|
|
13807
|
+
creditsReserved: null,
|
|
13808
|
+
creditsCharged: null,
|
|
13809
|
+
creditsRefunded: null,
|
|
13810
|
+
billingStatus: "unknown",
|
|
13811
|
+
failureReason: null
|
|
12989
13812
|
};
|
|
12990
13813
|
}
|
|
12991
13814
|
})
|
|
@@ -13010,7 +13833,8 @@ function registerCarouselTools(server) {
|
|
|
13010
13833
|
return {
|
|
13011
13834
|
...s,
|
|
13012
13835
|
imageJobId: job?.jobId ?? null,
|
|
13013
|
-
imageError: job?.error ?? null
|
|
13836
|
+
imageError: job?.error ?? null,
|
|
13837
|
+
imageBillingStatus: job?.billingStatus ?? "unknown"
|
|
13014
13838
|
};
|
|
13015
13839
|
}),
|
|
13016
13840
|
imageModel: image_model,
|
|
@@ -13022,11 +13846,19 @@ function registerCarouselTools(server) {
|
|
|
13022
13846
|
jobIds: successfulJobs.map((j) => j.jobId),
|
|
13023
13847
|
failedSlides: failedJobs.map((j) => ({
|
|
13024
13848
|
slideNumber: j.slideNumber,
|
|
13025
|
-
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
|
|
13026
13855
|
})),
|
|
13027
13856
|
credits: {
|
|
13028
13857
|
textGeneration: textCredits,
|
|
13029
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,
|
|
13030
13862
|
totalEstimated: textCredits + successfulJobs.length * perImageCost
|
|
13031
13863
|
}
|
|
13032
13864
|
}
|
|
@@ -13297,7 +14129,7 @@ function registerHyperframesTools(server) {
|
|
|
13297
14129
|
);
|
|
13298
14130
|
server.tool(
|
|
13299
14131
|
"render_hyperframes",
|
|
13300
|
-
"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.",
|
|
13301
14133
|
{
|
|
13302
14134
|
composition_html: z30.string().max(5e5).optional().describe(
|
|
13303
14135
|
"Inline HTML composition (full <html>...</html>). Max 500KB. Use composition_url for larger."
|
|
@@ -13309,7 +14141,9 @@ function registerHyperframesTools(server) {
|
|
|
13309
14141
|
aspect_ratio: z30.enum(["9:16", "16:9", "1:1"]).optional().describe('Output aspect ratio. Default "9:16".'),
|
|
13310
14142
|
duration_sec: z30.number().min(0.1).max(600).describe("Output duration in seconds. Required. Max 600 (10 min)."),
|
|
13311
14143
|
fps: z30.union([z30.literal(24), z30.literal(30), z30.literal(60)]).optional().describe("Frames per second. Default 30."),
|
|
13312
|
-
quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master.")
|
|
14144
|
+
quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master."),
|
|
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.")
|
|
13313
14147
|
},
|
|
13314
14148
|
async ({
|
|
13315
14149
|
composition_html,
|
|
@@ -13318,7 +14152,9 @@ function registerHyperframesTools(server) {
|
|
|
13318
14152
|
aspect_ratio,
|
|
13319
14153
|
duration_sec,
|
|
13320
14154
|
fps,
|
|
13321
|
-
quality
|
|
14155
|
+
quality,
|
|
14156
|
+
project_id,
|
|
14157
|
+
response_format
|
|
13322
14158
|
}) => {
|
|
13323
14159
|
const userId = await getDefaultUserId();
|
|
13324
14160
|
const rateLimit = checkRateLimit("generation", `render_hyperframes:${userId}`);
|
|
@@ -13363,23 +14199,36 @@ function registerHyperframesTools(server) {
|
|
|
13363
14199
|
aspectRatio: aspect_ratio || "9:16",
|
|
13364
14200
|
durationSec: duration_sec,
|
|
13365
14201
|
fps: fps || 30,
|
|
13366
|
-
quality: quality || "standard"
|
|
14202
|
+
quality: quality || "standard",
|
|
14203
|
+
...project_id && { projectId: project_id }
|
|
13367
14204
|
});
|
|
13368
14205
|
if (error || !data?.jobId) {
|
|
13369
14206
|
throw new Error(error || data?.error || "Failed to create Hyperframes render job");
|
|
13370
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
|
+
};
|
|
13371
14220
|
return {
|
|
13372
14221
|
content: [
|
|
13373
14222
|
{
|
|
13374
14223
|
type: "text",
|
|
13375
|
-
text: [
|
|
14224
|
+
text: response_format === "json" ? JSON.stringify({ data: payload }) : [
|
|
13376
14225
|
`Hyperframes render job queued.`,
|
|
13377
14226
|
` Job ID: ${data.jobId}`,
|
|
13378
14227
|
` Credits: ${data.creditsCost}`,
|
|
13379
14228
|
` Duration: ${duration_sec}s @ ${fps || 30}fps (${aspect_ratio || "9:16"})`,
|
|
13380
14229
|
` Quality: ${quality || "standard"}`,
|
|
13381
14230
|
``,
|
|
13382
|
-
`Poll with check_status
|
|
14231
|
+
`Poll with check_status.`
|
|
13383
14232
|
].join("\n")
|
|
13384
14233
|
}
|
|
13385
14234
|
]
|
|
@@ -13389,7 +14238,7 @@ function registerHyperframesTools(server) {
|
|
|
13389
14238
|
content: [
|
|
13390
14239
|
{
|
|
13391
14240
|
type: "text",
|
|
13392
|
-
text: `Failed to queue Hyperframes render: ${err
|
|
14241
|
+
text: `Failed to queue Hyperframes render: ${sanitizeError(err)}`
|
|
13393
14242
|
}
|
|
13394
14243
|
],
|
|
13395
14244
|
isError: true
|
|
@@ -13408,7 +14257,17 @@ import {
|
|
|
13408
14257
|
import { z as z31 } from "zod";
|
|
13409
14258
|
import fs from "node:fs/promises";
|
|
13410
14259
|
import path from "node:path";
|
|
13411
|
-
|
|
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
|
+
};
|
|
13412
14271
|
var RecentPostOutputSchema = z31.object({
|
|
13413
14272
|
id: z31.string(),
|
|
13414
14273
|
platform: z31.string(),
|
|
@@ -13421,47 +14280,119 @@ var RecentPostOutputSchema = z31.object({
|
|
|
13421
14280
|
});
|
|
13422
14281
|
function startOfCurrentWeekMonday() {
|
|
13423
14282
|
const now = /* @__PURE__ */ new Date();
|
|
13424
|
-
const day = now.
|
|
14283
|
+
const day = now.getUTCDay();
|
|
13425
14284
|
const monday = new Date(now);
|
|
13426
|
-
monday.setUTCDate(now.getUTCDate() - day +
|
|
14285
|
+
monday.setUTCDate(now.getUTCDate() - (day + 6) % 7);
|
|
14286
|
+
monday.setUTCHours(0, 0, 0, 0);
|
|
13427
14287
|
return monday.toISOString().split("T")[0];
|
|
13428
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
|
+
}
|
|
13429
14339
|
function registerContentCalendarApp(server) {
|
|
13430
14340
|
registerAppTool(
|
|
13431
14341
|
server,
|
|
13432
14342
|
"open_content_calendar",
|
|
13433
14343
|
{
|
|
13434
14344
|
title: "Content Calendar",
|
|
13435
|
-
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.",
|
|
13436
14346
|
inputSchema: {
|
|
13437
|
-
|
|
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(
|
|
13438
14351
|
"ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday."
|
|
13439
14352
|
)
|
|
13440
14353
|
},
|
|
13441
14354
|
outputSchema: {
|
|
13442
14355
|
start_date: z31.string(),
|
|
14356
|
+
project_id: z31.string(),
|
|
13443
14357
|
posts: z31.array(RecentPostOutputSchema),
|
|
13444
14358
|
scopes: z31.array(z31.string())
|
|
13445
14359
|
},
|
|
13446
14360
|
_meta: {
|
|
13447
14361
|
ui: {
|
|
13448
|
-
resourceUri: CALENDAR_URI
|
|
13449
|
-
csp: {
|
|
13450
|
-
"img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
|
|
13451
|
-
"connect-src": ["'self'"]
|
|
13452
|
-
}
|
|
14362
|
+
resourceUri: CALENDAR_URI
|
|
13453
14363
|
}
|
|
13454
14364
|
}
|
|
13455
14365
|
},
|
|
13456
|
-
async ({ start_date }
|
|
13457
|
-
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
|
+
}
|
|
13458
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
|
+
}
|
|
13459
14387
|
const { data: result, error } = await callEdgeFunction(
|
|
13460
14388
|
"mcp-data",
|
|
13461
14389
|
{
|
|
13462
|
-
action: "
|
|
13463
|
-
|
|
13464
|
-
|
|
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
|
|
13465
14396
|
},
|
|
13466
14397
|
{ timeoutMs: 15e3 }
|
|
13467
14398
|
);
|
|
@@ -13470,19 +14401,16 @@ function registerContentCalendarApp(server) {
|
|
|
13470
14401
|
content: [
|
|
13471
14402
|
{
|
|
13472
14403
|
type: "text",
|
|
13473
|
-
text:
|
|
14404
|
+
text: "The content calendar could not load posts. Please retry."
|
|
13474
14405
|
}
|
|
13475
14406
|
],
|
|
13476
14407
|
isError: true
|
|
13477
14408
|
};
|
|
13478
14409
|
}
|
|
13479
|
-
const posts = (result.posts ?? []).filter((
|
|
13480
|
-
const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
|
|
13481
|
-
if (!ts) return false;
|
|
13482
|
-
return ts.split("T")[0] >= fromDate;
|
|
13483
|
-
});
|
|
14410
|
+
const posts = (result.posts ?? []).map(publicRecentPost).filter((post) => post !== null);
|
|
13484
14411
|
const structuredContent = {
|
|
13485
14412
|
start_date: fromDate,
|
|
14413
|
+
project_id: resolvedProjectId,
|
|
13486
14414
|
posts,
|
|
13487
14415
|
scopes: userScopes
|
|
13488
14416
|
};
|
|
@@ -13501,36 +14429,64 @@ function registerContentCalendarApp(server) {
|
|
|
13501
14429
|
server,
|
|
13502
14430
|
CALENDAR_URI,
|
|
13503
14431
|
CALENDAR_URI,
|
|
13504
|
-
{
|
|
14432
|
+
{
|
|
14433
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
14434
|
+
description: "Self-contained Social Neuron project content calendar.",
|
|
14435
|
+
_meta: { ui: { csp: CALENDAR_CSP } }
|
|
14436
|
+
},
|
|
13505
14437
|
async () => {
|
|
13506
|
-
const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
|
|
13507
14438
|
try {
|
|
13508
|
-
const html = await
|
|
14439
|
+
const html = await readCalendarHtml();
|
|
13509
14440
|
return {
|
|
13510
|
-
contents: [
|
|
14441
|
+
contents: [
|
|
14442
|
+
{
|
|
14443
|
+
uri: CALENDAR_URI,
|
|
14444
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
14445
|
+
text: html,
|
|
14446
|
+
_meta: { ui: { csp: CALENDAR_CSP } }
|
|
14447
|
+
}
|
|
14448
|
+
]
|
|
13511
14449
|
};
|
|
13512
|
-
} catch
|
|
14450
|
+
} catch {
|
|
13513
14451
|
const errorHtml = `<!DOCTYPE html>
|
|
13514
14452
|
<html><head><title>Content Calendar \u2014 unavailable</title></head>
|
|
13515
14453
|
<body style="font-family:sans-serif;padding:24px;color:#444;">
|
|
13516
14454
|
<h2>Content Calendar app bundle missing</h2>
|
|
13517
|
-
<p>The
|
|
13518
|
-
<code>apps/content-calendar/dist/mcp-app.html</code> is not built.
|
|
13519
|
-
Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
|
|
13520
|
-
<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>
|
|
13521
14456
|
</body></html>`;
|
|
13522
14457
|
return {
|
|
13523
|
-
contents: [
|
|
14458
|
+
contents: [
|
|
14459
|
+
{
|
|
14460
|
+
uri: CALENDAR_URI,
|
|
14461
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
14462
|
+
text: errorHtml,
|
|
14463
|
+
_meta: { ui: { csp: CALENDAR_CSP } }
|
|
14464
|
+
}
|
|
14465
|
+
]
|
|
13524
14466
|
};
|
|
13525
14467
|
}
|
|
13526
14468
|
}
|
|
13527
14469
|
);
|
|
13528
14470
|
}
|
|
13529
14471
|
|
|
13530
|
-
// 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";
|
|
13531
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";
|
|
13532
14482
|
init_supabase();
|
|
13533
|
-
var
|
|
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 = [
|
|
13534
14490
|
"youtube",
|
|
13535
14491
|
"tiktok",
|
|
13536
14492
|
"instagram",
|
|
@@ -13538,34 +14494,262 @@ var PLATFORM_ENUM4 = [
|
|
|
13538
14494
|
"linkedin",
|
|
13539
14495
|
"facebook",
|
|
13540
14496
|
"threads",
|
|
13541
|
-
"bluesky"
|
|
13542
|
-
"shopify",
|
|
13543
|
-
"etsy"
|
|
14497
|
+
"bluesky"
|
|
13544
14498
|
];
|
|
13545
|
-
|
|
13546
|
-
|
|
13547
|
-
|
|
13548
|
-
|
|
13549
|
-
|
|
13550
|
-
|
|
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
|
+
};
|
|
13551
14537
|
}
|
|
13552
|
-
|
|
13553
|
-
|
|
13554
|
-
|
|
13555
|
-
|
|
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";
|
|
14716
|
+
init_supabase();
|
|
14717
|
+
var PLATFORM_ENUM4 = [
|
|
14718
|
+
"youtube",
|
|
14719
|
+
"tiktok",
|
|
14720
|
+
"instagram",
|
|
14721
|
+
"twitter",
|
|
14722
|
+
"linkedin",
|
|
14723
|
+
"facebook",
|
|
14724
|
+
"threads",
|
|
14725
|
+
"bluesky",
|
|
14726
|
+
"shopify",
|
|
14727
|
+
"etsy"
|
|
14728
|
+
];
|
|
14729
|
+
function findActiveAccount(accounts, platform2) {
|
|
14730
|
+
const target = platform2.toLowerCase();
|
|
14731
|
+
return accounts.find((a) => {
|
|
14732
|
+
const effectiveStatus = a.effective_status || a.status;
|
|
14733
|
+
return a.platform.toLowerCase() === target && (effectiveStatus === "active" || effectiveStatus === "expires_soon");
|
|
14734
|
+
}) ?? null;
|
|
14735
|
+
}
|
|
14736
|
+
var MAX_CONCURRENT_WAITS_PER_USER = 3;
|
|
14737
|
+
var inFlightWaitsByUser = /* @__PURE__ */ new Map();
|
|
14738
|
+
function registerConnectionTools(server) {
|
|
14739
|
+
server.tool(
|
|
13556
14740
|
"start_platform_connection",
|
|
13557
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.",
|
|
13558
14742
|
{
|
|
13559
|
-
platform:
|
|
13560
|
-
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(
|
|
13561
14745
|
"Brand/project ID to bind the new social account to. Use this when connecting a brand-specific account."
|
|
13562
14746
|
),
|
|
13563
|
-
response_format:
|
|
14747
|
+
response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
|
|
13564
14748
|
},
|
|
13565
14749
|
async ({ platform: platform2, project_id, response_format }) => {
|
|
13566
14750
|
const format = response_format ?? "text";
|
|
13567
14751
|
const userId = await getDefaultUserId();
|
|
13568
|
-
const rl = checkRateLimit("
|
|
14752
|
+
const rl = checkRateLimit("posting", `start_platform_connection:${userId}`);
|
|
13569
14753
|
if (!rl.allowed) {
|
|
13570
14754
|
return {
|
|
13571
14755
|
content: [
|
|
@@ -13645,13 +14829,13 @@ function registerConnectionTools(server) {
|
|
|
13645
14829
|
"wait_for_connection",
|
|
13646
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.",
|
|
13647
14831
|
{
|
|
13648
|
-
platform:
|
|
13649
|
-
project_id:
|
|
14832
|
+
platform: z33.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
|
|
14833
|
+
project_id: z33.string().optional().describe(
|
|
13650
14834
|
"Brand/project ID to scope the connection poll. Use the same project_id passed to start_platform_connection."
|
|
13651
14835
|
),
|
|
13652
|
-
timeout_s:
|
|
13653
|
-
poll_interval_s:
|
|
13654
|
-
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.")
|
|
13655
14839
|
},
|
|
13656
14840
|
async ({ platform: platform2, project_id, timeout_s, poll_interval_s, response_format }) => {
|
|
13657
14841
|
const format = response_format ?? "text";
|
|
@@ -13784,7 +14968,7 @@ function registerConnectionTools(server) {
|
|
|
13784
14968
|
}
|
|
13785
14969
|
|
|
13786
14970
|
// src/tools/harness.ts
|
|
13787
|
-
import { z as
|
|
14971
|
+
import { z as z34 } from "zod";
|
|
13788
14972
|
var ALLOWED_AGENTS = [
|
|
13789
14973
|
"conductor",
|
|
13790
14974
|
"brand-brain",
|
|
@@ -13794,38 +14978,38 @@ var ALLOWED_AGENTS = [
|
|
|
13794
14978
|
"engager"
|
|
13795
14979
|
];
|
|
13796
14980
|
var writeReflectionSchema = {
|
|
13797
|
-
reflection_text:
|
|
13798
|
-
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(
|
|
13799
14983
|
"Which agent produced this reflection. Must be one of: conductor, brand-brain, drafter, publisher, analyst, engager."
|
|
13800
14984
|
),
|
|
13801
|
-
provenance:
|
|
13802
|
-
content_history_id:
|
|
13803
|
-
outcome_event_id:
|
|
13804
|
-
prm_score_ids:
|
|
13805
|
-
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.")
|
|
13806
14990
|
}).strict().describe(
|
|
13807
14991
|
"Source evidence for this reflection. Only these four keys are accepted \u2014 unknown keys are rejected to prevent spurious provenance claims."
|
|
13808
14992
|
),
|
|
13809
|
-
brand_id:
|
|
13810
|
-
pipeline_id:
|
|
13811
|
-
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.")
|
|
13812
14996
|
};
|
|
13813
14997
|
var readReflectionSchema = {
|
|
13814
|
-
brand_id:
|
|
13815
|
-
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(
|
|
13816
15000
|
"Optional filter: only return reflections produced by this agent. One of: conductor, brand-brain, drafter, publisher, analyst, engager."
|
|
13817
15001
|
),
|
|
13818
|
-
limit:
|
|
15002
|
+
limit: z34.number().int().min(1).max(100).optional().describe(
|
|
13819
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)."
|
|
13820
15004
|
)
|
|
13821
15005
|
};
|
|
13822
15006
|
var recordOutcomeSchema = {
|
|
13823
|
-
decision_event_id:
|
|
13824
|
-
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(
|
|
13825
15009
|
"Observation horizon. Only horizon=24h with a non-null reward triggers a learning-loop update; 1h/6h are stored but inert for learning."
|
|
13826
15010
|
),
|
|
13827
|
-
reward:
|
|
13828
|
-
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(
|
|
13829
15013
|
'Optional map of raw metric name \u2192 value (e.g. {"likes": 42, "reach": 1200}). Stored verbatim; not used for learning directly.'
|
|
13830
15014
|
)
|
|
13831
15015
|
};
|
|
@@ -13921,7 +15105,7 @@ function registerHarnessTools(server, _ctx) {
|
|
|
13921
15105
|
}
|
|
13922
15106
|
|
|
13923
15107
|
// src/tools/hermes.ts
|
|
13924
|
-
import { z as
|
|
15108
|
+
import { z as z35 } from "zod";
|
|
13925
15109
|
function asEnvelope25(data) {
|
|
13926
15110
|
return {
|
|
13927
15111
|
_meta: {
|
|
@@ -13931,7 +15115,7 @@ function asEnvelope25(data) {
|
|
|
13931
15115
|
data
|
|
13932
15116
|
};
|
|
13933
15117
|
}
|
|
13934
|
-
var PLATFORM =
|
|
15118
|
+
var PLATFORM = z35.enum([
|
|
13935
15119
|
"instagram",
|
|
13936
15120
|
"twitter",
|
|
13937
15121
|
"linkedin",
|
|
@@ -13947,12 +15131,12 @@ function registerHermesTools(server) {
|
|
|
13947
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.",
|
|
13948
15132
|
{
|
|
13949
15133
|
platform: PLATFORM.describe("Target platform for the draft."),
|
|
13950
|
-
copy:
|
|
13951
|
-
project_id:
|
|
13952
|
-
media_url:
|
|
13953
|
-
hermes_run_id:
|
|
13954
|
-
source_intel_ids:
|
|
13955
|
-
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()
|
|
13956
15140
|
},
|
|
13957
15141
|
async ({ platform: platform2, copy, project_id, media_url, hermes_run_id, response_format }) => {
|
|
13958
15142
|
const { data, error } = await callEdgeFunction(
|
|
@@ -13990,15 +15174,15 @@ function registerHermesTools(server) {
|
|
|
13990
15174
|
"record_voice_lesson",
|
|
13991
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)".',
|
|
13992
15176
|
{
|
|
13993
|
-
project_id:
|
|
13994
|
-
lesson:
|
|
13995
|
-
evidence:
|
|
13996
|
-
engagement_lift_pct:
|
|
13997
|
-
sample_size:
|
|
13998
|
-
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.")
|
|
13999
15183
|
}).describe("Quantitative evidence backing the lesson."),
|
|
14000
|
-
applies_to:
|
|
14001
|
-
response_format:
|
|
15184
|
+
applies_to: z35.array(PLATFORM).min(1).describe("Platforms this lesson applies to."),
|
|
15185
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14002
15186
|
},
|
|
14003
15187
|
async ({ project_id, lesson, evidence, applies_to, response_format }) => {
|
|
14004
15188
|
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
@@ -14034,10 +15218,10 @@ function registerHermesTools(server) {
|
|
|
14034
15218
|
"record_observation",
|
|
14035
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.',
|
|
14036
15220
|
{
|
|
14037
|
-
summary:
|
|
14038
|
-
deltas:
|
|
14039
|
-
run_id:
|
|
14040
|
-
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()
|
|
14041
15225
|
},
|
|
14042
15226
|
async ({ summary, deltas, run_id, response_format }) => {
|
|
14043
15227
|
const { data, error } = await callEdgeFunction("mcp-data", { action: "record-observation", summary, deltas, run_id });
|
|
@@ -14064,13 +15248,13 @@ function registerHermesTools(server) {
|
|
|
14064
15248
|
"record_intel_signal",
|
|
14065
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.",
|
|
14066
15250
|
{
|
|
14067
|
-
source:
|
|
14068
|
-
url:
|
|
14069
|
-
topic:
|
|
14070
|
-
title:
|
|
14071
|
-
summary:
|
|
14072
|
-
score:
|
|
14073
|
-
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()
|
|
14074
15258
|
},
|
|
14075
15259
|
async ({ source, url, topic, title, summary, score, response_format }) => {
|
|
14076
15260
|
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
@@ -14108,16 +15292,16 @@ function registerHermesTools(server) {
|
|
|
14108
15292
|
"record_campaign_spend",
|
|
14109
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.",
|
|
14110
15294
|
{
|
|
14111
|
-
campaign_id:
|
|
14112
|
-
category:
|
|
15295
|
+
campaign_id: z35.string().min(1).max(200).describe("Campaign identifier (slug)."),
|
|
15296
|
+
category: z35.enum([
|
|
14113
15297
|
"hermes_drafts",
|
|
14114
15298
|
"carousel_renders",
|
|
14115
15299
|
"analytics_pulls",
|
|
14116
15300
|
"paid_amplification",
|
|
14117
15301
|
"other"
|
|
14118
15302
|
]).describe("Cost category."),
|
|
14119
|
-
amount_usd:
|
|
14120
|
-
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()
|
|
14121
15305
|
},
|
|
14122
15306
|
async ({ campaign_id, category, amount_usd, response_format }) => {
|
|
14123
15307
|
const { data, error } = await callEdgeFunction("mcp-data", {
|
|
@@ -14152,7 +15336,7 @@ function registerHermesTools(server) {
|
|
|
14152
15336
|
"get_active_campaigns",
|
|
14153
15337
|
"List currently-running campaigns with thesis, budget, hero format, and current spend. Use to bias drafts toward active campaign themes.",
|
|
14154
15338
|
{
|
|
14155
|
-
response_format:
|
|
15339
|
+
response_format: z35.enum(["text", "json"]).optional()
|
|
14156
15340
|
},
|
|
14157
15341
|
async ({ response_format }) => {
|
|
14158
15342
|
const { data, error } = await callEdgeFunction("mcp-data", { action: "get-active-campaigns" });
|
|
@@ -14197,7 +15381,7 @@ ${"=".repeat(40)}
|
|
|
14197
15381
|
}
|
|
14198
15382
|
|
|
14199
15383
|
// src/tools/skills.ts
|
|
14200
|
-
import { z as
|
|
15384
|
+
import { z as z36 } from "zod";
|
|
14201
15385
|
|
|
14202
15386
|
// src/lib/skills-manifest.ts
|
|
14203
15387
|
var SKILLS_MANIFEST = [
|
|
@@ -14226,6 +15410,19 @@ function listSkills(opts) {
|
|
|
14226
15410
|
}
|
|
14227
15411
|
|
|
14228
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
|
+
}
|
|
14229
15426
|
function asEnvelope26(data) {
|
|
14230
15427
|
return {
|
|
14231
15428
|
_meta: {
|
|
@@ -14235,7 +15432,14 @@ function asEnvelope26(data) {
|
|
|
14235
15432
|
data
|
|
14236
15433
|
};
|
|
14237
15434
|
}
|
|
14238
|
-
var STUDIO_VALUES = [
|
|
15435
|
+
var STUDIO_VALUES = [
|
|
15436
|
+
"video",
|
|
15437
|
+
"avatar",
|
|
15438
|
+
"carousel",
|
|
15439
|
+
"voice",
|
|
15440
|
+
"caption",
|
|
15441
|
+
"edit"
|
|
15442
|
+
];
|
|
14239
15443
|
function renderSkillSummary(skill) {
|
|
14240
15444
|
const lines = [];
|
|
14241
15445
|
lines.push(`${skill.name} (${skill.id})`);
|
|
@@ -14254,19 +15458,88 @@ function registerSkillsTools(server) {
|
|
|
14254
15458
|
"list_skills",
|
|
14255
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.',
|
|
14256
15460
|
{
|
|
14257
|
-
studio:
|
|
14258
|
-
|
|
14259
|
-
|
|
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
|
+
)
|
|
14260
15470
|
},
|
|
14261
15471
|
async ({ studio, featured_only, response_format }) => {
|
|
14262
|
-
const skills = listSkills({ studio, featuredOnly: featured_only });
|
|
14263
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 });
|
|
14264
15533
|
if (format === "json") {
|
|
14265
15534
|
return {
|
|
14266
15535
|
content: [
|
|
14267
15536
|
{
|
|
14268
15537
|
type: "text",
|
|
14269
|
-
text: JSON.stringify(
|
|
15538
|
+
text: JSON.stringify(
|
|
15539
|
+
asEnvelope26({ count: skills.length, skills }),
|
|
15540
|
+
null,
|
|
15541
|
+
2
|
|
15542
|
+
)
|
|
14270
15543
|
}
|
|
14271
15544
|
]
|
|
14272
15545
|
};
|
|
@@ -14276,7 +15549,9 @@ function registerSkillsTools(server) {
|
|
|
14276
15549
|
content: [
|
|
14277
15550
|
{
|
|
14278
15551
|
type: "text",
|
|
14279
|
-
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
|
+
)
|
|
14280
15555
|
}
|
|
14281
15556
|
]
|
|
14282
15557
|
};
|
|
@@ -14295,18 +15570,22 @@ ${blocks}` }]
|
|
|
14295
15570
|
"run_skill",
|
|
14296
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.",
|
|
14297
15572
|
{
|
|
14298
|
-
skill_id:
|
|
15573
|
+
skill_id: z36.string().describe(
|
|
14299
15574
|
'The id of the skill to run (e.g. "skill-brand-locked-viral-hook-reel"). Use list_skills to discover available ids.'
|
|
14300
15575
|
),
|
|
14301
|
-
topic:
|
|
14302
|
-
|
|
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(
|
|
14303
15580
|
'Who the content is for (e.g. "first-time founders"). Defaults to brand persona.'
|
|
14304
15581
|
),
|
|
14305
|
-
hook:
|
|
15582
|
+
hook: z36.string().optional().describe(
|
|
14306
15583
|
"Optional explicit hook. If omitted, the skill drafts and scores 3-5 candidates."
|
|
14307
15584
|
),
|
|
14308
|
-
cta:
|
|
14309
|
-
|
|
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.")
|
|
14310
15589
|
},
|
|
14311
15590
|
async ({ skill_id, topic, audience, hook, cta, response_format }) => {
|
|
14312
15591
|
const skill = getSkill(skill_id);
|
|
@@ -14374,10 +15653,78 @@ ${blocks}` }]
|
|
|
14374
15653
|
};
|
|
14375
15654
|
}
|
|
14376
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
|
+
);
|
|
14377
15724
|
}
|
|
14378
15725
|
|
|
14379
15726
|
// src/tools/loopPulse.ts
|
|
14380
|
-
import { z as
|
|
15727
|
+
import { z as z37 } from "zod";
|
|
14381
15728
|
function asEnvelope27(data) {
|
|
14382
15729
|
return {
|
|
14383
15730
|
_meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
|
|
@@ -14389,7 +15736,7 @@ function registerLoopPulseTools(server) {
|
|
|
14389
15736
|
"get_loop_pulse",
|
|
14390
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.',
|
|
14391
15738
|
{
|
|
14392
|
-
response_format:
|
|
15739
|
+
response_format: z37.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
|
|
14393
15740
|
},
|
|
14394
15741
|
async ({ response_format }) => {
|
|
14395
15742
|
const format = response_format ?? "text";
|
|
@@ -14432,7 +15779,7 @@ function registerLoopPulseTools(server) {
|
|
|
14432
15779
|
}
|
|
14433
15780
|
|
|
14434
15781
|
// src/tools/banditState.ts
|
|
14435
|
-
import { z as
|
|
15782
|
+
import { z as z38 } from "zod";
|
|
14436
15783
|
init_supabase();
|
|
14437
15784
|
function asEnvelope28(data) {
|
|
14438
15785
|
return {
|
|
@@ -14445,8 +15792,8 @@ function registerBanditStateTools(server) {
|
|
|
14445
15792
|
"get_bandit_state",
|
|
14446
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).",
|
|
14447
15794
|
{
|
|
14448
|
-
project_id:
|
|
14449
|
-
platform:
|
|
15795
|
+
project_id: z38.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
|
|
15796
|
+
platform: z38.enum([
|
|
14450
15797
|
"instagram",
|
|
14451
15798
|
"tiktok",
|
|
14452
15799
|
"youtube",
|
|
@@ -14458,7 +15805,7 @@ function registerBanditStateTools(server) {
|
|
|
14458
15805
|
]).optional().describe(
|
|
14459
15806
|
"Lowercase platform name. Omit to return both platform-scoped and legacy platform-agnostic arms."
|
|
14460
15807
|
),
|
|
14461
|
-
arm_type:
|
|
15808
|
+
arm_type: z38.enum([
|
|
14462
15809
|
"hook_family",
|
|
14463
15810
|
"hook_type",
|
|
14464
15811
|
"format",
|
|
@@ -14472,8 +15819,8 @@ function registerBanditStateTools(server) {
|
|
|
14472
15819
|
"posting_time_bucket",
|
|
14473
15820
|
"content_format"
|
|
14474
15821
|
]).optional().describe("Arm dimension. Omit to return all types grouped."),
|
|
14475
|
-
top_k:
|
|
14476
|
-
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.")
|
|
14477
15824
|
},
|
|
14478
15825
|
async ({ project_id, platform: platform2, arm_type, top_k, response_format }) => {
|
|
14479
15826
|
const format = response_format ?? "text";
|
|
@@ -14528,12 +15875,128 @@ function registerBanditStateTools(server) {
|
|
|
14528
15875
|
);
|
|
14529
15876
|
}
|
|
14530
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
|
+
|
|
14531
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
|
+
}
|
|
14532
15994
|
function wrapToolWithScanner(toolName, handler) {
|
|
14533
15995
|
return async function scannerWrappedHandler(...handlerArgs) {
|
|
14534
15996
|
const args = handlerArgs[0];
|
|
14535
15997
|
const ctx = handlerArgs[1];
|
|
14536
|
-
const
|
|
15998
|
+
const scanArgs = redactBase64Payloads(args);
|
|
15999
|
+
const inputText = scanArgs === void 0 ? "{}" : typeof scanArgs === "string" ? scanArgs : JSON.stringify(scanArgs);
|
|
14537
16000
|
const inputScan = scan(inputText, {
|
|
14538
16001
|
mode: "block",
|
|
14539
16002
|
source: "mcp_tool_input",
|
|
@@ -14561,6 +16024,16 @@ function wrapToolWithScanner(toolName, handler) {
|
|
|
14561
16024
|
source: "mcp_tool_output",
|
|
14562
16025
|
user_id: ctx?.userId
|
|
14563
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
|
+
}
|
|
14564
16037
|
if (outputScan.sanitized_text !== void 0) {
|
|
14565
16038
|
try {
|
|
14566
16039
|
ctx?.logScan?.(toolName, "output", outputScan);
|
|
@@ -14569,7 +16042,10 @@ function wrapToolWithScanner(toolName, handler) {
|
|
|
14569
16042
|
try {
|
|
14570
16043
|
return JSON.parse(outputScan.sanitized_text);
|
|
14571
16044
|
} catch {
|
|
14572
|
-
return
|
|
16045
|
+
return toolError(
|
|
16046
|
+
"server_error",
|
|
16047
|
+
"The response could not be returned safely. Please retry or contact support."
|
|
16048
|
+
);
|
|
14573
16049
|
}
|
|
14574
16050
|
}
|
|
14575
16051
|
return result;
|
|
@@ -14619,8 +16095,7 @@ function applyScopeEnforcement(server, scopeResolver) {
|
|
|
14619
16095
|
details: {
|
|
14620
16096
|
source: "wrapper",
|
|
14621
16097
|
// A thrown exception escaped the handler — an unclassified fault.
|
|
14622
|
-
error_type: "server_error"
|
|
14623
|
-
exception: err instanceof Error ? err.message.slice(0, 200) : "unknown"
|
|
16098
|
+
error_type: "server_error"
|
|
14624
16099
|
}
|
|
14625
16100
|
});
|
|
14626
16101
|
throw err;
|
|
@@ -14753,24 +16228,26 @@ function registerAllTools(server, options) {
|
|
|
14753
16228
|
registerSkillsTools(server);
|
|
14754
16229
|
registerLoopPulseTools(server);
|
|
14755
16230
|
registerBanditStateTools(server);
|
|
16231
|
+
registerLifecycleTools(server);
|
|
14756
16232
|
if (!options?.skipApps) {
|
|
14757
16233
|
registerContentCalendarApp(server);
|
|
16234
|
+
registerAnalyticsPulseApp(server);
|
|
14758
16235
|
}
|
|
14759
16236
|
applyAnnotations(server);
|
|
14760
16237
|
}
|
|
14761
16238
|
|
|
14762
16239
|
// src/prompts.ts
|
|
14763
|
-
import { z as
|
|
16240
|
+
import { z as z40 } from "zod";
|
|
14764
16241
|
function registerPrompts(server) {
|
|
14765
16242
|
server.prompt(
|
|
14766
16243
|
"create_weekly_content_plan",
|
|
14767
16244
|
"Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
|
|
14768
16245
|
{
|
|
14769
|
-
niche:
|
|
14770
|
-
platforms:
|
|
16246
|
+
niche: z40.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
|
|
16247
|
+
platforms: z40.string().optional().describe(
|
|
14771
16248
|
'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
|
|
14772
16249
|
),
|
|
14773
|
-
tone:
|
|
16250
|
+
tone: z40.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
|
|
14774
16251
|
},
|
|
14775
16252
|
({ niche, platforms, tone }) => {
|
|
14776
16253
|
const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
|
|
@@ -14812,8 +16289,8 @@ After building the plan, use \`save_content_plan\` to save it.`
|
|
|
14812
16289
|
"analyze_top_content",
|
|
14813
16290
|
"Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
|
|
14814
16291
|
{
|
|
14815
|
-
timeframe:
|
|
14816
|
-
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")')
|
|
14817
16294
|
},
|
|
14818
16295
|
({ timeframe, platform: platform2 }) => {
|
|
14819
16296
|
const period = timeframe || "30 days";
|
|
@@ -14850,10 +16327,10 @@ Format as a clear, actionable performance report.`
|
|
|
14850
16327
|
"repurpose_content",
|
|
14851
16328
|
"Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
|
|
14852
16329
|
{
|
|
14853
|
-
source:
|
|
16330
|
+
source: z40.string().describe(
|
|
14854
16331
|
"The source content to repurpose \u2014 a URL, transcript, or the content text itself"
|
|
14855
16332
|
),
|
|
14856
|
-
target_platforms:
|
|
16333
|
+
target_platforms: z40.string().optional().describe(
|
|
14857
16334
|
'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
|
|
14858
16335
|
)
|
|
14859
16336
|
},
|
|
@@ -14895,9 +16372,9 @@ For each piece, include the platform, format, character count, and suggested pos
|
|
|
14895
16372
|
"setup_brand_voice",
|
|
14896
16373
|
"Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
|
|
14897
16374
|
{
|
|
14898
|
-
brand_name:
|
|
14899
|
-
industry:
|
|
14900
|
-
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")
|
|
14901
16378
|
},
|
|
14902
16379
|
({ brand_name, industry, website }) => {
|
|
14903
16380
|
const industryContext = industry ? ` in the ${industry} space` : "";
|
|
@@ -15260,11 +16737,7 @@ init_request_context();
|
|
|
15260
16737
|
|
|
15261
16738
|
// src/lib/discovery-catalog.ts
|
|
15262
16739
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15263
|
-
var PUBLIC_SCHEMA_OMIT_PROPERTIES = {
|
|
15264
|
-
// Internal lineage fields are accepted by the runtime for Social Neuron's own
|
|
15265
|
-
// automation, but should not be advertised in public MCP/OpenAPI discovery.
|
|
15266
|
-
schedule_post: ["origin", "hermes_run_id"]
|
|
15267
|
-
};
|
|
16740
|
+
var PUBLIC_SCHEMA_OMIT_PROPERTIES = {};
|
|
15268
16741
|
var cached = /* @__PURE__ */ new Map();
|
|
15269
16742
|
function buildDiscoveryCatalog(profile = "full") {
|
|
15270
16743
|
const existing = cached.get(profile);
|
|
@@ -15391,7 +16864,9 @@ function buildOpenApiDocument() {
|
|
|
15391
16864
|
async function computeOpenApiDocument() {
|
|
15392
16865
|
const discovery = await buildDiscoveryCatalog();
|
|
15393
16866
|
const schemaByName = new Map(discovery.map((t) => [t.name, t.inputSchema]));
|
|
15394
|
-
const publicTools = TOOL_CATALOG.filter(
|
|
16867
|
+
const publicTools = TOOL_CATALOG.filter(
|
|
16868
|
+
(t) => !t.localOnly && !t.internal && !t.hiddenFromPublicCount
|
|
16869
|
+
);
|
|
15395
16870
|
const paths = {};
|
|
15396
16871
|
for (const tool of publicTools) {
|
|
15397
16872
|
const scope = TOOL_SCOPES[tool.name];
|
|
@@ -15474,7 +16949,11 @@ function getCallHandler() {
|
|
|
15474
16949
|
return cachedCallHandler;
|
|
15475
16950
|
}
|
|
15476
16951
|
function restToolNames() {
|
|
15477
|
-
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
|
+
);
|
|
15478
16957
|
}
|
|
15479
16958
|
async function invokeToolRest(name, args) {
|
|
15480
16959
|
const handler = getCallHandler();
|
|
@@ -15549,7 +17028,7 @@ function httpStatusForResult(result) {
|
|
|
15549
17028
|
}
|
|
15550
17029
|
|
|
15551
17030
|
// src/lib/token-verifier.ts
|
|
15552
|
-
import { createHash as
|
|
17031
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
15553
17032
|
import * as jose from "jose";
|
|
15554
17033
|
var jwks = null;
|
|
15555
17034
|
function getJWKS(supabaseUrl) {
|
|
@@ -15560,10 +17039,21 @@ function getJWKS(supabaseUrl) {
|
|
|
15560
17039
|
return jwks;
|
|
15561
17040
|
}
|
|
15562
17041
|
var tokenValidationCache = /* @__PURE__ */ new Map();
|
|
15563
|
-
var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS =
|
|
17042
|
+
var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS = 6e4;
|
|
15564
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
|
+
}
|
|
15565
17055
|
function cacheKey(token) {
|
|
15566
|
-
return
|
|
17056
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
15567
17057
|
}
|
|
15568
17058
|
function evictFromCache(token) {
|
|
15569
17059
|
tokenValidationCache.delete(cacheKey(token));
|
|
@@ -15598,6 +17088,7 @@ async function verifyCachedOpaqueToken(token, validate, ttlMs) {
|
|
|
15598
17088
|
}
|
|
15599
17089
|
function createTokenVerifier(options) {
|
|
15600
17090
|
const { supabaseUrl, supabaseAnonKey } = options;
|
|
17091
|
+
const allowSupabaseSessionTokens = options.allowSupabaseSessionTokens ?? process.env.MCP_ALLOW_SUPABASE_SESSION_TOKENS === "true";
|
|
15601
17092
|
const expectedResource = normalizeResource(
|
|
15602
17093
|
options.resource ?? process.env.MCP_RESOURCE_URL ?? process.env.MCP_SERVER_URL
|
|
15603
17094
|
) ?? DEFAULT_MCP_RESOURCE;
|
|
@@ -15613,7 +17104,10 @@ function createTokenVerifier(options) {
|
|
|
15613
17104
|
CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS
|
|
15614
17105
|
);
|
|
15615
17106
|
}
|
|
15616
|
-
|
|
17107
|
+
if (!allowSupabaseSessionTokens) {
|
|
17108
|
+
throw new Error("Unsupported access token");
|
|
17109
|
+
}
|
|
17110
|
+
return verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey);
|
|
15617
17111
|
}
|
|
15618
17112
|
};
|
|
15619
17113
|
}
|
|
@@ -15621,7 +17115,10 @@ function normalizeResource(value) {
|
|
|
15621
17115
|
if (!value) return void 0;
|
|
15622
17116
|
try {
|
|
15623
17117
|
const parsed = new URL(value);
|
|
15624
|
-
|
|
17118
|
+
parsed.hash = "";
|
|
17119
|
+
parsed.search = "";
|
|
17120
|
+
if (parsed.pathname !== "/") parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
17121
|
+
return parsed.toString().replace(/\/$/, "");
|
|
15625
17122
|
} catch {
|
|
15626
17123
|
return value.replace(/\/$/, "");
|
|
15627
17124
|
}
|
|
@@ -15635,7 +17132,7 @@ function audienceIncludesExpected(audience, expectedResource) {
|
|
|
15635
17132
|
}
|
|
15636
17133
|
return false;
|
|
15637
17134
|
}
|
|
15638
|
-
async function verifySupabaseJwt(token, supabaseUrl) {
|
|
17135
|
+
async function verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey) {
|
|
15639
17136
|
const jwksKeySet = getJWKS(supabaseUrl);
|
|
15640
17137
|
const { payload } = await jose.jwtVerify(token, jwksKeySet, {
|
|
15641
17138
|
issuer: `${supabaseUrl}/auth/v1`,
|
|
@@ -15647,14 +17144,41 @@ async function verifySupabaseJwt(token, supabaseUrl) {
|
|
|
15647
17144
|
if (!userId) {
|
|
15648
17145
|
throw new Error("JWT missing sub claim");
|
|
15649
17146
|
}
|
|
15650
|
-
const
|
|
15651
|
-
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
|
+
}
|
|
15652
17176
|
return {
|
|
15653
17177
|
token,
|
|
15654
17178
|
clientId: payload.client_id ?? "supabase-oauth",
|
|
15655
|
-
scopes,
|
|
17179
|
+
scopes: validScopes(entitlements.scopes),
|
|
15656
17180
|
expiresAt: payload.exp,
|
|
15657
|
-
extra: { userId }
|
|
17181
|
+
extra: { userId, tier: entitlements.tier }
|
|
15658
17182
|
};
|
|
15659
17183
|
}
|
|
15660
17184
|
async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
|
|
@@ -15686,12 +17210,12 @@ async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
|
|
|
15686
17210
|
throw new Error("API key expired");
|
|
15687
17211
|
}
|
|
15688
17212
|
const projectId = data.projectId ?? data.project_id ?? null;
|
|
15689
|
-
const extra = { userId: data.userId
|
|
17213
|
+
const extra = { userId: data.userId };
|
|
15690
17214
|
if (projectId) extra.projectId = projectId;
|
|
15691
17215
|
return {
|
|
15692
17216
|
token: apiKey,
|
|
15693
17217
|
clientId: "api-key",
|
|
15694
|
-
scopes: data.scopes
|
|
17218
|
+
scopes: validScopes(data.scopes),
|
|
15695
17219
|
expiresAt,
|
|
15696
17220
|
extra
|
|
15697
17221
|
};
|
|
@@ -15737,9 +17261,9 @@ async function verifyConnectorToken(accessToken, supabaseUrl, supabaseAnonKey, e
|
|
|
15737
17261
|
return {
|
|
15738
17262
|
token: accessToken,
|
|
15739
17263
|
clientId: data.clientId ?? "connector-oauth",
|
|
15740
|
-
scopes: data.scopes
|
|
17264
|
+
scopes: validScopes(data.scopes),
|
|
15741
17265
|
expiresAt,
|
|
15742
|
-
extra: { userId: data.userId,
|
|
17266
|
+
extra: { userId: data.userId, resource: expectedResource }
|
|
15743
17267
|
};
|
|
15744
17268
|
} catch (err) {
|
|
15745
17269
|
if (err instanceof Error && err.name === "AbortError") {
|
|
@@ -15886,7 +17410,7 @@ function createClientsStore() {
|
|
|
15886
17410
|
function markUnavailable(reason) {
|
|
15887
17411
|
if (supabaseAvailable) {
|
|
15888
17412
|
console.error(
|
|
15889
|
-
`[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.`
|
|
15890
17414
|
);
|
|
15891
17415
|
supabaseAvailable = false;
|
|
15892
17416
|
}
|
|
@@ -15915,7 +17439,7 @@ function createClientsStore() {
|
|
|
15915
17439
|
const { error: deleteError } = await supabase.from("mcp_oauth_clients").delete().eq("client_id", clientId);
|
|
15916
17440
|
if (deleteError) {
|
|
15917
17441
|
console.error(
|
|
15918
|
-
`[oauth] failed to remove client with disallowed redirect URI: ${deleteError
|
|
17442
|
+
`[oauth] failed to remove client with disallowed redirect URI: ${sanitizeError(deleteError)}`
|
|
15919
17443
|
);
|
|
15920
17444
|
}
|
|
15921
17445
|
return void 0;
|
|
@@ -15976,7 +17500,11 @@ function createOAuthProvider(options) {
|
|
|
15976
17500
|
const { supabaseUrl, supabaseAnonKey } = options;
|
|
15977
17501
|
const appBaseUrl = options.appBaseUrl ?? "https://www.socialneuron.com";
|
|
15978
17502
|
const clientsStore = createClientsStore();
|
|
15979
|
-
const tokenVerifier2 = createTokenVerifier({
|
|
17503
|
+
const tokenVerifier2 = createTokenVerifier({
|
|
17504
|
+
supabaseUrl,
|
|
17505
|
+
supabaseAnonKey,
|
|
17506
|
+
resource: options.resource
|
|
17507
|
+
});
|
|
15980
17508
|
return {
|
|
15981
17509
|
get clientsStore() {
|
|
15982
17510
|
return clientsStore;
|
|
@@ -16049,12 +17577,11 @@ function createOAuthProvider(options) {
|
|
|
16049
17577
|
}
|
|
16050
17578
|
clearTimeout(timer);
|
|
16051
17579
|
if (!response.ok) {
|
|
16052
|
-
|
|
16053
|
-
throw new Error(err.error ?? `HTTP ${response.status}`);
|
|
17580
|
+
throw new Error(`Authorization code exchange failed (HTTP ${response.status})`);
|
|
16054
17581
|
}
|
|
16055
17582
|
const data = await response.json();
|
|
16056
17583
|
if (!data.access_token) {
|
|
16057
|
-
throw new Error(
|
|
17584
|
+
throw new Error("No access token returned from exchange");
|
|
16058
17585
|
}
|
|
16059
17586
|
return {
|
|
16060
17587
|
access_token: data.access_token,
|
|
@@ -16086,12 +17613,11 @@ function createOAuthProvider(options) {
|
|
|
16086
17613
|
}
|
|
16087
17614
|
);
|
|
16088
17615
|
if (!response.ok) {
|
|
16089
|
-
|
|
16090
|
-
throw new Error(err.error ?? `HTTP ${response.status}`);
|
|
17616
|
+
throw new Error(`Refresh token exchange failed (HTTP ${response.status})`);
|
|
16091
17617
|
}
|
|
16092
17618
|
const data = await response.json();
|
|
16093
17619
|
if (!data.access_token) {
|
|
16094
|
-
throw new Error(
|
|
17620
|
+
throw new Error("No access token returned from refresh");
|
|
16095
17621
|
}
|
|
16096
17622
|
return {
|
|
16097
17623
|
access_token: data.access_token,
|
|
@@ -16135,8 +17661,7 @@ function createOAuthProvider(options) {
|
|
|
16135
17661
|
throw new Error(`Token revocation failed: HTTP ${response.status}`);
|
|
16136
17662
|
}
|
|
16137
17663
|
} catch (err) {
|
|
16138
|
-
|
|
16139
|
-
console.error(`[oauth] Token revocation call failed: ${msg}`);
|
|
17664
|
+
console.error(`[oauth] Token revocation call failed: ${sanitizeError(err)}`);
|
|
16140
17665
|
} finally {
|
|
16141
17666
|
clearTimeout(timer);
|
|
16142
17667
|
}
|
|
@@ -16148,6 +17673,10 @@ function createOAuthProvider(options) {
|
|
|
16148
17673
|
init_posthog();
|
|
16149
17674
|
|
|
16150
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
|
+
];
|
|
16151
17680
|
function buildProtectedResourceMetadata(options) {
|
|
16152
17681
|
return {
|
|
16153
17682
|
resource: options.resourceUrl,
|
|
@@ -16195,18 +17724,16 @@ function parseAllowedOrigins(raw) {
|
|
|
16195
17724
|
}
|
|
16196
17725
|
function buildOriginPolicy(input) {
|
|
16197
17726
|
const envOrigins = parseAllowedOrigins(input.allowedOriginsEnv);
|
|
16198
|
-
|
|
16199
|
-
|
|
16200
|
-
}
|
|
16201
|
-
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);
|
|
16202
17729
|
for (const configuredUrl of input.configuredUrls ?? []) {
|
|
16203
17730
|
const normalized = normalizeOrigin(configuredUrl);
|
|
16204
|
-
if (normalized)
|
|
17731
|
+
if (normalized) allowedOrigins.add(normalized);
|
|
16205
17732
|
}
|
|
16206
|
-
if (input.nodeEnv !== "production") {
|
|
16207
|
-
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);
|
|
16208
17735
|
}
|
|
16209
|
-
return { allowedOrigins
|
|
17736
|
+
return { allowedOrigins, source };
|
|
16210
17737
|
}
|
|
16211
17738
|
function validateBrowserOrigin(originHeader, policy) {
|
|
16212
17739
|
if (originHeader === void 0) return { allowed: true, origin: null };
|
|
@@ -16218,6 +17745,20 @@ function validateBrowserOrigin(originHeader, policy) {
|
|
|
16218
17745
|
return { allowed: true, origin: normalized };
|
|
16219
17746
|
}
|
|
16220
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
|
+
|
|
16221
17762
|
// src/http.ts
|
|
16222
17763
|
process.env.MCP_TRANSPORT = "http";
|
|
16223
17764
|
var PORT = parseInt(process.env.PORT ?? "8080", 10);
|
|
@@ -16227,9 +17768,19 @@ var MCP_SERVER_URL = process.env.MCP_SERVER_URL ?? `http://localhost:${PORT}/mcp
|
|
|
16227
17768
|
var APP_BASE_URL = process.env.APP_BASE_URL ?? "https://www.socialneuron.com";
|
|
16228
17769
|
var NODE_ENV = process.env.NODE_ENV ?? "development";
|
|
16229
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
|
+
];
|
|
16230
17777
|
var ORIGIN_POLICY = buildOriginPolicy({
|
|
16231
17778
|
allowedOriginsEnv: process.env.ALLOWED_ORIGINS,
|
|
16232
|
-
configuredUrls: [
|
|
17779
|
+
configuredUrls: [
|
|
17780
|
+
APP_BASE_URL,
|
|
17781
|
+
MCP_SERVER_URL,
|
|
17782
|
+
...TRUSTED_BROWSER_MCP_CLIENTS
|
|
17783
|
+
],
|
|
16233
17784
|
nodeEnv: NODE_ENV
|
|
16234
17785
|
});
|
|
16235
17786
|
function deriveOAuthIssuerUrl() {
|
|
@@ -16254,6 +17805,18 @@ function deriveOAuthIssuerUrl() {
|
|
|
16254
17805
|
return "https://mcp.socialneuron.com";
|
|
16255
17806
|
}
|
|
16256
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
|
+
}
|
|
16257
17820
|
if (!SUPABASE_URL2 || !SUPABASE_ANON_KEY) {
|
|
16258
17821
|
console.error("[MCP HTTP] Missing SUPABASE_URL or SUPABASE_ANON_KEY");
|
|
16259
17822
|
process.exit(1);
|
|
@@ -16265,17 +17828,19 @@ if (SUPABASE_SERVICE_ROLE_KEY && SUPABASE_SERVICE_ROLE_KEY.length < 100) {
|
|
|
16265
17828
|
);
|
|
16266
17829
|
}
|
|
16267
17830
|
process.on("uncaughtException", (err) => {
|
|
16268
|
-
|
|
17831
|
+
logOperationalError("Uncaught exception", err);
|
|
16269
17832
|
process.exit(1);
|
|
16270
17833
|
});
|
|
16271
17834
|
process.on("unhandledRejection", (reason) => {
|
|
16272
|
-
|
|
16273
|
-
console.error(`[MCP HTTP] Unhandled rejection: ${message}`);
|
|
17835
|
+
logOperationalError("Unhandled rejection", reason);
|
|
16274
17836
|
process.exit(1);
|
|
16275
17837
|
});
|
|
16276
17838
|
var tokenVerifier = createTokenVerifier({
|
|
16277
17839
|
supabaseUrl: SUPABASE_URL2,
|
|
16278
|
-
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
|
|
16279
17844
|
});
|
|
16280
17845
|
initPostHog();
|
|
16281
17846
|
var MAX_SESSIONS = 500;
|
|
@@ -16289,15 +17854,46 @@ function countUserSessions(userId) {
|
|
|
16289
17854
|
}
|
|
16290
17855
|
return count;
|
|
16291
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
|
+
}
|
|
16292
17890
|
var cleanupInterval = setInterval(
|
|
16293
17891
|
() => {
|
|
16294
17892
|
const now = Date.now();
|
|
16295
17893
|
for (const [sessionId, entry] of sessions) {
|
|
16296
|
-
if (now - entry.lastActivity > SESSION_TIMEOUT_MS) {
|
|
16297
|
-
entry
|
|
16298
|
-
|
|
16299
|
-
sessions.delete(sessionId);
|
|
16300
|
-
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.");
|
|
16301
17897
|
}
|
|
16302
17898
|
}
|
|
16303
17899
|
},
|
|
@@ -16320,7 +17916,7 @@ setInterval(() => {
|
|
|
16320
17916
|
}
|
|
16321
17917
|
}, IP_RATE_CLEANUP_INTERVAL).unref();
|
|
16322
17918
|
app.use((req, res, next) => {
|
|
16323
|
-
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")
|
|
16324
17920
|
return next();
|
|
16325
17921
|
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
|
|
16326
17922
|
const now = Date.now();
|
|
@@ -16391,7 +17987,8 @@ app.use((req, res, next) => {
|
|
|
16391
17987
|
var oauthProvider = createOAuthProvider({
|
|
16392
17988
|
supabaseUrl: SUPABASE_URL2,
|
|
16393
17989
|
supabaseAnonKey: SUPABASE_ANON_KEY,
|
|
16394
|
-
appBaseUrl: APP_BASE_URL
|
|
17990
|
+
appBaseUrl: APP_BASE_URL,
|
|
17991
|
+
resource: MCP_SERVER_URL
|
|
16395
17992
|
});
|
|
16396
17993
|
var SCOPES_SUPPORTED = [
|
|
16397
17994
|
"mcp:full",
|
|
@@ -16402,7 +17999,7 @@ var SCOPES_SUPPORTED = [
|
|
|
16402
17999
|
"mcp:comments",
|
|
16403
18000
|
"mcp:autopilot"
|
|
16404
18001
|
];
|
|
16405
|
-
app.get(
|
|
18002
|
+
app.get(PROTECTED_RESOURCE_METADATA_PATHS, (_req, res) => {
|
|
16406
18003
|
res.json(
|
|
16407
18004
|
buildProtectedResourceMetadata({
|
|
16408
18005
|
resourceUrl: MCP_SERVER_URL,
|
|
@@ -16458,7 +18055,7 @@ app.use((req, _res, next) => {
|
|
|
16458
18055
|
app.use((req, res, next) => {
|
|
16459
18056
|
authRouter(req, res, (err) => {
|
|
16460
18057
|
if (err) {
|
|
16461
|
-
|
|
18058
|
+
logOperationalError("Auth router error", err);
|
|
16462
18059
|
}
|
|
16463
18060
|
next(err);
|
|
16464
18061
|
});
|
|
@@ -16491,8 +18088,8 @@ async function authenticateRequest(req, res, next) {
|
|
|
16491
18088
|
projectId: authInfo.extra?.projectId ?? null
|
|
16492
18089
|
};
|
|
16493
18090
|
next();
|
|
16494
|
-
} catch
|
|
16495
|
-
const message =
|
|
18091
|
+
} catch {
|
|
18092
|
+
const message = "The access token is invalid, expired, or not authorized for MCP.";
|
|
16496
18093
|
res.setHeader(
|
|
16497
18094
|
"WWW-Authenticate",
|
|
16498
18095
|
buildWwwAuthenticateHeader({
|
|
@@ -16600,11 +18197,7 @@ app.get(
|
|
|
16600
18197
|
status: "ok",
|
|
16601
18198
|
version: MCP_VERSION,
|
|
16602
18199
|
transport: "streamable-http",
|
|
16603
|
-
|
|
16604
|
-
sessionCap: MAX_SESSIONS,
|
|
16605
|
-
uptime: Math.floor(process.uptime()),
|
|
16606
|
-
memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
|
16607
|
-
env: NODE_ENV
|
|
18200
|
+
uptime: Math.floor(process.uptime())
|
|
16608
18201
|
});
|
|
16609
18202
|
}
|
|
16610
18203
|
);
|
|
@@ -16656,7 +18249,8 @@ app.post(
|
|
|
16656
18249
|
});
|
|
16657
18250
|
return;
|
|
16658
18251
|
}
|
|
16659
|
-
const
|
|
18252
|
+
const category = rateLimitCategoryForTool(name);
|
|
18253
|
+
const rl = checkRateLimit(category, `rest:${auth.userId}`);
|
|
16660
18254
|
if (!rl.allowed) {
|
|
16661
18255
|
res.setHeader("Retry-After", String(rl.retryAfter));
|
|
16662
18256
|
res.status(429).json({
|
|
@@ -16723,7 +18317,9 @@ app.post(
|
|
|
16723
18317
|
app.post("/mcp", async (req, res) => {
|
|
16724
18318
|
const auth = req.auth;
|
|
16725
18319
|
const existingSessionId = req.headers["mcp-session-id"];
|
|
16726
|
-
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}`);
|
|
16727
18323
|
if (!rl.allowed) {
|
|
16728
18324
|
res.setHeader("Retry-After", String(rl.retryAfter));
|
|
16729
18325
|
res.status(429).json({
|
|
@@ -16743,31 +18339,33 @@ app.post("/mcp", async (req, res) => {
|
|
|
16743
18339
|
});
|
|
16744
18340
|
return;
|
|
16745
18341
|
}
|
|
16746
|
-
|
|
16747
|
-
|
|
16748
|
-
|
|
16749
|
-
|
|
16750
|
-
|
|
16751
|
-
|
|
16752
|
-
|
|
16753
|
-
|
|
16754
|
-
|
|
16755
|
-
|
|
16756
|
-
|
|
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
|
+
)
|
|
16757
18355
|
);
|
|
16758
18356
|
return;
|
|
16759
18357
|
}
|
|
16760
|
-
if (
|
|
18358
|
+
if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER && !await reclaimOldestIdleSession(auth.userId)) {
|
|
16761
18359
|
res.status(429).json({
|
|
16762
18360
|
error: "too_many_sessions",
|
|
16763
|
-
error_description: `
|
|
18361
|
+
error_description: `Per-user session limit reached (${MAX_SESSIONS_PER_USER}); all sessions are active. Close an existing client and retry.`
|
|
16764
18362
|
});
|
|
16765
18363
|
return;
|
|
16766
18364
|
}
|
|
16767
|
-
if (
|
|
18365
|
+
if (sessions.size >= MAX_SESSIONS && !await reclaimOldestIdleSession()) {
|
|
16768
18366
|
res.status(429).json({
|
|
16769
18367
|
error: "too_many_sessions",
|
|
16770
|
-
error_description: `
|
|
18368
|
+
error_description: `Server session limit reached (${MAX_SESSIONS}); all sessions are active. Try again later.`
|
|
16771
18369
|
});
|
|
16772
18370
|
return;
|
|
16773
18371
|
}
|
|
@@ -16782,14 +18380,17 @@ app.post("/mcp", async (req, res) => {
|
|
|
16782
18380
|
});
|
|
16783
18381
|
registerPrompts(server);
|
|
16784
18382
|
registerResources(server);
|
|
18383
|
+
let initializedSessionId = null;
|
|
16785
18384
|
const transport = new StreamableHTTPServerTransport({
|
|
16786
18385
|
sessionIdGenerator: () => randomUUID4(),
|
|
16787
18386
|
onsessioninitialized: (sessionId) => {
|
|
18387
|
+
initializedSessionId = sessionId;
|
|
16788
18388
|
sessions.set(sessionId, {
|
|
16789
18389
|
transport,
|
|
16790
18390
|
server,
|
|
16791
18391
|
lastActivity: Date.now(),
|
|
16792
|
-
userId: auth.userId
|
|
18392
|
+
userId: auth.userId,
|
|
18393
|
+
activeRequests: 1
|
|
16793
18394
|
});
|
|
16794
18395
|
}
|
|
16795
18396
|
});
|
|
@@ -16799,20 +18400,29 @@ app.post("/mcp", async (req, res) => {
|
|
|
16799
18400
|
}
|
|
16800
18401
|
};
|
|
16801
18402
|
await server.connect(transport);
|
|
16802
|
-
|
|
16803
|
-
|
|
16804
|
-
|
|
16805
|
-
|
|
16806
|
-
|
|
16807
|
-
|
|
16808
|
-
|
|
16809
|
-
|
|
16810
|
-
|
|
16811
|
-
|
|
16812
|
-
|
|
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
|
+
}
|
|
16813
18424
|
} catch (err) {
|
|
16814
|
-
|
|
16815
|
-
console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
|
|
18425
|
+
logOperationalError("POST /mcp error", err);
|
|
16816
18426
|
if (!res.headersSent) {
|
|
16817
18427
|
res.status(500).json({
|
|
16818
18428
|
jsonrpc: "2.0",
|
|
@@ -16832,19 +18442,21 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
|
|
|
16832
18442
|
res.status(403).json({ error: "Session belongs to another user" });
|
|
16833
18443
|
return;
|
|
16834
18444
|
}
|
|
16835
|
-
entry.lastActivity = Date.now();
|
|
16836
18445
|
res.setHeader("X-Accel-Buffering", "no");
|
|
16837
18446
|
res.setHeader("Cache-Control", "no-cache");
|
|
16838
|
-
await
|
|
16839
|
-
|
|
16840
|
-
|
|
16841
|
-
|
|
16842
|
-
|
|
16843
|
-
|
|
16844
|
-
|
|
16845
|
-
|
|
16846
|
-
|
|
16847
|
-
|
|
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
|
+
)
|
|
16848
18460
|
);
|
|
16849
18461
|
});
|
|
16850
18462
|
app.delete(
|
|
@@ -16861,18 +18473,13 @@ app.delete(
|
|
|
16861
18473
|
res.status(403).json({ error: "Session belongs to another user" });
|
|
16862
18474
|
return;
|
|
16863
18475
|
}
|
|
16864
|
-
await entry
|
|
16865
|
-
await entry.server.close();
|
|
16866
|
-
sessions.delete(sessionId);
|
|
18476
|
+
await closeSessionEntry(sessionId, entry);
|
|
16867
18477
|
res.status(200).json({ status: "session_closed" });
|
|
16868
18478
|
}
|
|
16869
18479
|
);
|
|
16870
18480
|
app.use(
|
|
16871
18481
|
(err, _req, res, _next) => {
|
|
16872
|
-
|
|
16873
|
-
"[MCP HTTP] Unhandled Express error:",
|
|
16874
|
-
err.stack || err.message || err
|
|
16875
|
-
);
|
|
18482
|
+
logOperationalError("Unhandled Express error", err);
|
|
16876
18483
|
if (res.headersSent) return;
|
|
16877
18484
|
const e = err;
|
|
16878
18485
|
const status = e.status ?? e.statusCode;
|
|
@@ -16891,9 +18498,8 @@ var httpServer = app.listen(PORT, "0.0.0.0", () => {
|
|
|
16891
18498
|
`[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`
|
|
16892
18499
|
);
|
|
16893
18500
|
console.log(`[MCP HTTP] Health: http://localhost:${PORT}/health`);
|
|
16894
|
-
console.log(`[MCP HTTP] MCP endpoint: ${MCP_SERVER_URL}`);
|
|
18501
|
+
console.log(`[MCP HTTP] MCP endpoint: ${safeEndpointForLog(MCP_SERVER_URL)}`);
|
|
16895
18502
|
console.log(`[MCP HTTP] Tool profile: ${TOOL_PROFILE}`);
|
|
16896
|
-
console.log(`[MCP HTTP] Environment: ${NODE_ENV}`);
|
|
16897
18503
|
});
|
|
16898
18504
|
async function shutdown(signal) {
|
|
16899
18505
|
console.log(`[MCP HTTP] ${signal} received, shutting down...`);
|