@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/dist/sn.js CHANGED
@@ -19,7 +19,7 @@ var MCP_VERSION;
19
19
  var init_version = __esm({
20
20
  "src/lib/version.ts"() {
21
21
  "use strict";
22
- MCP_VERSION = "1.8.0";
22
+ MCP_VERSION = "1.8.2";
23
23
  }
24
24
  });
25
25
 
@@ -66,25 +66,140 @@ __export(credentials_exports, {
66
66
  saveSupabaseUrl: () => saveSupabaseUrl
67
67
  });
68
68
  import { execFileSync } from "node:child_process";
69
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
69
+ import {
70
+ closeSync,
71
+ constants,
72
+ existsSync,
73
+ fchmodSync,
74
+ fstatSync,
75
+ ftruncateSync,
76
+ lstatSync,
77
+ mkdirSync,
78
+ openSync,
79
+ readFileSync,
80
+ unlinkSync,
81
+ writeFileSync
82
+ } from "node:fs";
70
83
  import { homedir, platform } from "node:os";
71
84
  import { join } from "node:path";
85
+ function assertSafeCredentialPaths() {
86
+ if (platform() === "win32") return;
87
+ const uid = process.getuid?.();
88
+ if (existsSync(CONFIG_DIR)) {
89
+ const directory = lstatSync(CONFIG_DIR);
90
+ if (directory.isSymbolicLink() || !directory.isDirectory()) {
91
+ throw new Error("Unsafe Social Neuron credential directory. Refusing to use it.");
92
+ }
93
+ if (uid !== void 0 && directory.uid !== uid) {
94
+ throw new Error("Social Neuron credential directory is not owned by the current user.");
95
+ }
96
+ }
97
+ if (existsSync(CREDENTIALS_FILE)) {
98
+ const file = lstatSync(CREDENTIALS_FILE);
99
+ if (file.isSymbolicLink() || !file.isFile()) {
100
+ throw new Error("Unsafe Social Neuron credential file. Refusing to use it.");
101
+ }
102
+ if (uid !== void 0 && file.uid !== uid) {
103
+ throw new Error("Social Neuron credential file is not owned by the current user.");
104
+ }
105
+ }
106
+ }
107
+ function openValidatedCredentialPath(target, kind, flags) {
108
+ let fd;
109
+ try {
110
+ fd = openSync(
111
+ target,
112
+ flags | constants.O_NOFOLLOW | (kind === "directory" ? constants.O_DIRECTORY : 0)
113
+ );
114
+ } catch (error) {
115
+ if (error.code === "ENOENT") return null;
116
+ throw new Error(`Unsafe Social Neuron credential ${kind}. Refusing to use it.`);
117
+ }
118
+ const opened = fstatSync(fd);
119
+ const uid = process.getuid?.();
120
+ if ((kind === "directory" ? !opened.isDirectory() : !opened.isFile()) || uid !== void 0 && opened.uid !== uid || kind === "file" && opened.nlink !== 1) {
121
+ closeSync(fd);
122
+ throw new Error(`Unsafe Social Neuron credential ${kind}. Refusing to use it.`);
123
+ }
124
+ return fd;
125
+ }
126
+ function hardenCredentialPath(target, kind, mode) {
127
+ const fd = openValidatedCredentialPath(target, kind, constants.O_RDONLY);
128
+ if (fd === null) return;
129
+ try {
130
+ const opened = fstatSync(fd);
131
+ if ((opened.mode & 63) !== 0) fchmodSync(fd, mode);
132
+ } finally {
133
+ closeSync(fd);
134
+ }
135
+ }
136
+ function hardenCredentialPermissions() {
137
+ if (platform() === "win32") return;
138
+ hardenCredentialPath(CONFIG_DIR, "directory", 448);
139
+ hardenCredentialPath(CREDENTIALS_FILE, "file", 384);
140
+ }
72
141
  function readCredentialsFile() {
142
+ if (platform() !== "win32") hardenCredentialPath(CONFIG_DIR, "directory", 448);
143
+ const fd = platform() === "win32" ? (() => {
144
+ try {
145
+ return openSync(CREDENTIALS_FILE, constants.O_RDONLY);
146
+ } catch (error) {
147
+ if (error.code === "ENOENT") return null;
148
+ throw error;
149
+ }
150
+ })() : openValidatedCredentialPath(CREDENTIALS_FILE, "file", constants.O_RDONLY);
151
+ if (fd === null) return {};
73
152
  try {
74
- if (!existsSync(CREDENTIALS_FILE)) return {};
75
- const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
76
- return JSON.parse(raw);
77
- } catch {
78
- return {};
153
+ if (platform() !== "win32") fchmodSync(fd, 384);
154
+ const parsed = JSON.parse(readFileSync(fd, "utf-8"));
155
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
156
+ throw new Error("Social Neuron credential file is invalid.");
157
+ }
158
+ return parsed;
159
+ } finally {
160
+ closeSync(fd);
79
161
  }
80
162
  }
81
163
  function writeCredentialsFile(data) {
82
164
  if (!existsSync(CONFIG_DIR)) {
83
165
  mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
84
166
  }
85
- writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2) + "\n", {
86
- mode: 384
87
- });
167
+ assertSafeCredentialPaths();
168
+ const payload = JSON.stringify(data, null, 2) + "\n";
169
+ if (platform() === "win32") {
170
+ writeFileSync(CREDENTIALS_FILE, payload, { mode: 384 });
171
+ } else {
172
+ let fd;
173
+ try {
174
+ fd = openSync(
175
+ CREDENTIALS_FILE,
176
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
177
+ 384
178
+ );
179
+ } catch (error) {
180
+ if (error.code !== "EEXIST") {
181
+ throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
182
+ }
183
+ try {
184
+ fd = openSync(CREDENTIALS_FILE, constants.O_WRONLY | constants.O_NOFOLLOW);
185
+ } catch {
186
+ throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
187
+ }
188
+ }
189
+ try {
190
+ const opened = fstatSync(fd);
191
+ const uid = process.getuid?.();
192
+ if (!opened.isFile() || opened.nlink !== 1 || uid !== void 0 && opened.uid !== uid) {
193
+ throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
194
+ }
195
+ fchmodSync(fd, 384);
196
+ ftruncateSync(fd, 0);
197
+ writeFileSync(fd, payload);
198
+ } finally {
199
+ closeSync(fd);
200
+ }
201
+ }
202
+ hardenCredentialPermissions();
88
203
  }
89
204
  function macKeychainRead(service) {
90
205
  try {
@@ -294,7 +409,6 @@ async function validateApiKey(apiKey, _attempt = 0) {
294
409
  }
295
410
  );
296
411
  if (!response.ok) {
297
- const text = await response.text();
298
412
  const retryable = response.status === 429 || response.status >= 500;
299
413
  if (retryable && _attempt < VALIDATE_MAX_RETRIES) {
300
414
  await sleep(300 * (_attempt + 1));
@@ -303,11 +417,19 @@ async function validateApiKey(apiKey, _attempt = 0) {
303
417
  return {
304
418
  valid: false,
305
419
  retryable,
306
- error: `Validation failed (HTTP ${response.status}): ${text}`
420
+ error: `Validation failed (HTTP ${response.status}).`
307
421
  };
308
422
  }
309
- return await response.json();
310
- } catch (err) {
423
+ const result = await response.json();
424
+ if (!result.valid) {
425
+ return {
426
+ valid: false,
427
+ retryable: false,
428
+ error: "API key is invalid, expired, or revoked."
429
+ };
430
+ }
431
+ return result;
432
+ } catch {
311
433
  if (_attempt < VALIDATE_MAX_RETRIES) {
312
434
  await sleep(300 * (_attempt + 1));
313
435
  return validateApiKey(apiKey, _attempt + 1);
@@ -315,7 +437,7 @@ async function validateApiKey(apiKey, _attempt = 0) {
315
437
  return {
316
438
  valid: false,
317
439
  retryable: true,
318
- error: err instanceof Error ? err.message : String(err)
440
+ error: "Authentication service is temporarily unavailable."
319
441
  };
320
442
  }
321
443
  }
@@ -336,10 +458,12 @@ __export(posthog_exports, {
336
458
  initPostHog: () => initPostHog,
337
459
  shutdownPostHog: () => shutdownPostHog
338
460
  });
339
- import { createHash } from "node:crypto";
461
+ import { createHmac, randomBytes } from "node:crypto";
340
462
  import { PostHog } from "posthog-node";
341
463
  function hashUserId(userId) {
342
- return createHash("sha256").update(`${POSTHOG_SALT}:${userId}`).digest("hex").substring(0, 32);
464
+ const hmac = createHmac("sha256", POSTHOG_PSEUDONYMIZATION_KEY);
465
+ hmac.update(userId);
466
+ return hmac.digest("hex").substring(0, 32);
343
467
  }
344
468
  function initPostHog() {
345
469
  if (isTelemetryDisabled()) return;
@@ -375,13 +499,13 @@ async function shutdownPostHog() {
375
499
  client = null;
376
500
  }
377
501
  }
378
- var POSTHOG_SALT, client;
502
+ var POSTHOG_PSEUDONYMIZATION_KEY, client;
379
503
  var init_posthog = __esm({
380
504
  "src/lib/posthog.ts"() {
381
505
  "use strict";
382
506
  init_supabase();
383
507
  init_request_context();
384
- POSTHOG_SALT = "socialneuron-mcp-ph-v1";
508
+ POSTHOG_PSEUDONYMIZATION_KEY = process.env.POSTHOG_PSEUDONYMIZATION_KEY || randomBytes(32).toString("hex");
385
509
  client = null;
386
510
  }
387
511
  });
@@ -603,7 +727,7 @@ var init_supabase = __esm({
603
727
  });
604
728
 
605
729
  // src/cli/sn/parse.ts
606
- import { createHash as createHash2 } from "node:crypto";
730
+ import { createHash } from "node:crypto";
607
731
  function parseSnArgs(argv) {
608
732
  const parsed = { _: [] };
609
733
  for (let i = 0; i < argv.length; i += 1) {
@@ -633,8 +757,8 @@ function isEnabledFlag(value) {
633
757
  }
634
758
  function emitSnResult(payload, asJson) {
635
759
  if (asJson) {
636
- const envelope = { schema_version: "1", ...payload };
637
- process.stdout.write(JSON.stringify(envelope, null, 2) + "\n");
760
+ const envelope2 = { schema_version: "1", ...payload };
761
+ process.stdout.write(JSON.stringify(envelope2, null, 2) + "\n");
638
762
  }
639
763
  }
640
764
  function isValidHttpsUrl(value) {
@@ -669,7 +793,7 @@ async function checkUrlReachability(url) {
669
793
  } catch (error) {
670
794
  return {
671
795
  ok: false,
672
- error: error instanceof Error ? error.message : String(error)
796
+ error: "Network request failed."
673
797
  };
674
798
  } finally {
675
799
  clearTimeout(timer);
@@ -699,7 +823,7 @@ function buildPublishIdempotencyKey(input) {
699
823
  title: input.title ?? "",
700
824
  scheduledAt: input.scheduledAt ?? ""
701
825
  });
702
- return `sn_${createHash2("sha256").update(material).digest("hex").slice(0, 24)}`;
826
+ return `sn_${createHash("sha256").update(material).digest("hex").slice(0, 24)}`;
703
827
  }
704
828
  function normalizePlatforms(platformsRaw) {
705
829
  const caseMap = {
@@ -802,6 +926,52 @@ var edge_function_exports = {};
802
926
  __export(edge_function_exports, {
803
927
  callEdgeFunction: () => callEdgeFunction
804
928
  });
929
+ function safeGatewayError(responseText, status) {
930
+ try {
931
+ const parsed = JSON.parse(responseText);
932
+ const nested = parsed.error && typeof parsed.error === "object" ? parsed.error : null;
933
+ const candidates = [
934
+ nested?.error_type,
935
+ nested?.code,
936
+ parsed.error_type,
937
+ parsed.error_code,
938
+ parsed.code,
939
+ typeof parsed.error === "string" ? parsed.error : null
940
+ ];
941
+ for (const value of candidates) {
942
+ if (typeof value !== "string") continue;
943
+ const normalized = value.trim().toLowerCase();
944
+ if (SAFE_GATEWAY_ERROR_CODES.has(normalized)) return normalized;
945
+ const embedded = [...SAFE_GATEWAY_ERROR_CODES].find((code) => normalized.includes(code));
946
+ if (embedded) return embedded;
947
+ }
948
+ } catch {
949
+ }
950
+ return `Backend request failed (HTTP ${status}).`;
951
+ }
952
+ function safeFailureData(responseText) {
953
+ try {
954
+ const parsed = JSON.parse(responseText);
955
+ const metric = (name) => {
956
+ const value = parsed[name];
957
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
958
+ };
959
+ const billingStatus = typeof parsed.billing_status === "string" && SAFE_BILLING_STATUSES.has(parsed.billing_status) ? parsed.billing_status : void 0;
960
+ const failureReason = parsed.failure_reason === "generation_failed" || parsed.failure_reason === "authentication_failed" || parsed.failure_reason === "cancelled_by_user" ? parsed.failure_reason : void 0;
961
+ const jobStatus = typeof parsed.status === "string" && SAFE_JOB_STATUSES.has(parsed.status) ? parsed.status : void 0;
962
+ const safe = {
963
+ ...jobStatus ? { status: jobStatus } : {},
964
+ ...metric("credits_reserved") !== void 0 ? { credits_reserved: metric("credits_reserved") } : {},
965
+ ...metric("credits_charged") !== void 0 ? { credits_charged: metric("credits_charged") } : {},
966
+ ...metric("credits_refunded") !== void 0 ? { credits_refunded: metric("credits_refunded") } : {},
967
+ ...billingStatus ? { billing_status: billingStatus } : {},
968
+ ...failureReason ? { failure_reason: failureReason } : {}
969
+ };
970
+ return Object.keys(safe).length > 0 ? safe : null;
971
+ } catch {
972
+ return null;
973
+ }
974
+ }
805
975
  function getApiKeyOrNull() {
806
976
  const envKey = process.env.SOCIALNEURON_API_KEY;
807
977
  if (envKey && envKey.trim().length) return envKey.trim();
@@ -872,33 +1042,28 @@ async function callEdgeFunction(functionName, body, options) {
872
1042
  clearTimeout(timer);
873
1043
  const responseText = await response.text();
874
1044
  if (!response.ok) {
875
- let errorMessage;
876
- try {
877
- const errorJson = JSON.parse(responseText);
878
- errorMessage = errorJson.error || errorJson.message || responseText;
879
- } catch {
880
- errorMessage = responseText || `HTTP ${response.status}`;
881
- }
1045
+ const errorCode = safeGatewayError(responseText, response.status);
1046
+ const failureData = safeFailureData(responseText);
882
1047
  if (response.status === 401) {
883
1048
  return {
884
- data: null,
1049
+ data: failureData,
885
1050
  error: `Authentication failed (HTTP 401). Run 'npx @socialneuron/mcp-server login' to re-authenticate.`
886
1051
  };
887
1052
  }
888
1053
  if (response.status === 403) {
889
1054
  return {
890
- data: null,
891
- error: `Forbidden (HTTP 403): ${errorMessage}. This action isn't permitted for your account, plan, or scope \u2014 your connection is still valid.`
1055
+ data: failureData,
1056
+ error: `Forbidden (HTTP 403): ${errorCode}. This action isn't permitted for your account, plan, or scope \u2014 your connection is still valid.`
892
1057
  };
893
1058
  }
894
1059
  if (response.status === 429) {
895
1060
  const retryAfter = response.headers.get("retry-after") || "60";
896
1061
  return {
897
- data: null,
1062
+ data: failureData,
898
1063
  error: `Rate limit exceeded (HTTP 429). Wait ${retryAfter}s before retrying. Reduce request frequency or upgrade your plan.`
899
1064
  };
900
1065
  }
901
- return { data: null, error: errorMessage };
1066
+ return { data: failureData, error: errorCode };
902
1067
  }
903
1068
  try {
904
1069
  const data = JSON.parse(responseText);
@@ -914,15 +1079,48 @@ async function callEdgeFunction(functionName, body, options) {
914
1079
  error: `Edge Function '${functionName}' timed out after ${timeoutMs}ms`
915
1080
  };
916
1081
  }
917
- const message = err instanceof Error ? err.message : String(err);
918
- return { data: null, error: message };
1082
+ return { data: null, error: "Network request failed. Please retry." };
919
1083
  }
920
1084
  }
1085
+ var SAFE_GATEWAY_ERROR_CODES, SAFE_BILLING_STATUSES, SAFE_JOB_STATUSES;
921
1086
  var init_edge_function = __esm({
922
1087
  "src/lib/edge-function.ts"() {
923
1088
  "use strict";
924
1089
  init_supabase();
925
1090
  init_request_context();
1091
+ SAFE_GATEWAY_ERROR_CODES = /* @__PURE__ */ new Set([
1092
+ "daily_limit_reached",
1093
+ "insufficient_credits",
1094
+ "project_scope_mismatch",
1095
+ "schedule_conflict",
1096
+ "post_not_found",
1097
+ "post_not_reschedulable",
1098
+ "post_in_progress",
1099
+ "not_cancellable",
1100
+ "publishing_in_progress",
1101
+ "plan_upgrade_required",
1102
+ "rate_limited",
1103
+ "validation_error",
1104
+ "permission_denied",
1105
+ "not_found"
1106
+ ]);
1107
+ SAFE_BILLING_STATUSES = /* @__PURE__ */ new Set([
1108
+ "reserved",
1109
+ "charged",
1110
+ "refunded",
1111
+ "failed_no_charge",
1112
+ "refund_pending",
1113
+ "not_charged"
1114
+ ]);
1115
+ SAFE_JOB_STATUSES = /* @__PURE__ */ new Set([
1116
+ "queued",
1117
+ "pending",
1118
+ "processing",
1119
+ "completed",
1120
+ "failed",
1121
+ "cancelled",
1122
+ "canceled"
1123
+ ]);
926
1124
  }
927
1125
  });
928
1126
 
@@ -938,6 +1136,7 @@ function evaluateQuality(input) {
938
1136
  const firstLine = caption.split("\n")[0]?.trim() ?? "";
939
1137
  const hashtags = countHashtags(caption);
940
1138
  const threshold = Math.min(35, Math.max(0, input.threshold ?? 26));
1139
+ const isShortFormX = platforms.length > 0 && platforms.every((p) => p === "twitter") && caption.length <= 280;
941
1140
  const blockedTerms = [
942
1141
  ...(input.brandAvoidPatterns ?? []).map((t) => t.trim()).filter(Boolean),
943
1142
  ...(input.customBannedTerms ?? []).map((t) => t.trim()).filter(Boolean)
@@ -947,11 +1146,12 @@ function evaluateQuality(input) {
947
1146
  if (firstLine.length >= 20 && firstLine.length <= 120) hookScore += 1;
948
1147
  if (/[!?]/.test(firstLine) || /\b\d+(\.\d+)?\b/.test(firstLine)) hookScore += 1;
949
1148
  if (/\b(how|why|stop|avoid|build|launch|scale|grow|mistake)\b/i.test(firstLine)) hookScore += 1;
1149
+ if (isShortFormX) hookScore = Math.max(hookScore, 3);
950
1150
  categories.push({
951
1151
  name: "Hook Strength",
952
1152
  score: Math.min(5, hookScore),
953
1153
  maxScore: 5,
954
- detail: "First line should create curiosity/value within 120 chars."
1154
+ detail: isShortFormX ? "Short-form X: keyword-hook heuristics neutralized; substance judge arbitrates." : "First line should create curiosity/value within 120 chars."
955
1155
  });
956
1156
  let clarityScore = 2;
957
1157
  if (caption.length >= 80 && caption.length <= 1200) clarityScore += 2;
@@ -977,7 +1177,8 @@ function evaluateQuality(input) {
977
1177
  detail: "Length, title, and hashtag usage should match target platforms."
978
1178
  });
979
1179
  let brandScore = 3;
980
- const brandKeyword = input.brandKeyword ?? process.env.SOCIALNEURON_BRAND_KEYWORD?.trim();
1180
+ const rawBrandKeyword = input.brandKeyword ?? process.env.SOCIALNEURON_BRAND_KEYWORD?.trim();
1181
+ const brandKeyword = rawBrandKeyword ? rawBrandKeyword.slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : null;
981
1182
  if (brandKeyword && new RegExp("\\b" + brandKeyword + "\\b", "i").test(title + " " + caption))
982
1183
  brandScore += 1;
983
1184
  if (!/\b(you|your|customer|audience)\b/i.test(caption)) brandScore -= 1;
@@ -988,11 +1189,12 @@ function evaluateQuality(input) {
988
1189
  brandScore -= Math.min(2, matched.length);
989
1190
  }
990
1191
  }
1192
+ if (isShortFormX) brandScore = Math.max(brandScore, 3);
991
1193
  categories.push({
992
1194
  name: "Brand Alignment",
993
1195
  score: Math.max(0, Math.min(5, brandScore)),
994
1196
  maxScore: 5,
995
- detail: "Voice should match brand context and audience focus."
1197
+ detail: isShortFormX ? "Short-form X: second-person-address heuristic neutralized; blocked terms still enforced." : "Voice should match brand context and audience focus."
996
1198
  });
997
1199
  let noveltyScore = 2;
998
1200
  if (/\b(case study|framework|workflow|playbook|breakdown|behind the scenes)\b/i.test(caption))
@@ -1003,21 +1205,23 @@ function evaluateQuality(input) {
1003
1205
  const matched = blockedTerms.filter((term) => lowerCombined.includes(term.toLowerCase()));
1004
1206
  if (matched.length > 0) noveltyScore -= Math.min(2, matched.length);
1005
1207
  }
1208
+ if (isShortFormX) noveltyScore = Math.max(noveltyScore, 3);
1006
1209
  categories.push({
1007
1210
  name: "Novelty",
1008
1211
  score: Math.max(0, Math.min(5, noveltyScore)),
1009
1212
  maxScore: 5,
1010
- detail: "Avoid generic phrasing; include distinct angle."
1213
+ detail: isShortFormX ? "Short-form X: keyword-novelty neutralized; substance judge arbitrates." : "Avoid generic phrasing; include distinct angle."
1011
1214
  });
1012
1215
  let ctaScore = 2;
1013
1216
  if (/\b(comment|reply|share|save|follow|subscribe|click|try|book|download)\b/i.test(caption))
1014
1217
  ctaScore += 2;
1015
1218
  if (/\?$/.test(firstLine)) ctaScore += 1;
1219
+ if (isShortFormX) ctaScore = Math.max(ctaScore, 3);
1016
1220
  categories.push({
1017
1221
  name: "CTA Strength",
1018
1222
  score: Math.min(5, ctaScore),
1019
1223
  maxScore: 5,
1020
- detail: "Should include a clear next action."
1224
+ detail: isShortFormX ? "Short-form X: explicit CTA optional; native posts may close without one." : "Should include a clear next action."
1021
1225
  });
1022
1226
  let safetyScore = 5;
1023
1227
  if (/\b(guarantee|guaranteed|no risk|risk-free|always works|100%)\b/i.test(caption))
@@ -2022,9 +2226,6 @@ function searchTools(query) {
2022
2226
  (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)
2023
2227
  );
2024
2228
  }
2025
- function getModules() {
2026
- return [...new Set(TOOL_CATALOG.map((t) => t.module))];
2027
- }
2028
2229
  var TOOL_CATALOG;
2029
2230
  var init_tool_catalog = __esm({
2030
2231
  "src/lib/tool-catalog.ts"() {
@@ -2099,6 +2300,26 @@ var init_tool_catalog = __esm({
2099
2300
  module: "carousel",
2100
2301
  scope: "mcp:write"
2101
2302
  },
2303
+ {
2304
+ name: "cancel_async_job",
2305
+ description: "Cancel an owned pending async job and refund an eligible debit",
2306
+ module: "lifecycle",
2307
+ scope: "mcp:write",
2308
+ task_intent: "Stop a queued generation or render before worker execution",
2309
+ use_when: "The user explicitly wants to cancel a pending job and has confirmed the action.",
2310
+ avoid_when: "The job is already processing or terminal; check_status instead.",
2311
+ next_tools: ["check_status", "get_credit_balance"]
2312
+ },
2313
+ {
2314
+ name: "delete_carousel",
2315
+ description: "Delete an owned carousel content record from one project",
2316
+ module: "lifecycle",
2317
+ scope: "mcp:write",
2318
+ task_intent: "Remove an unwanted carousel record from Social Neuron",
2319
+ use_when: "The user explicitly confirms deletion and understands stored media follows normal retention.",
2320
+ avoid_when: "The user expects an already-published social post or retained media object to be removed.",
2321
+ next_tools: ["list_recent_posts"]
2322
+ },
2102
2323
  // media
2103
2324
  {
2104
2325
  name: "upload_media",
@@ -2119,6 +2340,26 @@ var init_tool_catalog = __esm({
2119
2340
  module: "distribution",
2120
2341
  scope: "mcp:distribute"
2121
2342
  },
2343
+ {
2344
+ name: "reschedule_post",
2345
+ description: "Atomically move an unclaimed scheduled post to a new time within its brand project",
2346
+ module: "distribution",
2347
+ scope: "mcp:distribute",
2348
+ task_intent: "Move an already scheduled post without creating a duplicate publication",
2349
+ use_when: "A pending or scheduled post needs a different future publication time.",
2350
+ avoid_when: "The post is already publishing or published; create a new post instead.",
2351
+ next_tools: ["list_recent_posts", "open_content_calendar"]
2352
+ },
2353
+ {
2354
+ name: "cancel_scheduled_post",
2355
+ description: "Cancel an owned scheduled post before publishing begins",
2356
+ module: "lifecycle",
2357
+ scope: "mcp:distribute",
2358
+ task_intent: "Unschedule a post without publishing it",
2359
+ use_when: "The user explicitly confirms cancellation of a draft, pending, or scheduled post.",
2360
+ avoid_when: "The post is already publishing or published.",
2361
+ next_tools: ["list_recent_posts", "open_content_calendar"]
2362
+ },
2122
2363
  {
2123
2364
  name: "list_recent_posts",
2124
2365
  description: "List recently published or scheduled posts",
@@ -2309,6 +2550,16 @@ var init_tool_catalog = __esm({
2309
2550
  module: "planning",
2310
2551
  scope: "mcp:write"
2311
2552
  },
2553
+ {
2554
+ name: "delete_content_plan",
2555
+ description: "Permanently delete an owned content plan from one project",
2556
+ module: "lifecycle",
2557
+ scope: "mcp:write",
2558
+ task_intent: "Remove an obsolete saved content plan",
2559
+ use_when: "The user explicitly confirms permanent deletion of a specific plan.",
2560
+ avoid_when: "The plan has scheduled posts the user also expects to cancel; cancel those posts separately.",
2561
+ next_tools: ["plan_content_week", "list_recent_posts"]
2562
+ },
2312
2563
  {
2313
2564
  name: "submit_content_plan_for_approval",
2314
2565
  description: "Submit a content plan for team approval",
@@ -2403,6 +2654,16 @@ var init_tool_catalog = __esm({
2403
2654
  module: "autopilot",
2404
2655
  scope: "mcp:autopilot"
2405
2656
  },
2657
+ {
2658
+ name: "delete_autopilot_config",
2659
+ description: "Permanently delete an owned autopilot configuration from one project",
2660
+ module: "lifecycle",
2661
+ scope: "mcp:autopilot",
2662
+ task_intent: "Remove an autopilot configuration while retaining historical run records",
2663
+ use_when: "The user explicitly confirms deletion of the configuration.",
2664
+ avoid_when: "The user only wants to pause or edit automation, or expects historical posts to be deleted.",
2665
+ next_tools: ["list_autopilot_configs"]
2666
+ },
2406
2667
  // extraction
2407
2668
  {
2408
2669
  name: "extract_url_content",
@@ -2519,7 +2780,13 @@ var init_tool_catalog = __esm({
2519
2780
  // apps (MCP Apps — interactive UI inside the host)
2520
2781
  {
2521
2782
  name: "open_content_calendar",
2522
- description: "Open an interactive drag-drop calendar of the user's scheduled posts inside the host (Claude Desktop / claude.ai). Renders an MCP App; backed by list_recent_posts, schedule_post, find_next_slots \u2014 no new tools needed.",
2783
+ 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.",
2784
+ module: "apps",
2785
+ scope: "mcp:read"
2786
+ },
2787
+ {
2788
+ name: "open_analytics_pulse",
2789
+ description: "Open a project-scoped interactive performance dashboard with views, engagement, platform mix, and top posts.",
2523
2790
  module: "apps",
2524
2791
  scope: "mcp:read"
2525
2792
  },
@@ -2634,6 +2901,12 @@ var init_tool_catalog = __esm({
2634
2901
  module: "skills",
2635
2902
  scope: "mcp:read"
2636
2903
  },
2904
+ {
2905
+ name: "get_skill",
2906
+ description: "Fetch the full body and current compiled guidance for one Social Neuron skill by slug.",
2907
+ module: "skills",
2908
+ scope: "mcp:read"
2909
+ },
2637
2910
  {
2638
2911
  name: "run_skill",
2639
2912
  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.",
@@ -2665,17 +2938,21 @@ __export(discovery_exports, {
2665
2938
  handleInfo: () => handleInfo,
2666
2939
  handleTools: () => handleTools
2667
2940
  });
2941
+ function publicStdioTools() {
2942
+ return TOOL_CATALOG.filter(
2943
+ (t) => !t.internal && !t.hiddenFromPublicCount && t.module !== "apps"
2944
+ );
2945
+ }
2668
2946
  async function handleTools(args, asJson) {
2669
- let tools = TOOL_CATALOG;
2947
+ let tools = publicStdioTools();
2670
2948
  const scope = args.scope;
2671
2949
  if (typeof scope === "string") {
2672
- tools = getToolsByScope(scope);
2950
+ tools = tools.filter((tool) => tool.scope === scope);
2673
2951
  }
2674
2952
  const module = args.module;
2675
2953
  if (typeof module === "string") {
2676
- tools = getToolsByModule(module);
2954
+ tools = tools.filter((tool) => tool.module === module);
2677
2955
  }
2678
- tools = tools.filter((t) => !t.internal);
2679
2956
  if (asJson) {
2680
2957
  emitSnResult({ ok: true, command: "tools", toolCount: tools.length, tools }, true);
2681
2958
  return;
@@ -2704,27 +2981,31 @@ Module: ${moduleName} (${moduleTools.length} tools)`);
2704
2981
  process.exit(0);
2705
2982
  }
2706
2983
  async function handleInfo(args, asJson) {
2984
+ const stdioTools = publicStdioTools();
2707
2985
  const info = {
2708
2986
  version: MCP_VERSION,
2709
- toolCount: TOOL_CATALOG.filter((t) => !t.internal).length,
2710
- modules: getModules()
2987
+ toolCount: stdioTools.length,
2988
+ modules: Array.from(new Set(stdioTools.map((tool) => tool.module))).sort(),
2989
+ auth: null
2711
2990
  };
2712
- try {
2713
- const { loadApiKey: loadApiKey2 } = await Promise.resolve().then(() => (init_credentials(), credentials_exports));
2714
- const { validateApiKey: validateApiKey2 } = await Promise.resolve().then(() => (init_api_keys(), api_keys_exports));
2715
- const apiKey = await loadApiKey2();
2716
- if (apiKey) {
2717
- const result = await validateApiKey2(apiKey);
2718
- if (result.valid) {
2719
- info.auth = {
2720
- email: result.email || null,
2721
- scopes: result.scopes || [],
2722
- expiresAt: result.expiresAt || null
2723
- };
2991
+ if (!isEnabledFlag(args.offline)) {
2992
+ try {
2993
+ const { loadApiKey: loadApiKey2 } = await Promise.resolve().then(() => (init_credentials(), credentials_exports));
2994
+ const { validateApiKey: validateApiKey2 } = await Promise.resolve().then(() => (init_api_keys(), api_keys_exports));
2995
+ const apiKey = await loadApiKey2();
2996
+ if (apiKey) {
2997
+ const result = await validateApiKey2(apiKey);
2998
+ if (result.valid) {
2999
+ info.auth = {
3000
+ email: result.email || null,
3001
+ scopes: result.scopes || [],
3002
+ expiresAt: result.expiresAt || null
3003
+ };
3004
+ }
2724
3005
  }
3006
+ } catch {
3007
+ info.auth = null;
2725
3008
  }
2726
- } catch {
2727
- info.auth = null;
2728
3009
  }
2729
3010
  if (info.auth) {
2730
3011
  try {
@@ -2838,6 +3119,9 @@ var init_scopes = __esm({
2838
3119
  generate_carousel: "mcp:write",
2839
3120
  create_carousel: "mcp:write",
2840
3121
  upload_media: "mcp:write",
3122
+ cancel_async_job: "mcp:write",
3123
+ delete_carousel: "mcp:write",
3124
+ delete_content_plan: "mcp:write",
2841
3125
  // mcp:read (media)
2842
3126
  get_media_url: "mcp:read",
2843
3127
  // F4 Hyperframes — HTML composition runtime
@@ -2845,6 +3129,8 @@ var init_scopes = __esm({
2845
3129
  render_hyperframes: "mcp:write",
2846
3130
  // mcp:distribute
2847
3131
  schedule_post: "mcp:distribute",
3132
+ reschedule_post: "mcp:distribute",
3133
+ cancel_scheduled_post: "mcp:distribute",
2848
3134
  start_platform_connection: "mcp:distribute",
2849
3135
  // mcp:analytics
2850
3136
  refresh_platform_analytics: "mcp:analytics",
@@ -2859,6 +3145,7 @@ var init_scopes = __esm({
2859
3145
  list_autopilot_configs: "mcp:autopilot",
2860
3146
  update_autopilot_config: "mcp:autopilot",
2861
3147
  get_autopilot_status: "mcp:autopilot",
3148
+ delete_autopilot_config: "mcp:autopilot",
2862
3149
  // Recipes
2863
3150
  list_recipes: "mcp:read",
2864
3151
  get_recipe_details: "mcp:read",
@@ -2902,6 +3189,7 @@ var init_scopes = __esm({
2902
3189
  detect_anomalies: "mcp:analytics",
2903
3190
  // mcp:read (Apps — entry tool for the Content Calendar MCP App; reads recent posts)
2904
3191
  open_content_calendar: "mcp:read",
3192
+ open_analytics_pulse: "mcp:read",
2905
3193
  // mcp:write (Agentic harness — learning loop write-back)
2906
3194
  write_agent_reflection: "mcp:write",
2907
3195
  record_outcome: "mcp:write",
@@ -2917,6 +3205,7 @@ var init_scopes = __esm({
2917
3205
  get_active_campaigns: "mcp:read",
2918
3206
  // mcp:read / mcp:write (Skills)
2919
3207
  list_skills: "mcp:read",
3208
+ get_skill: "mcp:read",
2920
3209
  run_skill: "mcp:write",
2921
3210
  // mcp:read (Loop observability — growth-loop KPIs + content learning state)
2922
3211
  get_loop_pulse: "mcp:read",
@@ -3043,6 +3332,11 @@ var init_tool_annotations = __esm({
3043
3332
  OVERRIDES = {
3044
3333
  // Destructive or overwrite tools
3045
3334
  delete_comment: { destructiveHint: true },
3335
+ cancel_async_job: { destructiveHint: true, idempotentHint: true },
3336
+ cancel_scheduled_post: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
3337
+ delete_carousel: { destructiveHint: true, idempotentHint: true },
3338
+ delete_content_plan: { destructiveHint: true, idempotentHint: true },
3339
+ delete_autopilot_config: { destructiveHint: true, idempotentHint: true },
3046
3340
  moderate_comment: { destructiveHint: true },
3047
3341
  save_brand_profile: { destructiveHint: true, idempotentHint: true },
3048
3342
  update_platform_voice: { destructiveHint: true, idempotentHint: true },
@@ -3207,6 +3501,7 @@ var init_tool_profile = __esm({
3207
3501
  "list_hyperframes_blocks",
3208
3502
  "render_hyperframes",
3209
3503
  "list_skills",
3504
+ "get_skill",
3210
3505
  "run_skill"
3211
3506
  ]);
3212
3507
  }
@@ -3240,7 +3535,8 @@ var init_constants = __esm({
3240
3535
  ssn: "\\d{3}-\\d{2}-\\d{4}",
3241
3536
  ip: "(?<![\\d.])(?:\\d{1,3}\\.){3}\\d{1,3}(?![\\d.])"
3242
3537
  },
3243
- MAX_LENGTH: 1e4
3538
+ MAX_LENGTH: 1e4,
3539
+ MAX_OUTPUT_LENGTH: 1e6
3244
3540
  };
3245
3541
  }
3246
3542
  });
@@ -3337,7 +3633,8 @@ var init_pii = __esm({
3337
3633
  function scan(text, options) {
3338
3634
  const flagged = /* @__PURE__ */ new Set();
3339
3635
  let risk = 0;
3340
- if (text.length > CONSTANTS.MAX_LENGTH) {
3636
+ const maxLength = options.source === "mcp_tool_output" ? CONSTANTS.MAX_OUTPUT_LENGTH : CONSTANTS.MAX_LENGTH;
3637
+ if (text.length > maxLength) {
3341
3638
  return {
3342
3639
  passed: false,
3343
3640
  risk_score: 1,
@@ -3412,6 +3709,7 @@ var CATEGORY_CONFIGS, RateLimiter, limiters;
3412
3709
  var init_rate_limit = __esm({
3413
3710
  "src/lib/rate-limit.ts"() {
3414
3711
  "use strict";
3712
+ init_scopes();
3415
3713
  CATEGORY_CONFIGS = {
3416
3714
  posting: { maxTokens: 30, refillRate: 30 / 60 },
3417
3715
  // 30 req/min — publish/schedule/comment
@@ -3502,8 +3800,8 @@ function registerIdeationTools(server) {
3502
3800
  brand_voice: z.string().max(500).optional().describe(
3503
3801
  '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.'
3504
3802
  ),
3505
- model: z.enum(["gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"]).optional().describe(
3506
- "AI model to use. Defaults to gemini-2.5-flash. Use gemini-2.5-pro for highest quality."
3803
+ model: z.enum(["gemini-2.5-flash", "gemini-2.5-pro"]).optional().describe(
3804
+ "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."
3507
3805
  ),
3508
3806
  project_id: z.string().uuid().optional().describe(
3509
3807
  "Project ID to auto-load brand profile and performance context for prompt enrichment."
@@ -3512,7 +3810,7 @@ function registerIdeationTools(server) {
3512
3810
  async ({ prompt: prompt2, content_type, platform: platform3, brand_voice, model, project_id }) => {
3513
3811
  try {
3514
3812
  const userId = await getDefaultUserId();
3515
- const rl = checkRateLimit("posting", userId);
3813
+ const rl = checkRateLimit("generation", `generate_content:${userId}`);
3516
3814
  if (!rl.allowed) {
3517
3815
  return {
3518
3816
  content: [
@@ -3769,7 +4067,7 @@ Content Type: ${content_type}`;
3769
4067
  async ({ content, source_platform, target_platform, brand_voice, project_id }) => {
3770
4068
  try {
3771
4069
  const userId = await getDefaultUserId();
3772
- const rl = checkRateLimit("posting", userId);
4070
+ const rl = checkRateLimit("generation", `adapt_content:${userId}`);
3773
4071
  if (!rl.allowed) {
3774
4072
  return {
3775
4073
  content: [
@@ -3881,11 +4179,12 @@ function buildCheckStatusPayload(job, liveStatus) {
3881
4179
  const progress = liveStatus?.progress ?? null;
3882
4180
  const resultUrl = liveStatus?.resultUrl ?? job.result_url ?? null;
3883
4181
  const r2Key = resultUrl && !resultUrl.startsWith("http") ? resultUrl : null;
3884
- const errorMessage = liveStatus?.error ?? job.error_message ?? null;
4182
+ const rawError = liveStatus?.error ?? job.error_message ?? null;
4183
+ const errorMessage = rawError && status === "failed" ? "Generation failed. Retry or choose another model." : rawError && (status === "cancelled" || status === "canceled") ? "Cancelled by user." : null;
3885
4184
  const allUrls = job.result_metadata?.all_urls ?? null;
3886
4185
  const modelRequested = job.result_metadata?.model_requested ?? null;
3887
4186
  const modelDelivered = job.result_metadata?.model_delivered ?? null;
3888
- const fallbackReason = job.result_metadata?.fallback_reason ?? null;
4187
+ const fallbackReason = job.result_metadata?.fallback_reason ? "Requested model was unavailable; a fallback model was used." : null;
3889
4188
  return {
3890
4189
  job_id: job.id,
3891
4190
  job_type: job.job_type,
@@ -3897,6 +4196,11 @@ function buildCheckStatusPayload(job, liveStatus) {
3897
4196
  all_urls: allUrls,
3898
4197
  error: errorMessage,
3899
4198
  credits_cost: job.credits_cost,
4199
+ credits_reserved: job.credits_reserved ?? null,
4200
+ credits_charged: job.credits_charged ?? null,
4201
+ credits_refunded: job.credits_refunded ?? null,
4202
+ billing_status: job.billing_status ?? "unknown",
4203
+ failure_reason: job.failure_reason ?? null,
3900
4204
  created_at: job.created_at,
3901
4205
  completed_at: job.completed_at,
3902
4206
  model_requested: modelRequested,
@@ -3998,6 +4302,13 @@ var init_budget = __esm({
3998
4302
 
3999
4303
  // src/tools/content.ts
4000
4304
  import { z as z2 } from "zod";
4305
+ function buildFallbackDisclosureLine(meta) {
4306
+ const requested = meta?.model_requested;
4307
+ const delivered = meta?.model_delivered;
4308
+ if (!requested || !delivered || requested === delivered) return null;
4309
+ const reason = meta?.fallback_reason ? " because the requested model was unavailable" : "";
4310
+ return `Note: requested "${requested}" but delivered "${delivered}"${reason} \u2014 cost never exceeds the requested model's price.`;
4311
+ }
4001
4312
  function asEnvelope2(data) {
4002
4313
  return {
4003
4314
  _meta: {
@@ -4066,7 +4377,7 @@ function registerContentTools(server) {
4066
4377
  isError: true
4067
4378
  };
4068
4379
  }
4069
- const rateLimit = checkRateLimit("posting", `generate_video:${userId}`);
4380
+ const rateLimit = checkRateLimit("generation", `generate_video:${userId}`);
4070
4381
  if (!rateLimit.allowed) {
4071
4382
  return {
4072
4383
  content: [
@@ -4164,7 +4475,7 @@ function registerContentTools(server) {
4164
4475
  );
4165
4476
  server.tool(
4166
4477
  "generate_image",
4167
- "Start an async AI image generation job \u2014 returns a job_id immediately. Poll with check_status every 5-15s until complete. Costs 2-10 credits depending on model. Use for social media posts, carousel slides, or as input to generate_video (image-to-video).",
4478
+ "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.",
4168
4479
  {
4169
4480
  prompt: z2.string().max(2e3).describe(
4170
4481
  "Text prompt describing the image to generate. Be specific about style, composition, colors, lighting, and subject matter."
@@ -4186,9 +4497,10 @@ function registerContentTools(server) {
4186
4497
  image_url: z2.string().optional().describe(
4187
4498
  "Reference image URL for image-to-image generation. Required for ideogram model. Optional for others."
4188
4499
  ),
4500
+ project_id: z2.string().optional().describe("Project ID to associate the generated image with."),
4189
4501
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4190
4502
  },
4191
- async ({ prompt: prompt2, model, aspect_ratio, image_url, response_format }) => {
4503
+ async ({ prompt: prompt2, model, aspect_ratio, image_url, project_id, response_format }) => {
4192
4504
  const format = response_format ?? "text";
4193
4505
  const userId = await getDefaultUserId();
4194
4506
  const assetBudget = checkAssetBudget();
@@ -4206,7 +4518,7 @@ function registerContentTools(server) {
4206
4518
  isError: true
4207
4519
  };
4208
4520
  }
4209
- const rateLimit = checkRateLimit("posting", `generate_image:${userId}`);
4521
+ const rateLimit = checkRateLimit("generation", `generate_image:${userId}`);
4210
4522
  if (!rateLimit.allowed) {
4211
4523
  return {
4212
4524
  content: [
@@ -4224,7 +4536,8 @@ function registerContentTools(server) {
4224
4536
  prompt: prompt2,
4225
4537
  model,
4226
4538
  aspectRatio: aspect_ratio ?? "1:1",
4227
- imageUrl: image_url
4539
+ imageUrl: image_url,
4540
+ ...project_id && { projectId: project_id }
4228
4541
  },
4229
4542
  { timeoutMs: 3e4 }
4230
4543
  );
@@ -4263,7 +4576,8 @@ function registerContentTools(server) {
4263
4576
  jobId,
4264
4577
  taskId: data.taskId,
4265
4578
  asyncJobId: data.asyncJobId,
4266
- model: data.model
4579
+ model: data.model,
4580
+ projectId: project_id ?? null
4267
4581
  }),
4268
4582
  null,
4269
4583
  2
@@ -4344,20 +4658,28 @@ function registerContentTools(server) {
4344
4658
  }
4345
4659
  );
4346
4660
  if (liveStatus) {
4661
+ const livePayload = buildCheckStatusPayload(job, liveStatus);
4347
4662
  const lines2 = [
4348
4663
  `Job: ${job.id}`,
4349
4664
  `Type: ${job.job_type}`,
4350
4665
  `Model: ${job.model}`,
4351
- `Status: ${liveStatus.status}`,
4352
- `Progress: ${liveStatus.progress}%`
4666
+ `Status: ${livePayload.status}`,
4667
+ `Progress: ${livePayload.progress}%`
4353
4668
  ];
4354
- if (liveStatus.resultUrl) {
4355
- lines2.push(`Result URL: ${liveStatus.resultUrl}`);
4669
+ if (livePayload.result_url) {
4670
+ lines2.push(`Result URL: ${livePayload.result_url}`);
4356
4671
  }
4357
- if (liveStatus.error) {
4358
- lines2.push(`Error: ${liveStatus.error}`);
4672
+ if (livePayload.error) {
4673
+ lines2.push(`Error: ${livePayload.error}`);
4359
4674
  }
4675
+ const fallbackDisclosure2 = buildFallbackDisclosureLine(job.result_metadata);
4676
+ if (fallbackDisclosure2) lines2.push(fallbackDisclosure2);
4360
4677
  lines2.push(`Credits: ${job.credits_cost}`);
4678
+ if (job.billing_status) {
4679
+ lines2.push(
4680
+ `Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
4681
+ );
4682
+ }
4361
4683
  lines2.push(`Created: ${job.created_at}`);
4362
4684
  if (format === "json") {
4363
4685
  return {
@@ -4375,7 +4697,7 @@ function registerContentTools(server) {
4375
4697
  // `job` + `liveStatus`; do not duplicate them here.
4376
4698
  asEnvelope2({
4377
4699
  ...liveStatus,
4378
- ...buildCheckStatusPayload(job, liveStatus)
4700
+ ...livePayload
4379
4701
  }),
4380
4702
  null,
4381
4703
  2
@@ -4418,7 +4740,14 @@ function registerContentTools(server) {
4418
4740
  if (job.error_message) {
4419
4741
  lines.push(`Error: ${job.error_message}`);
4420
4742
  }
4743
+ const fallbackDisclosure = buildFallbackDisclosureLine(job.result_metadata);
4744
+ if (fallbackDisclosure) lines.push(fallbackDisclosure);
4421
4745
  lines.push(`Credits: ${job.credits_cost}`);
4746
+ if (job.billing_status) {
4747
+ lines.push(
4748
+ `Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
4749
+ );
4750
+ }
4422
4751
  lines.push(`Created: ${job.created_at}`);
4423
4752
  if (job.completed_at) {
4424
4753
  lines.push(`Completed: ${job.completed_at}`);
@@ -4444,7 +4773,7 @@ function registerContentTools(server) {
4444
4773
  );
4445
4774
  server.tool(
4446
4775
  "create_storyboard",
4447
- "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 visual branding across frames.",
4776
+ "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.",
4448
4777
  {
4449
4778
  concept: z2.string().max(2e3).describe(
4450
4779
  '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").'
@@ -4468,6 +4797,7 @@ function registerContentTools(server) {
4468
4797
  style: z2.string().optional().describe(
4469
4798
  'Visual style direction (e.g., "cinematic", "anime", "documentary", "motion graphics").'
4470
4799
  ),
4800
+ project_id: z2.string().optional().describe("Project ID for brand-scoped generation and attribution."),
4471
4801
  response_format: z2.enum(["text", "json"]).optional().describe(
4472
4802
  "Response format. Defaults to json for structured storyboard data."
4473
4803
  )
@@ -4479,6 +4809,7 @@ function registerContentTools(server) {
4479
4809
  target_duration,
4480
4810
  num_scenes,
4481
4811
  style,
4812
+ project_id,
4482
4813
  response_format
4483
4814
  }) => {
4484
4815
  const format = response_format ?? "json";
@@ -4561,7 +4892,8 @@ Return ONLY valid JSON in this exact format:
4561
4892
  prompt: storyboardPrompt,
4562
4893
  type: "storyboard",
4563
4894
  model: "gemini-2.5-flash",
4564
- responseFormat: "json"
4895
+ responseFormat: "json",
4896
+ ...project_id && { projectId: project_id }
4565
4897
  },
4566
4898
  { timeoutMs: 6e4 }
4567
4899
  );
@@ -4576,7 +4908,18 @@ Return ONLY valid JSON in this exact format:
4576
4908
  isError: true
4577
4909
  };
4578
4910
  }
4579
- const rawContent = data?.content ?? "";
4911
+ const rawContent = data?.content?.trim() || data?.text?.trim() || "";
4912
+ if (!rawContent) {
4913
+ return {
4914
+ content: [
4915
+ {
4916
+ type: "text",
4917
+ text: "Storyboard generation failed: the AI service returned an empty response."
4918
+ }
4919
+ ],
4920
+ isError: true
4921
+ };
4922
+ }
4580
4923
  addCreditsUsed(estimatedCost);
4581
4924
  if (format === "json") {
4582
4925
  try {
@@ -4612,16 +4955,17 @@ Return ONLY valid JSON in this exact format:
4612
4955
  );
4613
4956
  server.tool(
4614
4957
  "generate_voiceover",
4615
- "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 ~2 credits per generation.",
4958
+ "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.",
4616
4959
  {
4617
4960
  text: z2.string().max(5e3).describe("The script/text to convert to speech."),
4618
4961
  voice: z2.enum(["rachel", "domi"]).optional().describe(
4619
4962
  "Voice selection. rachel=warm female, domi=confident female. Defaults to rachel."
4620
4963
  ),
4621
4964
  speed: z2.number().min(0.5).max(2).optional().describe("Speech speed multiplier. 1.0 is normal. Defaults to 1.0."),
4965
+ project_id: z2.string().optional().describe("Project ID to associate the generated voiceover with."),
4622
4966
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
4623
4967
  },
4624
- async ({ text, voice, speed, response_format }) => {
4968
+ async ({ text, voice, speed, project_id, response_format }) => {
4625
4969
  const format = response_format ?? "text";
4626
4970
  const userId = await getDefaultUserId();
4627
4971
  const estimatedCost = 15;
@@ -4633,7 +4977,7 @@ Return ONLY valid JSON in this exact format:
4633
4977
  };
4634
4978
  }
4635
4979
  const rateLimit = checkRateLimit(
4636
- "posting",
4980
+ "generation",
4637
4981
  `generate_voiceover:${userId}`
4638
4982
  );
4639
4983
  if (!rateLimit.allowed) {
@@ -4656,7 +5000,8 @@ Return ONLY valid JSON in this exact format:
4656
5000
  {
4657
5001
  text,
4658
5002
  voiceId: ELEVENLABS_VOICE_IDS[voice ?? "rachel"],
4659
- speed: speed ?? 1
5003
+ speed: speed ?? 1,
5004
+ ...project_id && { projectId: project_id }
4660
5005
  },
4661
5006
  { timeoutMs: 6e4 }
4662
5007
  );
@@ -4692,7 +5037,8 @@ Return ONLY valid JSON in this exact format:
4692
5037
  asEnvelope2({
4693
5038
  audioUrl: data.audioUrl,
4694
5039
  durationSeconds: data.durationSeconds,
4695
- voice: voice ?? "rachel"
5040
+ voice: voice ?? "rachel",
5041
+ projectId: project_id ?? null
4696
5042
  }),
4697
5043
  null,
4698
5044
  2
@@ -4798,7 +5144,7 @@ Return ONLY valid JSON in this exact format:
4798
5144
  }
4799
5145
  const userId = await getDefaultUserId();
4800
5146
  const rateLimit = checkRateLimit(
4801
- "posting",
5147
+ "generation",
4802
5148
  `generate_carousel:${userId}`
4803
5149
  );
4804
5150
  if (!rateLimit.allowed) {
@@ -4952,9 +5298,6 @@ var init_content2 = __esm({
4952
5298
  // src/lib/sanitize-error.ts
4953
5299
  function sanitizeError(error) {
4954
5300
  const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
4955
- if (process.env.NODE_ENV !== "production") {
4956
- console.error("[Error]", msg);
4957
- }
4958
5301
  for (const [pattern, userMessage] of ERROR_PATTERNS) {
4959
5302
  if (pattern.test(msg)) {
4960
5303
  return userMessage;
@@ -5158,7 +5501,7 @@ var init_ssrf = __esm({
5158
5501
 
5159
5502
  // src/tools/distribution.ts
5160
5503
  import { z as z3 } from "zod";
5161
- import { createHash as createHash3 } from "node:crypto";
5504
+ import { createHash as createHash2 } from "node:crypto";
5162
5505
  function snakeToCamel(obj) {
5163
5506
  const result = {};
5164
5507
  for (const [key, value] of Object.entries(obj)) {
@@ -5184,6 +5527,87 @@ function asEnvelope3(data) {
5184
5527
  data
5185
5528
  };
5186
5529
  }
5530
+ function publicConnectedAccount(value) {
5531
+ if (!value || typeof value !== "object") return null;
5532
+ const row = value;
5533
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
5534
+ return null;
5535
+ }
5536
+ return {
5537
+ id: row.id,
5538
+ platform: row.platform,
5539
+ status: row.status,
5540
+ ...typeof row.effective_status === "string" ? { effective_status: row.effective_status } : {},
5541
+ username: typeof row.username === "string" ? row.username : null,
5542
+ created_at: row.created_at,
5543
+ ...typeof row.updated_at === "string" || row.updated_at === null ? { updated_at: row.updated_at } : {},
5544
+ ...typeof row.expires_at === "string" || row.expires_at === null ? { expires_at: row.expires_at } : {},
5545
+ ...typeof row.has_refresh_token === "boolean" ? { has_refresh_token: row.has_refresh_token } : {},
5546
+ ...typeof row.project_id === "string" || row.project_id === null ? { project_id: row.project_id } : {}
5547
+ };
5548
+ }
5549
+ function publicPostRecord(value) {
5550
+ if (!value || typeof value !== "object") return null;
5551
+ const row = value;
5552
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
5553
+ return null;
5554
+ }
5555
+ const nullableString = (field) => typeof row[field] === "string" ? row[field] : null;
5556
+ return {
5557
+ id: row.id,
5558
+ platform: row.platform,
5559
+ status: row.status,
5560
+ title: nullableString("title"),
5561
+ external_post_id: nullableString("external_post_id"),
5562
+ published_at: nullableString("published_at"),
5563
+ scheduled_at: nullableString("scheduled_at"),
5564
+ created_at: row.created_at
5565
+ };
5566
+ }
5567
+ function mediaTypeFromPath(value) {
5568
+ try {
5569
+ const pathname = value.startsWith("http") ? new URL(value).pathname : value;
5570
+ const extension = pathname.split(".").pop()?.toLowerCase();
5571
+ if (!extension) return null;
5572
+ if (VIDEO_FILE_EXTENSIONS.has(extension)) return "VIDEO";
5573
+ if (IMAGE_FILE_EXTENSIONS.has(extension)) return "IMAGE";
5574
+ } catch {
5575
+ return null;
5576
+ }
5577
+ return null;
5578
+ }
5579
+ function inferScheduleMediaType(explicit, singleCandidates, collectionCandidates) {
5580
+ if (explicit) return explicit;
5581
+ const populatedCollection = collectionCandidates.find(
5582
+ (values) => Array.isArray(values) && values.length > 0
5583
+ );
5584
+ if (populatedCollection) {
5585
+ if (populatedCollection.length > 1) return "CAROUSEL_ALBUM";
5586
+ for (const values of collectionCandidates) {
5587
+ if (!values || values.length !== 1) continue;
5588
+ const inferred = mediaTypeFromPath(values[0]);
5589
+ if (inferred) return inferred;
5590
+ }
5591
+ return null;
5592
+ }
5593
+ for (const value of singleCandidates) {
5594
+ if (typeof value !== "string" || value.length === 0) continue;
5595
+ const inferred = mediaTypeFromPath(value);
5596
+ if (inferred) return inferred;
5597
+ }
5598
+ return null;
5599
+ }
5600
+ async function validatePublishMediaUrl(url) {
5601
+ try {
5602
+ if (new URL(url).protocol !== "https:") {
5603
+ return "Media URLs must use HTTPS.";
5604
+ }
5605
+ } catch {
5606
+ return "Media URL is invalid.";
5607
+ }
5608
+ const check = await validateUrlForSSRF(url);
5609
+ return check.isValid ? null : check.error || "Media URL failed safety validation.";
5610
+ }
5187
5611
  function accountEffectiveStatus(account) {
5188
5612
  return account.effective_status || account.status;
5189
5613
  }
@@ -5202,22 +5626,11 @@ function requestedAccountIdForPlatform(accountId, accountIds, platform3) {
5202
5626
  if (!accountIds) return void 0;
5203
5627
  return accountIds[platform3] || accountIds[platform3.toLowerCase()];
5204
5628
  }
5205
- function isAlreadyR2Signed(url) {
5206
- try {
5207
- const u = new URL(url);
5208
- return u.searchParams.has("X-Amz-Signature");
5209
- } catch {
5210
- return false;
5211
- }
5212
- }
5213
5629
  async function rehostExternalUrl(mediaUrl, projectId) {
5214
5630
  const ssrf = await validateUrlForSSRF(mediaUrl);
5215
5631
  if (!ssrf.isValid) {
5216
5632
  return { error: ssrf.error ?? "URL rejected by SSRF check" };
5217
5633
  }
5218
- if (isAlreadyR2Signed(mediaUrl)) {
5219
- return { signedUrl: mediaUrl, r2Key: "" };
5220
- }
5221
5634
  const { data, error } = await callEdgeFunction(
5222
5635
  "upload-to-r2",
5223
5636
  { url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
@@ -5236,19 +5649,19 @@ function registerDistributionTools(server) {
5236
5649
  media_url: z3.string().optional().describe(
5237
5650
  "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."
5238
5651
  ),
5239
- media_urls: z3.array(z3.string()).optional().describe(
5652
+ media_urls: z3.array(z3.string().url()).min(2).max(10).optional().describe(
5240
5653
  "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."
5241
5654
  ),
5242
5655
  r2_key: z3.string().optional().describe(
5243
5656
  "R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
5244
5657
  ),
5245
- r2_keys: z3.array(z3.string()).optional().describe(
5658
+ r2_keys: z3.array(z3.string()).min(2).max(10).optional().describe(
5246
5659
  "Array of R2 object keys for carousel posts. Each is signed on demand. Alternative to media_urls."
5247
5660
  ),
5248
5661
  job_id: z3.string().optional().describe(
5249
5662
  "Async job ID from generate_image/generate_video. Resolves the completed job's R2 key and signs it. Alternative to media_url/r2_key."
5250
5663
  ),
5251
- job_ids: z3.array(z3.string()).optional().describe(
5664
+ job_ids: z3.array(z3.string()).min(2).max(10).optional().describe(
5252
5665
  "Array of async job IDs for carousel posts. Each resolved to its R2 key. Alternative to media_urls/r2_keys."
5253
5666
  ),
5254
5667
  platform_metadata: z3.object({
@@ -5278,7 +5691,10 @@ function registerDistributionTools(server) {
5278
5691
  category_id: z3.string().optional(),
5279
5692
  tags: z3.array(z3.string()).optional(),
5280
5693
  made_for_kids: z3.boolean().optional(),
5281
- notify_subscribers: z3.boolean().optional()
5694
+ notify_subscribers: z3.boolean().optional(),
5695
+ contains_synthetic_media: z3.boolean().optional().describe(
5696
+ "YouTube altered-or-synthetic-content disclosure. Defaults to true for MCP posts; set false explicitly for verified non-AI media."
5697
+ )
5282
5698
  }).optional(),
5283
5699
  facebook: z3.object({
5284
5700
  page_id: z3.string().optional().describe("Facebook Page ID to post to."),
@@ -5361,14 +5777,8 @@ function registerDistributionTools(server) {
5361
5777
  auto_rehost: z3.boolean().optional().describe(
5362
5778
  "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."
5363
5779
  ),
5364
- visual_gate_result: z3.object({ passed: z3.boolean() }).passthrough().optional().describe(
5365
- "Visual QA gate verdict from the carousel/image generation pipeline. Required when mediaType is CAROUSEL_ALBUM, IMAGE, or VIDEO and VISUAL_GATE_ENFORCE is enabled on the EF. Produce with visual_quality_check tool. Must have passed=true for the gate to allow the publish."
5366
- ),
5367
- origin: z3.enum(["human", "hermes", "user"]).optional().describe(
5368
- "Originator lineage for the post. Marks who scheduled it; use the agent value when calling from an autonomous workflow, otherwise default 'human'."
5369
- ),
5370
- hermes_run_id: z3.string().optional().describe(
5371
- "Optional agent run identifier for traceability between a draft and the run that produced it."
5780
+ idempotency_key: z3.string().regex(/^[a-zA-Z0-9_-]{8,128}$/).optional().describe(
5781
+ "Stable 8-128 character retry key (letters, numbers, underscore, hyphen). Reuse the same key when retrying the same publish request to prevent duplicate posts."
5372
5782
  )
5373
5783
  },
5374
5784
  async ({
@@ -5391,9 +5801,7 @@ function registerDistributionTools(server) {
5391
5801
  platform_metadata,
5392
5802
  account_id,
5393
5803
  account_ids,
5394
- visual_gate_result,
5395
- origin,
5396
- hermes_run_id
5804
+ idempotency_key
5397
5805
  }) => {
5398
5806
  const format = response_format ?? "text";
5399
5807
  if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
@@ -5422,6 +5830,8 @@ function registerDistributionTools(server) {
5422
5830
  }
5423
5831
  let resolvedMediaUrl = media_url;
5424
5832
  let resolvedMediaUrls = media_urls;
5833
+ let resolvedMediaUrlIsTrustedR2 = false;
5834
+ let resolvedMediaUrlsAreTrustedR2 = (media_urls ?? []).map(() => false);
5425
5835
  const signR2Key = async (key) => {
5426
5836
  const cleanKey = key.startsWith("r2://") ? key.slice(5) : key;
5427
5837
  const { data: signData } = await callEdgeFunction(
@@ -5439,8 +5849,11 @@ function registerDistributionTools(server) {
5439
5849
  );
5440
5850
  const resultUrl = jobData?.job?.result_url;
5441
5851
  if (!resultUrl) return null;
5442
- if (!resultUrl.startsWith("http")) return signR2Key(resultUrl);
5443
- return resultUrl;
5852
+ if (!resultUrl.startsWith("http")) {
5853
+ const signed = await signR2Key(resultUrl);
5854
+ return signed ? { url: signed, trustedR2: true } : null;
5855
+ }
5856
+ return { url: resultUrl, trustedR2: false };
5444
5857
  };
5445
5858
  try {
5446
5859
  if (r2_key && !resolvedMediaUrl) {
@@ -5457,6 +5870,7 @@ function registerDistributionTools(server) {
5457
5870
  };
5458
5871
  }
5459
5872
  resolvedMediaUrl = signed;
5873
+ resolvedMediaUrlIsTrustedR2 = true;
5460
5874
  } else if (job_id && !resolvedMediaUrl && !r2_key) {
5461
5875
  const resolved = await resolveJobId(job_id);
5462
5876
  if (!resolved) {
@@ -5470,7 +5884,8 @@ function registerDistributionTools(server) {
5470
5884
  isError: true
5471
5885
  };
5472
5886
  }
5473
- resolvedMediaUrl = resolved;
5887
+ resolvedMediaUrl = resolved.url;
5888
+ resolvedMediaUrlIsTrustedR2 = resolved.trustedR2;
5474
5889
  }
5475
5890
  if (r2_keys && r2_keys.length > 0 && !resolvedMediaUrls) {
5476
5891
  const signed = await Promise.all(r2_keys.map(signR2Key));
@@ -5487,6 +5902,7 @@ function registerDistributionTools(server) {
5487
5902
  };
5488
5903
  }
5489
5904
  resolvedMediaUrls = signed;
5905
+ resolvedMediaUrlsAreTrustedR2 = signed.map(() => true);
5490
5906
  } else if (job_ids && job_ids.length > 0 && !resolvedMediaUrls && !r2_keys) {
5491
5907
  const resolved = await Promise.all(job_ids.map(resolveJobId));
5492
5908
  const failIdx = resolved.findIndex((r) => !r);
@@ -5501,10 +5917,12 @@ function registerDistributionTools(server) {
5501
5917
  isError: true
5502
5918
  };
5503
5919
  }
5504
- resolvedMediaUrls = resolved;
5920
+ const resolvedJobs = resolved;
5921
+ resolvedMediaUrls = resolvedJobs.map((item) => item.url);
5922
+ resolvedMediaUrlsAreTrustedR2 = resolvedJobs.map((item) => item.trustedR2);
5505
5923
  }
5506
5924
  const shouldRehost = auto_rehost !== false;
5507
- if (shouldRehost && resolvedMediaUrl) {
5925
+ if (shouldRehost && resolvedMediaUrl && !resolvedMediaUrlIsTrustedR2) {
5508
5926
  const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
5509
5927
  if ("error" in rehost) {
5510
5928
  return {
@@ -5518,10 +5936,13 @@ function registerDistributionTools(server) {
5518
5936
  };
5519
5937
  }
5520
5938
  resolvedMediaUrl = rehost.signedUrl;
5939
+ resolvedMediaUrlIsTrustedR2 = true;
5521
5940
  }
5522
5941
  if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
5523
5942
  const rehosted = await Promise.all(
5524
- resolvedMediaUrls.map((u) => rehostExternalUrl(u, project_id))
5943
+ resolvedMediaUrls.map(
5944
+ (u, index) => resolvedMediaUrlsAreTrustedR2[index] ? Promise.resolve({ signedUrl: u, r2Key: "" }) : rehostExternalUrl(u, project_id)
5945
+ )
5525
5946
  );
5526
5947
  const failIdx = rehosted.findIndex((r) => "error" in r);
5527
5948
  if (failIdx !== -1) {
@@ -5537,6 +5958,7 @@ function registerDistributionTools(server) {
5537
5958
  };
5538
5959
  }
5539
5960
  resolvedMediaUrls = rehosted.map((r) => r.signedUrl);
5961
+ resolvedMediaUrlsAreTrustedR2 = resolvedMediaUrls.map(() => true);
5540
5962
  }
5541
5963
  } catch (resolveErr) {
5542
5964
  return {
@@ -5549,6 +5971,42 @@ function registerDistributionTools(server) {
5549
5971
  isError: true
5550
5972
  };
5551
5973
  }
5974
+ const hasResolvedMedia = Boolean(
5975
+ resolvedMediaUrl || resolvedMediaUrls && resolvedMediaUrls.length > 0
5976
+ );
5977
+ const resolvedMediaType = inferScheduleMediaType(
5978
+ media_type,
5979
+ [resolvedMediaUrl, r2_key],
5980
+ [resolvedMediaUrls, r2_keys]
5981
+ );
5982
+ if (hasResolvedMedia && !resolvedMediaType) {
5983
+ return {
5984
+ content: [
5985
+ {
5986
+ type: "text",
5987
+ text: "media_type is required when the media format cannot be inferred from a file extension. Set IMAGE, VIDEO, or CAROUSEL_ALBUM explicitly."
5988
+ }
5989
+ ],
5990
+ isError: true
5991
+ };
5992
+ }
5993
+ const finalUrls = [resolvedMediaUrl, ...resolvedMediaUrls ?? []].filter(
5994
+ (value) => typeof value === "string" && value.length > 0
5995
+ );
5996
+ for (const url of finalUrls) {
5997
+ const validationError = await validatePublishMediaUrl(url);
5998
+ if (validationError) {
5999
+ return {
6000
+ content: [
6001
+ {
6002
+ type: "text",
6003
+ text: `Media URL blocked: ${validationError}`
6004
+ }
6005
+ ],
6006
+ isError: true
6007
+ };
6008
+ }
6009
+ }
5552
6010
  const normalizedPlatforms = platforms.map(
5553
6011
  (p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p
5554
6012
  );
@@ -5674,12 +6132,32 @@ Created with Social Neuron`;
5674
6132
  };
5675
6133
  return true;
5676
6134
  })();
6135
+ const defaultAiDisclosure = (platformKey, field) => {
6136
+ const existing = normalizedPlatformMetadata?.[platformKey];
6137
+ if (existing && existing[field] !== void 0) return;
6138
+ normalizedPlatformMetadata = {
6139
+ ...normalizedPlatformMetadata ?? {},
6140
+ [platformKey]: {
6141
+ ...existing ?? {},
6142
+ [field]: true
6143
+ }
6144
+ };
6145
+ };
6146
+ if (normalizedPlatforms.includes("TikTok")) {
6147
+ defaultAiDisclosure("tiktok", "is_ai_generated");
6148
+ }
6149
+ if (normalizedPlatforms.includes("Instagram")) {
6150
+ defaultAiDisclosure("instagram", "is_ai_generated");
6151
+ }
6152
+ if (normalizedPlatforms.includes("YouTube")) {
6153
+ defaultAiDisclosure("youtube", "contains_synthetic_media");
6154
+ }
5677
6155
  const { data, error } = await callEdgeFunction(
5678
6156
  "schedule-post",
5679
6157
  {
5680
6158
  mediaUrl: resolvedMediaUrl,
5681
6159
  mediaUrls: resolvedMediaUrls,
5682
- mediaType: media_type,
6160
+ mediaType: resolvedMediaType ?? void 0,
5683
6161
  caption: finalCaption,
5684
6162
  platforms: normalizedPlatforms,
5685
6163
  title,
@@ -5692,14 +6170,10 @@ Created with Social Neuron`;
5692
6170
  normalizedPlatformMetadata
5693
6171
  )
5694
6172
  } : {},
5695
- // Forward visual gate verdict + source so the EF can enforce on media posts.
5696
- ...visual_gate_result ? { visualGateResult: visual_gate_result } : {},
5697
- visualGateSource: "mcp",
5698
- // Originator lineage (Hermes integration, 2026-05-22). EF validates origin
5699
- // against the posts.origin CHECK constraint and persists hermesRunId when
5700
- // origin === 'hermes'.
5701
- ...origin ? { origin } : {},
5702
- ...hermes_run_id ? { hermesRunId: hermes_run_id } : {}
6173
+ ...idempotency_key ? { idempotencyKey: idempotency_key } : {}
6174
+ // Attribution is assigned by the authenticated gateway. Visual QA
6175
+ // attestations are server-produced evidence and are deliberately not
6176
+ // accepted from an MCP caller.
5703
6177
  },
5704
6178
  { timeoutMs: 3e4 }
5705
6179
  );
@@ -5766,75 +6240,172 @@ Created with Social Neuron`;
5766
6240
  }
5767
6241
  );
5768
6242
  server.tool(
5769
- "list_connected_accounts",
5770
- "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.",
6243
+ "reschedule_post",
6244
+ "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.",
5771
6245
  {
5772
- project_id: z3.string().optional().describe(
5773
- "Brand/project ID to scope connected accounts. Use the same project_id when calling schedule_post."
6246
+ post_id: z3.string().uuid().describe("Post ID returned by list_recent_posts."),
6247
+ project_id: z3.string().uuid().optional().describe(
6248
+ "Brand/project ID that owns the post. Defaults to the authenticated key's project or the account default."
5774
6249
  ),
5775
- include_all: z3.boolean().optional().describe("If true, include expired or inactive accounts as well as usable accounts."),
5776
- response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6250
+ scheduled_at: z3.string().datetime({ offset: true }).describe("New future publish time as an ISO 8601 datetime with timezone."),
6251
+ expected_scheduled_at: z3.string().datetime({ offset: true }).optional().describe(
6252
+ "Optional current schedule timestamp. If it changed since you read it, the update is rejected instead of silently overwriting it."
6253
+ ),
6254
+ response_format: z3.enum(["text", "json"]).default("text")
5777
6255
  },
5778
- async ({ project_id, include_all, response_format }) => {
5779
- const format = response_format ?? "text";
5780
- const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
5781
- action: "connected-accounts",
5782
- ...project_id ? { projectId: project_id, project_id } : {},
5783
- ...include_all ? { includeAll: true } : {}
5784
- });
5785
- if (efError || !result?.success) {
6256
+ async ({
6257
+ post_id,
6258
+ project_id,
6259
+ scheduled_at,
6260
+ expected_scheduled_at,
6261
+ response_format
6262
+ }) => {
6263
+ const next = new Date(scheduled_at);
6264
+ if (!Number.isFinite(next.getTime()) || next.getTime() <= Date.now()) {
5786
6265
  return {
5787
6266
  content: [
5788
6267
  {
5789
6268
  type: "text",
5790
- text: `Failed to list connected accounts: ${efError || result?.error || "Unknown error"}`
6269
+ text: "scheduled_at must be a valid future ISO datetime with timezone."
5791
6270
  }
5792
6271
  ],
5793
6272
  isError: true
5794
6273
  };
5795
6274
  }
5796
- const accounts = result.accounts ?? [];
5797
- if (accounts.length === 0) {
5798
- if (format === "json") {
5799
- return {
5800
- content: [
5801
- {
5802
- type: "text",
5803
- text: JSON.stringify(asEnvelope3({ accounts: [] }), null, 2)
5804
- }
5805
- ]
5806
- };
5807
- }
6275
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
6276
+ if (!resolvedProjectId) {
5808
6277
  return {
5809
6278
  content: [
5810
6279
  {
5811
6280
  type: "text",
5812
- text: "No connected social media accounts found. Connect platforms in Social Neuron Settings > Connections."
6281
+ text: "No project_id was provided and no default project is configured."
5813
6282
  }
5814
- ]
6283
+ ],
6284
+ isError: true
5815
6285
  };
5816
6286
  }
5817
- const lines = [
5818
- `${accounts.length} connected account(s)${project_id ? ` for project ${project_id}` : ""}:`,
5819
- ""
5820
- ];
5821
- for (const account of accounts) {
5822
- const name = account.username || "(unnamed)";
5823
- const platformLower = account.platform.toLowerCase();
5824
- const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
5825
- const status = accountEffectiveStatus(account);
5826
- lines.push(
5827
- ` ${platformLower}: ${name} | id=${account.id} | ${project} | status=${status} (connected ${account.created_at.split("T")[0]})`
5828
- );
5829
- }
5830
- if (format === "json") {
6287
+ const userId = await getDefaultUserId();
6288
+ const rateLimit = checkRateLimit("posting", `reschedule_post:${userId}`);
6289
+ if (!rateLimit.allowed) {
5831
6290
  return {
5832
6291
  content: [
5833
6292
  {
5834
6293
  type: "text",
5835
- text: JSON.stringify(asEnvelope3({ accounts }), null, 2)
6294
+ text: `Rate limit exceeded. Retry in ~${rateLimit.retryAfter}s.`
5836
6295
  }
5837
- ]
6296
+ ],
6297
+ isError: true
6298
+ };
6299
+ }
6300
+ const { data: result, error } = await callEdgeFunction("mcp-data", {
6301
+ action: "reschedule-scheduled-post",
6302
+ post_id,
6303
+ projectId: resolvedProjectId,
6304
+ project_id: resolvedProjectId,
6305
+ scheduled_at: next.toISOString(),
6306
+ ...expected_scheduled_at ? { expected_scheduled_at: new Date(expected_scheduled_at).toISOString() } : {}
6307
+ });
6308
+ if (error || !result?.success) {
6309
+ const code = result?.error ?? error ?? "reschedule_failed";
6310
+ 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.";
6311
+ return {
6312
+ content: [{ type: "text", text: recovery }],
6313
+ isError: true
6314
+ };
6315
+ }
6316
+ const publicResult = {
6317
+ success: true,
6318
+ post_id: result.post_id ?? post_id,
6319
+ project_id: result.project_id ?? resolvedProjectId,
6320
+ previous_scheduled_at: result.previous_scheduled_at ?? null,
6321
+ scheduled_at: result.scheduled_at ?? next.toISOString()
6322
+ };
6323
+ const structuredContent = asEnvelope3(publicResult);
6324
+ return {
6325
+ structuredContent,
6326
+ content: [
6327
+ {
6328
+ type: "text",
6329
+ text: response_format === "json" ? JSON.stringify(structuredContent, null, 2) : `Post ${publicResult.post_id} rescheduled to ${publicResult.scheduled_at}.`
6330
+ }
6331
+ ]
6332
+ };
6333
+ }
6334
+ );
6335
+ server.tool(
6336
+ "list_connected_accounts",
6337
+ "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.",
6338
+ {
6339
+ project_id: z3.string().optional().describe(
6340
+ "Brand/project ID to scope connected accounts. Use the same project_id when calling schedule_post."
6341
+ ),
6342
+ include_all: z3.boolean().optional().describe("If true, include expired or inactive accounts as well as usable accounts."),
6343
+ response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6344
+ },
6345
+ async ({ project_id, include_all, response_format }) => {
6346
+ const format = response_format ?? "text";
6347
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
6348
+ action: "connected-accounts",
6349
+ ...project_id ? { projectId: project_id, project_id } : {},
6350
+ ...include_all ? { includeAll: true } : {}
6351
+ });
6352
+ if (efError || !result?.success) {
6353
+ return {
6354
+ content: [
6355
+ {
6356
+ type: "text",
6357
+ text: `Failed to list connected accounts: ${efError || result?.error || "Unknown error"}`
6358
+ }
6359
+ ],
6360
+ isError: true
6361
+ };
6362
+ }
6363
+ const accounts = (result.accounts ?? []).map(publicConnectedAccount).filter((account) => account !== null);
6364
+ if (accounts.length === 0) {
6365
+ if (format === "json") {
6366
+ const structuredContent = asEnvelope3({ accounts: [] });
6367
+ return {
6368
+ structuredContent,
6369
+ content: [
6370
+ {
6371
+ type: "text",
6372
+ text: JSON.stringify(structuredContent, null, 2)
6373
+ }
6374
+ ]
6375
+ };
6376
+ }
6377
+ return {
6378
+ content: [
6379
+ {
6380
+ type: "text",
6381
+ text: "No connected social media accounts found. Connect platforms in Social Neuron Settings > Connections."
6382
+ }
6383
+ ]
6384
+ };
6385
+ }
6386
+ const lines = [
6387
+ `${accounts.length} connected account(s)${project_id ? ` for project ${project_id}` : ""}:`,
6388
+ ""
6389
+ ];
6390
+ for (const account of accounts) {
6391
+ const name = account.username || "(unnamed)";
6392
+ const platformLower = account.platform.toLowerCase();
6393
+ const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
6394
+ const status = accountEffectiveStatus(account);
6395
+ lines.push(
6396
+ ` ${platformLower}: ${name} | id=${account.id} | ${project} | status=${status} (connected ${account.created_at.split("T")[0]})`
6397
+ );
6398
+ }
6399
+ if (format === "json") {
6400
+ const structuredContent = asEnvelope3({ accounts });
6401
+ return {
6402
+ structuredContent,
6403
+ content: [
6404
+ {
6405
+ type: "text",
6406
+ text: JSON.stringify(structuredContent, null, 2)
6407
+ }
6408
+ ]
5838
6409
  };
5839
6410
  }
5840
6411
  return {
@@ -5846,6 +6417,9 @@ Created with Social Neuron`;
5846
6417
  "list_recent_posts",
5847
6418
  "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.",
5848
6419
  {
6420
+ project_id: z3.string().uuid().optional().describe(
6421
+ "Brand/project ID to scope posts. Defaults to the authenticated key's project or the account default."
6422
+ ),
5849
6423
  platform: z3.enum([
5850
6424
  "youtube",
5851
6425
  "tiktok",
@@ -5861,13 +6435,27 @@ Created with Social Neuron`;
5861
6435
  limit: z3.number().min(1).max(50).optional().describe("Maximum number of posts to return. Defaults to 20."),
5862
6436
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5863
6437
  },
5864
- async ({ platform: platform3, status, days, limit, response_format }) => {
6438
+ async ({ project_id, platform: platform3, status, days, limit, response_format }) => {
5865
6439
  const format = response_format ?? "text";
5866
6440
  const lookbackDays = days ?? 7;
6441
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
6442
+ if (!resolvedProjectId) {
6443
+ return {
6444
+ content: [
6445
+ {
6446
+ type: "text",
6447
+ text: "No project_id was provided and no default project is configured."
6448
+ }
6449
+ ],
6450
+ isError: true
6451
+ };
6452
+ }
5867
6453
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
5868
6454
  action: "recent-posts",
5869
6455
  days: lookbackDays,
5870
6456
  limit: limit ?? 20,
6457
+ projectId: resolvedProjectId,
6458
+ project_id: resolvedProjectId,
5871
6459
  ...platform3 ? { platform: platform3 } : {},
5872
6460
  ...status ? { status } : {}
5873
6461
  });
@@ -5882,7 +6470,7 @@ Created with Social Neuron`;
5882
6470
  isError: true
5883
6471
  };
5884
6472
  }
5885
- const rows = result.posts ?? [];
6473
+ const rows = (result.posts ?? []).map(publicPostRecord).filter((post) => post !== null);
5886
6474
  if (rows.length === 0) {
5887
6475
  if (format === "json") {
5888
6476
  const structuredContent2 = asEnvelope3({ posts: [] });
@@ -5957,8 +6545,11 @@ Created with Social Neuron`;
5957
6545
  };
5958
6546
  server.tool(
5959
6547
  "find_next_slots",
5960
- "Find optimal posting time slots based on best posting times and existing schedule. Returns non-conflicting slots sorted by engagement score.",
6548
+ "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.",
5961
6549
  {
6550
+ project_id: z3.string().uuid().optional().describe(
6551
+ "Brand/project ID used for conflict detection. Defaults to the authenticated key's project or the account default."
6552
+ ),
5962
6553
  platforms: z3.array(
5963
6554
  z3.enum([
5964
6555
  "youtube",
@@ -5977,6 +6568,7 @@ Created with Social Neuron`;
5977
6568
  response_format: z3.enum(["text", "json"]).default("text")
5978
6569
  },
5979
6570
  async ({
6571
+ project_id,
5980
6572
  platforms,
5981
6573
  count,
5982
6574
  start_after,
@@ -5984,13 +6576,35 @@ Created with Social Neuron`;
5984
6576
  response_format
5985
6577
  }) => {
5986
6578
  try {
6579
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
6580
+ if (!resolvedProjectId) {
6581
+ return {
6582
+ content: [
6583
+ {
6584
+ type: "text",
6585
+ text: "No project_id was provided and no default project is configured."
6586
+ }
6587
+ ],
6588
+ isError: true
6589
+ };
6590
+ }
5987
6591
  const startDate = start_after ? new Date(start_after) : /* @__PURE__ */ new Date();
6592
+ if (!Number.isFinite(startDate.getTime())) {
6593
+ return {
6594
+ content: [
6595
+ { type: "text", text: "start_after must be a valid ISO datetime." }
6596
+ ],
6597
+ isError: true
6598
+ };
6599
+ }
5988
6600
  const endDate = new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1e3);
5989
6601
  const { data: postsResult, error: postsError } = await callEdgeFunction("mcp-data", {
5990
6602
  action: "scheduled-posts",
5991
6603
  start_date: startDate.toISOString(),
5992
6604
  end_date: endDate.toISOString(),
5993
- statuses: ["scheduled", "draft"]
6605
+ statuses: ["pending", "scheduled", "draft"],
6606
+ projectId: resolvedProjectId,
6607
+ project_id: resolvedProjectId
5994
6608
  });
5995
6609
  const existingPosts = postsError ? [] : postsResult?.posts ?? [];
5996
6610
  const gapMs = min_gap_hours * 60 * 60 * 1e3;
@@ -6030,19 +6644,17 @@ Created with Social Neuron`;
6030
6644
  const slots = candidates.filter((s) => !s.conflict).sort((a, b) => b.engagement_score - a.engagement_score).slice(0, count);
6031
6645
  const conflictsAvoided = candidates.filter((s) => s.conflict).length;
6032
6646
  if (response_format === "json") {
6647
+ const structuredContent = asEnvelope3({
6648
+ slots,
6649
+ total_candidates: candidates.length,
6650
+ conflicts_avoided: conflictsAvoided
6651
+ });
6033
6652
  return {
6653
+ structuredContent,
6034
6654
  content: [
6035
6655
  {
6036
6656
  type: "text",
6037
- text: JSON.stringify(
6038
- asEnvelope3({
6039
- slots,
6040
- total_candidates: candidates.length,
6041
- conflicts_avoided: conflictsAvoided
6042
- }),
6043
- null,
6044
- 2
6045
- )
6657
+ text: JSON.stringify(structuredContent, null, 2)
6046
6658
  }
6047
6659
  ],
6048
6660
  isError: false
@@ -6407,7 +7019,7 @@ Created with Social Neuron`;
6407
7019
  const results = [];
6408
7020
  const buildIdempotencyKey = (post) => {
6409
7021
  const planId = effectivePlanId ?? (typeof workingPlan.plan_id === "string" ? String(workingPlan.plan_id) : "inline");
6410
- const captionHash = createHash3("sha256").update(post.caption).digest("hex").slice(0, 16);
7022
+ const captionHash = createHash2("sha256").update(post.caption).digest("hex").slice(0, 16);
6411
7023
  const raw = [
6412
7024
  "schedule_content_plan",
6413
7025
  planId,
@@ -6417,7 +7029,7 @@ Created with Social Neuron`;
6417
7029
  captionHash,
6418
7030
  idempotency_seed ?? ""
6419
7031
  ].join(":");
6420
- return `plan-${createHash3("sha256").update(raw).digest("hex").slice(0, 48)}`;
7032
+ return `plan-${createHash2("sha256").update(raw).digest("hex").slice(0, 48)}`;
6421
7033
  };
6422
7034
  const scheduleOne = async (post) => {
6423
7035
  if (!post.schedule_at) {
@@ -6603,7 +7215,7 @@ Created with Social Neuron`;
6603
7215
  }
6604
7216
  );
6605
7217
  }
6606
- var PLATFORM_CASE_MAP, TIKTOK_AUDIT_APPROVED, MCP_NOT_LIVE_FOR_POSTING;
7218
+ var PLATFORM_CASE_MAP, TIKTOK_AUDIT_APPROVED, MCP_NOT_LIVE_FOR_POSTING, VIDEO_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS;
6607
7219
  var init_distribution = __esm({
6608
7220
  "src/tools/distribution.ts"() {
6609
7221
  "use strict";
@@ -6624,8 +7236,29 @@ var init_distribution = __esm({
6624
7236
  threads: "Threads",
6625
7237
  bluesky: "Bluesky"
6626
7238
  };
6627
- TIKTOK_AUDIT_APPROVED = false;
7239
+ TIKTOK_AUDIT_APPROVED = !["false", "0", "no"].includes(
7240
+ (process.env.TIKTOK_AUDIT_APPROVED ?? "").toLowerCase()
7241
+ );
6628
7242
  MCP_NOT_LIVE_FOR_POSTING = /* @__PURE__ */ new Set(["LinkedIn", "Pinterest", "Reddit"]);
7243
+ VIDEO_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
7244
+ "mp4",
7245
+ "mov",
7246
+ "m4v",
7247
+ "webm",
7248
+ "avi",
7249
+ "mkv",
7250
+ "mpeg",
7251
+ "mpg"
7252
+ ]);
7253
+ IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
7254
+ "jpg",
7255
+ "jpeg",
7256
+ "png",
7257
+ "webp",
7258
+ "gif",
7259
+ "avif",
7260
+ "heic"
7261
+ ]);
6629
7262
  }
6630
7263
  });
6631
7264
 
@@ -6906,7 +7539,10 @@ function registerMediaTools(server) {
6906
7539
  content: [
6907
7540
  {
6908
7541
  type: "text",
6909
- text: `R2 upload failed (HTTP ${putResp.status}): ${await putResp.text().catch(() => "Unknown error")}`
7542
+ // Presigned-store bodies may include provider request IDs,
7543
+ // bucket names, or signed URL fragments. Status is enough
7544
+ // for user recovery and safe diagnostics.
7545
+ text: `R2 upload failed (HTTP ${putResp.status}). Please retry.`
6910
7546
  }
6911
7547
  ],
6912
7548
  isError: true
@@ -7118,7 +7754,7 @@ function asEnvelope4(data) {
7118
7754
  function registerAnalyticsTools(server) {
7119
7755
  server.tool(
7120
7756
  "fetch_analytics",
7121
- "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.",
7757
+ "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.",
7122
7758
  {
7123
7759
  platform: z5.enum([
7124
7760
  "youtube",
@@ -7136,19 +7772,26 @@ function registerAnalyticsTools(server) {
7136
7772
  content_id: z5.string().uuid().optional().describe(
7137
7773
  "Filter to a specific content_history ID to see performance of one piece of content."
7138
7774
  ),
7775
+ project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
7139
7776
  limit: z5.number().min(1).max(100).optional().describe("Maximum number of posts to return. Defaults to 20."),
7140
7777
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7141
7778
  },
7142
- async ({ platform: platform3, days, content_id, limit, response_format }) => {
7779
+ async ({ platform: platform3, days, content_id, project_id, limit, response_format }) => {
7143
7780
  const format = response_format ?? "text";
7144
7781
  const lookbackDays = days ?? 30;
7145
7782
  const maxPosts = limit ?? 20;
7783
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
7146
7784
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
7147
7785
  action: "analytics",
7148
7786
  platform: platform3,
7149
7787
  days: lookbackDays,
7150
- limit: maxPosts,
7151
- contentId: content_id
7788
+ // Fetch extra snapshots, then deduplicate to the requested post count.
7789
+ // The backend understands latestOnly after the paired application
7790
+ // deployment; older deployments safely ignore the hint.
7791
+ limit: Math.min(maxPosts * 5, 100),
7792
+ latestOnly: true,
7793
+ contentId: content_id,
7794
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
7152
7795
  });
7153
7796
  if (efError) {
7154
7797
  return {
@@ -7156,7 +7799,14 @@ function registerAnalyticsTools(server) {
7156
7799
  isError: true
7157
7800
  };
7158
7801
  }
7159
- const rows = result?.rows ?? [];
7802
+ const snapshotRows = result?.rows ?? [];
7803
+ const seenSnapshots = /* @__PURE__ */ new Set();
7804
+ const rows = [...snapshotRows].sort((a, b) => b.captured_at.localeCompare(a.captured_at)).filter((row) => {
7805
+ const key = `${row.post_id}:${row.platform.toLowerCase()}`;
7806
+ if (seenSnapshots.has(key)) return false;
7807
+ seenSnapshots.add(key);
7808
+ return true;
7809
+ }).slice(0, maxPosts);
7160
7810
  if (rows.length === 0) {
7161
7811
  if (format === "json") {
7162
7812
  const structuredContent = asEnvelope4({
@@ -7218,14 +7868,19 @@ function registerAnalyticsTools(server) {
7218
7868
  );
7219
7869
  server.tool(
7220
7870
  "refresh_platform_analytics",
7221
- "Queue analytics refresh jobs for all posts from the last 7 days across connected platforms. Call this before fetch_analytics if you need fresh data. Returns immediately \u2014 data updates asynchronously over the next 1-5 minutes.",
7871
+ "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.",
7222
7872
  {
7873
+ project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
7223
7874
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7224
7875
  },
7225
- async ({ response_format }) => {
7876
+ async ({ project_id, response_format }) => {
7226
7877
  const format = response_format ?? "text";
7227
7878
  const userId = await getDefaultUserId();
7228
- const rateLimit = checkRateLimit("posting", `refresh_platform_analytics:${userId}`);
7879
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
7880
+ const rateLimit = checkRateLimit(
7881
+ "posting",
7882
+ `refresh_platform_analytics:${userId}:${resolvedProjectId ?? "default"}`
7883
+ );
7229
7884
  if (!rateLimit.allowed) {
7230
7885
  return {
7231
7886
  content: [
@@ -7237,7 +7892,10 @@ function registerAnalyticsTools(server) {
7237
7892
  isError: true
7238
7893
  };
7239
7894
  }
7240
- const { data, error } = await callEdgeFunction("fetch-analytics", { userId });
7895
+ const { data, error } = await callEdgeFunction("fetch-analytics", {
7896
+ userId,
7897
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
7898
+ });
7241
7899
  if (error) {
7242
7900
  return {
7243
7901
  content: [{ type: "text", text: `Error refreshing analytics: ${error}` }],
@@ -7266,7 +7924,8 @@ function registerAnalyticsTools(server) {
7266
7924
  success: true,
7267
7925
  postsProcessed: result.postsProcessed,
7268
7926
  queued,
7269
- errored
7927
+ errored,
7928
+ projectId: resolvedProjectId ?? null
7270
7929
  });
7271
7930
  return {
7272
7931
  structuredContent,
@@ -7326,6 +7985,84 @@ var init_analytics2 = __esm({
7326
7985
  }
7327
7986
  });
7328
7987
 
7988
+ // src/lib/brandUrlInput.ts
7989
+ function canonicalizeHost(hostname) {
7990
+ return hostname.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, "");
7991
+ }
7992
+ function hostLooksLikeDomain(hostname) {
7993
+ return /\S+\.\S+/.test(canonicalizeHost(hostname));
7994
+ }
7995
+ function resolveHandle(handle, platform3) {
7996
+ const normalized = handle.replace(/^@/, "").trim();
7997
+ if (!normalized) return { url: null, handle: null, platform: null, ambiguous: false };
7998
+ if (!platform3) return { url: null, handle: normalized, platform: null, ambiguous: true };
7999
+ return {
8000
+ url: PROFILE_URL_BUILDERS[platform3](normalized),
8001
+ handle: normalized,
8002
+ platform: platform3,
8003
+ ambiguous: false
8004
+ };
8005
+ }
8006
+ function normalizeBrandUrlInput(raw, explicitPlatform) {
8007
+ const input = (raw ?? "").trim();
8008
+ if (!input) return { url: null, handle: null, platform: null, ambiguous: false };
8009
+ if (/^https?:\/\//i.test(input)) {
8010
+ let parsed;
8011
+ try {
8012
+ parsed = new URL(input);
8013
+ } catch {
8014
+ return { url: null, handle: null, platform: null, ambiguous: false, invalidUrl: true };
8015
+ }
8016
+ if (hostLooksLikeDomain(parsed.hostname)) {
8017
+ return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
8018
+ }
8019
+ return resolveHandle(canonicalizeHost(parsed.hostname), explicitPlatform);
8020
+ }
8021
+ const qualified = input.match(/^([a-z]{1,10}):\s*@?(.+)$/i);
8022
+ if (qualified) {
8023
+ const platform3 = PLATFORM_ALIASES[qualified[1].toLowerCase()];
8024
+ if (platform3) return resolveHandle(qualified[2], platform3);
8025
+ }
8026
+ if (input.startsWith("@")) return resolveHandle(input, explicitPlatform);
8027
+ if (!/\s/.test(input)) {
8028
+ try {
8029
+ const parsed = new URL(`https://${input}`);
8030
+ if (hostLooksLikeDomain(parsed.hostname)) {
8031
+ return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
8032
+ }
8033
+ } catch {
8034
+ }
8035
+ }
8036
+ return resolveHandle(input.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, ""), explicitPlatform);
8037
+ }
8038
+ var PLATFORM_ALIASES, PROFILE_URL_BUILDERS, UNICODE_DOT_VARIANTS;
8039
+ var init_brandUrlInput = __esm({
8040
+ "src/lib/brandUrlInput.ts"() {
8041
+ "use strict";
8042
+ PLATFORM_ALIASES = {
8043
+ instagram: "instagram",
8044
+ ig: "instagram",
8045
+ insta: "instagram",
8046
+ tiktok: "tiktok",
8047
+ tt: "tiktok",
8048
+ twitter: "twitter",
8049
+ x: "twitter",
8050
+ linkedin: "linkedin",
8051
+ li: "linkedin",
8052
+ youtube: "youtube",
8053
+ yt: "youtube"
8054
+ };
8055
+ PROFILE_URL_BUILDERS = {
8056
+ instagram: (handle) => `https://instagram.com/${encodeURIComponent(handle)}`,
8057
+ tiktok: (handle) => `https://tiktok.com/@${encodeURIComponent(handle)}`,
8058
+ twitter: (handle) => `https://x.com/${encodeURIComponent(handle)}`,
8059
+ linkedin: (handle) => `https://linkedin.com/company/${encodeURIComponent(handle)}`,
8060
+ youtube: (handle) => `https://youtube.com/@${encodeURIComponent(handle)}`
8061
+ };
8062
+ UNICODE_DOT_VARIANTS = /[。.。]/g;
8063
+ }
8064
+ });
8065
+
7329
8066
  // src/tools/brand.ts
7330
8067
  import { z as z6 } from "zod";
7331
8068
  function asEnvelope5(data) {
@@ -7342,12 +8079,31 @@ function registerBrandTools(server) {
7342
8079
  "extract_brand",
7343
8080
  "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.",
7344
8081
  {
7345
- url: z6.string().url().describe(
7346
- 'The website URL to analyze for brand identity (e.g. "https://example.com").'
8082
+ url: z6.string().min(1).max(2048).describe(
8083
+ '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").'
7347
8084
  ),
7348
8085
  response_format: z6.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7349
8086
  },
7350
- async ({ url, response_format }) => {
8087
+ async ({ url: rawUrl, response_format }) => {
8088
+ const normalized = normalizeBrandUrlInput(rawUrl);
8089
+ if (normalized.invalidUrl) {
8090
+ return {
8091
+ content: [{ type: "text", text: `URL blocked: "${rawUrl}" is not a valid URL.` }],
8092
+ isError: true
8093
+ };
8094
+ }
8095
+ if (!normalized.url) {
8096
+ return {
8097
+ content: [
8098
+ {
8099
+ type: "text",
8100
+ text: `"${rawUrl}" looks like a handle, not a full URL. Provide a profile URL or use "platform:handle" shorthand (for example "instagram:acmefoods").`
8101
+ }
8102
+ ],
8103
+ isError: true
8104
+ };
8105
+ }
8106
+ const url = normalized.url;
7351
8107
  const ssrfCheck = await validateUrlForSSRF(url);
7352
8108
  if (!ssrfCheck.isValid) {
7353
8109
  return {
@@ -7655,6 +8411,7 @@ var init_brand = __esm({
7655
8411
  init_supabase();
7656
8412
  init_ssrf();
7657
8413
  init_version();
8414
+ init_brandUrlInput();
7658
8415
  }
7659
8416
  });
7660
8417
 
@@ -8030,7 +8787,7 @@ function registerRemotionTools(server) {
8030
8787
  },
8031
8788
  async ({ composition_id, output_format, props }) => {
8032
8789
  const userId = await getDefaultUserId();
8033
- const rateLimit = checkRateLimit("screenshot", `render_demo_video:${userId}`);
8790
+ const rateLimit = checkRateLimit("generation", `render_demo_video:${userId}`);
8034
8791
  if (!rateLimit.allowed) {
8035
8792
  return {
8036
8793
  content: [
@@ -8369,22 +9126,25 @@ function asEnvelope6(data) {
8369
9126
  function registerInsightsTools(server) {
8370
9127
  server.tool(
8371
9128
  "get_performance_insights",
8372
- "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.",
9129
+ "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.",
8373
9130
  {
8374
9131
  insight_type: z9.enum(["top_hooks", "optimal_timing", "best_models", "competitor_patterns"]).optional().describe("Filter to a specific insight type."),
8375
9132
  days: z9.number().min(1).max(90).optional().describe("Number of days to look back. Defaults to 30. Max 90."),
8376
9133
  limit: z9.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
9134
+ project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
8377
9135
  response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8378
9136
  },
8379
- async ({ insight_type, days, limit, response_format }) => {
9137
+ async ({ insight_type, days, limit, project_id, response_format }) => {
8380
9138
  const format = response_format ?? "text";
8381
9139
  const lookbackDays = days ?? 30;
8382
9140
  const maxRows = limit ?? 10;
8383
9141
  const effectiveDays = Math.min(lookbackDays, MAX_INSIGHT_AGE_DAYS);
9142
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
8384
9143
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
8385
9144
  action: "performance-insights",
8386
9145
  days: effectiveDays,
8387
- limit: maxRows
9146
+ limit: maxRows,
9147
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
8388
9148
  });
8389
9149
  if (efError || !result?.success) {
8390
9150
  return {
@@ -8471,19 +9231,22 @@ function registerInsightsTools(server) {
8471
9231
  );
8472
9232
  server.tool(
8473
9233
  "get_best_posting_times",
8474
- "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.",
9234
+ "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.",
8475
9235
  {
8476
9236
  platform: z9.enum(PLATFORM_ENUM).optional().describe("Filter to a specific platform."),
8477
9237
  days: z9.number().min(1).max(90).optional().describe("Number of days to analyze. Defaults to 30. Max 90."),
9238
+ project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
8478
9239
  response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
8479
9240
  },
8480
- async ({ platform: platform3, days, response_format }) => {
9241
+ async ({ platform: platform3, days, project_id, response_format }) => {
8481
9242
  const format = response_format ?? "text";
8482
9243
  const lookbackDays = days ?? 30;
9244
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
8483
9245
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
8484
9246
  action: "best-posting-times",
8485
9247
  days: lookbackDays,
8486
- platform: platform3 ?? void 0
9248
+ platform: platform3 ?? void 0,
9249
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
8487
9250
  });
8488
9251
  if (efError || !result?.success) {
8489
9252
  return {
@@ -8597,6 +9360,7 @@ var init_insights = __esm({
8597
9360
  "src/tools/insights.ts"() {
8598
9361
  "use strict";
8599
9362
  init_edge_function();
9363
+ init_supabase();
8600
9364
  init_version();
8601
9365
  MAX_INSIGHT_AGE_DAYS = 30;
8602
9366
  PLATFORM_ENUM = [
@@ -9640,7 +10404,7 @@ Schedule: ${JSON.stringify(updated.schedule_config)}`
9640
10404
  }
9641
10405
  const statusData = {
9642
10406
  activeConfigs: result?.activeConfigs ?? 0,
9643
- recentRuns: [],
10407
+ recentRuns: result?.recentRuns ?? result?.recent_runs ?? [],
9644
10408
  pendingApprovals: result?.pendingApprovals ?? 0
9645
10409
  };
9646
10410
  if (format === "json") {
@@ -9662,8 +10426,20 @@ ${"=".repeat(40)}
9662
10426
  text += `Pending Approvals: ${statusData.pendingApprovals}
9663
10427
 
9664
10428
  `;
9665
- text += `No recent runs.
10429
+ if (statusData.recentRuns.length === 0) {
10430
+ text += `No recent runs.
9666
10431
  `;
10432
+ } else {
10433
+ text += `Recent Runs (${statusData.recentRuns.length}):
10434
+ `;
10435
+ for (const run of statusData.recentRuns.slice(0, 10)) {
10436
+ const id = String(run.id ?? run.run_id ?? "unknown");
10437
+ const status = String(run.status ?? "unknown");
10438
+ const credits = run.credits_used ?? run.creditsUsed;
10439
+ text += ` ${id}: ${status}${credits == null ? "" : ` (${String(credits)} credits)`}
10440
+ `;
10441
+ }
10442
+ }
9667
10443
  return {
9668
10444
  content: [{ type: "text", text }]
9669
10445
  };
@@ -11845,7 +12621,9 @@ function toolKnowledgeDocument(tool) {
11845
12621
  function getKnowledgeDocuments() {
11846
12622
  return [
11847
12623
  ...STATIC_KNOWLEDGE_DOCUMENTS,
11848
- ...TOOL_CATALOG.filter((t) => !t.internal).map(toolKnowledgeDocument)
12624
+ ...TOOL_CATALOG.filter((t) => !t.internal && !t.hiddenFromPublicCount).map(
12625
+ toolKnowledgeDocument
12626
+ )
11849
12627
  ];
11850
12628
  }
11851
12629
  function tokenize(input) {
@@ -11962,7 +12740,7 @@ function registerDiscoveryTools(server) {
11962
12740
  if (query) {
11963
12741
  results = searchTools(query);
11964
12742
  }
11965
- results = results.filter((t) => !t.internal);
12743
+ results = results.filter((t) => !t.internal && !t.hiddenFromPublicCount);
11966
12744
  if (module) {
11967
12745
  const moduleTools = getToolsByModule(module);
11968
12746
  results = results.filter((t) => moduleTools.some((mt) => mt.name === t.name));
@@ -13893,11 +14671,11 @@ function hexToLab(hex) {
13893
14671
  const lb = srgbToLinear(b);
13894
14672
  const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
13895
14673
  const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
13896
- const z38 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
14674
+ const z40 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
13897
14675
  const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
13898
14676
  const fx = f(x / 0.95047);
13899
14677
  const fy = f(y / 1);
13900
- const fz = f(z38 / 1.08883);
14678
+ const fz = f(z40 / 1.08883);
13901
14679
  return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
13902
14680
  }
13903
14681
  function deltaE2000(lab1, lab2) {
@@ -14137,10 +14915,10 @@ function registerBrandRuntimeTools(server) {
14137
14915
  pagesScraped: meta.pagesScraped || 0
14138
14916
  }
14139
14917
  };
14140
- const envelope = asEnvelope23(runtime);
14918
+ const envelope2 = asEnvelope23(runtime);
14141
14919
  return {
14142
- structuredContent: envelope,
14143
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
14920
+ structuredContent: envelope2,
14921
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
14144
14922
  };
14145
14923
  }
14146
14924
  );
@@ -14280,9 +15058,9 @@ function registerBrandRuntimeTools(server) {
14280
15058
  }
14281
15059
  const profile = row.profile_data;
14282
15060
  const checkResult = computeBrandConsistency(content, profile);
14283
- const envelope = asEnvelope23(checkResult);
15061
+ const envelope2 = asEnvelope23(checkResult);
14284
15062
  return {
14285
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
15063
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
14286
15064
  };
14287
15065
  }
14288
15066
  );
@@ -14314,9 +15092,9 @@ function registerBrandRuntimeTools(server) {
14314
15092
  content_colors,
14315
15093
  threshold ?? 10
14316
15094
  );
14317
- const envelope = asEnvelope23(auditResult);
15095
+ const envelope2 = asEnvelope23(auditResult);
14318
15096
  return {
14319
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
15097
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
14320
15098
  };
14321
15099
  }
14322
15100
  );
@@ -14349,9 +15127,9 @@ function registerBrandRuntimeTools(server) {
14349
15127
  row.profile_data.typography,
14350
15128
  format
14351
15129
  );
14352
- const envelope = asEnvelope23({ format, tokens: output });
15130
+ const envelope2 = asEnvelope23({ format, tokens: output });
14353
15131
  return {
14354
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
15132
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
14355
15133
  };
14356
15134
  }
14357
15135
  );
@@ -14505,7 +15283,7 @@ function registerCarouselTools(server) {
14505
15283
  };
14506
15284
  }
14507
15285
  const userId = await getDefaultUserId();
14508
- const rateLimit = checkRateLimit("posting", `create_carousel:${userId}`);
15286
+ const rateLimit = checkRateLimit("generation", `create_carousel:${userId}`);
14509
15287
  if (!rateLimit.allowed) {
14510
15288
  return {
14511
15289
  content: [
@@ -14577,7 +15355,12 @@ function registerCarouselTools(server) {
14577
15355
  slideNumber: slide.slideNumber,
14578
15356
  jobId: null,
14579
15357
  model: image_model,
14580
- error: error ?? "No job ID returned"
15358
+ error: error ?? "No job ID returned",
15359
+ creditsReserved: data?.credits_reserved ?? null,
15360
+ creditsCharged: data?.credits_charged ?? null,
15361
+ creditsRefunded: data?.credits_refunded ?? null,
15362
+ billingStatus: data?.billing_status ?? "unknown",
15363
+ failureReason: data?.failure_reason ?? null
14581
15364
  };
14582
15365
  }
14583
15366
  const jobId = data.asyncJobId ?? data.taskId ?? null;
@@ -14589,14 +15372,24 @@ function registerCarouselTools(server) {
14589
15372
  slideNumber: slide.slideNumber,
14590
15373
  jobId,
14591
15374
  model: image_model,
14592
- error: null
15375
+ error: null,
15376
+ creditsReserved: 0,
15377
+ creditsCharged: data.creditsDeducted ?? perImageCost,
15378
+ creditsRefunded: 0,
15379
+ billingStatus: "charged",
15380
+ failureReason: null
14593
15381
  };
14594
15382
  } catch (err) {
14595
15383
  return {
14596
15384
  slideNumber: slide.slideNumber,
14597
15385
  jobId: null,
14598
15386
  model: image_model,
14599
- error: sanitizeError(err)
15387
+ error: sanitizeError(err),
15388
+ creditsReserved: null,
15389
+ creditsCharged: null,
15390
+ creditsRefunded: null,
15391
+ billingStatus: "unknown",
15392
+ failureReason: null
14600
15393
  };
14601
15394
  }
14602
15395
  })
@@ -14621,7 +15414,8 @@ function registerCarouselTools(server) {
14621
15414
  return {
14622
15415
  ...s,
14623
15416
  imageJobId: job?.jobId ?? null,
14624
- imageError: job?.error ?? null
15417
+ imageError: job?.error ?? null,
15418
+ imageBillingStatus: job?.billingStatus ?? "unknown"
14625
15419
  };
14626
15420
  }),
14627
15421
  imageModel: image_model,
@@ -14633,11 +15427,19 @@ function registerCarouselTools(server) {
14633
15427
  jobIds: successfulJobs.map((j) => j.jobId),
14634
15428
  failedSlides: failedJobs.map((j) => ({
14635
15429
  slideNumber: j.slideNumber,
14636
- error: j.error
15430
+ error: j.error,
15431
+ credits_reserved: j.creditsReserved,
15432
+ credits_charged: j.creditsCharged,
15433
+ credits_refunded: j.creditsRefunded,
15434
+ billing_status: j.billingStatus,
15435
+ failure_reason: j.failureReason
14637
15436
  })),
14638
15437
  credits: {
14639
15438
  textGeneration: textCredits,
14640
15439
  imagesEstimated: successfulJobs.length * perImageCost,
15440
+ imagesCharged: imageJobs.reduce((sum, job) => sum + (job.creditsCharged ?? 0), 0),
15441
+ imagesRefunded: imageJobs.reduce((sum, job) => sum + (job.creditsRefunded ?? 0), 0),
15442
+ billingUnknownSlides: imageJobs.filter((job) => job.billingStatus === "unknown").length,
14641
15443
  totalEstimated: textCredits + successfulJobs.length * perImageCost
14642
15444
  }
14643
15445
  }
@@ -14833,7 +15635,7 @@ function registerHyperframesTools(server) {
14833
15635
  );
14834
15636
  server.tool(
14835
15637
  "render_hyperframes",
14836
- "Render an HTML video composition (Hyperframes) to MP4. Author the composition as HTML with data-* timing attributes and GSAP timelines \u2014 frame-accurate, no React build step. Use list_hyperframes_blocks to see the pre-built block catalog. Returns a job ID \u2014 poll with check_status. Note: F4 v1 ships the EF + scaffold; the worker container needs the hyperframes runtime installed (Phase 2) before this returns finished MP4s.",
15638
+ "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.",
14837
15639
  {
14838
15640
  composition_html: z30.string().max(5e5).optional().describe(
14839
15641
  "Inline HTML composition (full <html>...</html>). Max 500KB. Use composition_url for larger."
@@ -14845,7 +15647,9 @@ function registerHyperframesTools(server) {
14845
15647
  aspect_ratio: z30.enum(["9:16", "16:9", "1:1"]).optional().describe('Output aspect ratio. Default "9:16".'),
14846
15648
  duration_sec: z30.number().min(0.1).max(600).describe("Output duration in seconds. Required. Max 600 (10 min)."),
14847
15649
  fps: z30.union([z30.literal(24), z30.literal(30), z30.literal(60)]).optional().describe("Frames per second. Default 30."),
14848
- quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master.")
15650
+ quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master."),
15651
+ project_id: z30.string().optional().describe("Project ID to associate the Hyperframes render with."),
15652
+ response_format: z30.enum(["text", "json"]).optional().describe("Response format. Use json for a stable job_id handoff.")
14849
15653
  },
14850
15654
  async ({
14851
15655
  composition_html,
@@ -14854,7 +15658,9 @@ function registerHyperframesTools(server) {
14854
15658
  aspect_ratio,
14855
15659
  duration_sec,
14856
15660
  fps,
14857
- quality
15661
+ quality,
15662
+ project_id,
15663
+ response_format
14858
15664
  }) => {
14859
15665
  const userId = await getDefaultUserId();
14860
15666
  const rateLimit = checkRateLimit("generation", `render_hyperframes:${userId}`);
@@ -14899,23 +15705,36 @@ function registerHyperframesTools(server) {
14899
15705
  aspectRatio: aspect_ratio || "9:16",
14900
15706
  durationSec: duration_sec,
14901
15707
  fps: fps || 30,
14902
- quality: quality || "standard"
15708
+ quality: quality || "standard",
15709
+ ...project_id && { projectId: project_id }
14903
15710
  });
14904
15711
  if (error || !data?.jobId) {
14905
15712
  throw new Error(error || data?.error || "Failed to create Hyperframes render job");
14906
15713
  }
15714
+ const payload = {
15715
+ job_id: data.jobId,
15716
+ jobId: data.jobId,
15717
+ status: data.status,
15718
+ credits_cost: data.creditsCost,
15719
+ credits: data.creditsCost,
15720
+ duration_sec,
15721
+ fps: fps || 30,
15722
+ aspect_ratio: aspect_ratio || "9:16",
15723
+ quality: quality || "standard",
15724
+ project_id: project_id || null
15725
+ };
14907
15726
  return {
14908
15727
  content: [
14909
15728
  {
14910
15729
  type: "text",
14911
- text: [
15730
+ text: response_format === "json" ? JSON.stringify({ data: payload }) : [
14912
15731
  `Hyperframes render job queued.`,
14913
15732
  ` Job ID: ${data.jobId}`,
14914
15733
  ` Credits: ${data.creditsCost}`,
14915
15734
  ` Duration: ${duration_sec}s @ ${fps || 30}fps (${aspect_ratio || "9:16"})`,
14916
15735
  ` Quality: ${quality || "standard"}`,
14917
15736
  ``,
14918
- `Poll with check_status. Note: until F4 Phase 2 wires the runtime, the job will fail with a clear "HyperframesNotInstalled" message.`
15737
+ `Poll with check_status.`
14919
15738
  ].join("\n")
14920
15739
  }
14921
15740
  ]
@@ -14925,7 +15744,7 @@ function registerHyperframesTools(server) {
14925
15744
  content: [
14926
15745
  {
14927
15746
  type: "text",
14928
- text: `Failed to queue Hyperframes render: ${err instanceof Error ? err.message : "Unknown error"}`
15747
+ text: `Failed to queue Hyperframes render: ${sanitizeError(err)}`
14929
15748
  }
14930
15749
  ],
14931
15750
  isError: true
@@ -14941,6 +15760,7 @@ var init_hyperframes = __esm({
14941
15760
  init_rate_limit();
14942
15761
  init_supabase();
14943
15762
  init_edge_function();
15763
+ init_sanitize_error();
14944
15764
  HYPERFRAMES_BLOCKS = [
14945
15765
  // Transitions
14946
15766
  {
@@ -15058,49 +15878,122 @@ import {
15058
15878
  import { z as z31 } from "zod";
15059
15879
  import fs from "node:fs/promises";
15060
15880
  import path from "node:path";
15881
+ import { fileURLToPath } from "node:url";
15061
15882
  function startOfCurrentWeekMonday() {
15062
15883
  const now = /* @__PURE__ */ new Date();
15063
- const day = now.getDay();
15884
+ const day = now.getUTCDay();
15064
15885
  const monday = new Date(now);
15065
- monday.setUTCDate(now.getUTCDate() - day + (day === 0 ? -6 : 1));
15886
+ monday.setUTCDate(now.getUTCDate() - (day + 6) % 7);
15887
+ monday.setUTCHours(0, 0, 0, 0);
15066
15888
  return monday.toISOString().split("T")[0];
15067
15889
  }
15890
+ function endOfWeek(startDate) {
15891
+ const end = /* @__PURE__ */ new Date(`${startDate}T00:00:00.000Z`);
15892
+ end.setUTCDate(end.getUTCDate() + 7);
15893
+ end.setUTCMilliseconds(end.getUTCMilliseconds() - 1);
15894
+ return end.toISOString();
15895
+ }
15896
+ function isStrictIsoDate(value) {
15897
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
15898
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
15899
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
15900
+ }
15901
+ function publicRecentPost(value) {
15902
+ if (!value || typeof value !== "object") return null;
15903
+ const row = value;
15904
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
15905
+ return null;
15906
+ }
15907
+ const optional = (name) => typeof row[name] === "string" ? row[name] : null;
15908
+ return {
15909
+ id: row.id,
15910
+ platform: row.platform,
15911
+ status: row.status,
15912
+ title: optional("title"),
15913
+ external_post_id: optional("external_post_id"),
15914
+ published_at: optional("published_at"),
15915
+ scheduled_at: optional("scheduled_at"),
15916
+ created_at: row.created_at
15917
+ };
15918
+ }
15919
+ function calendarHtmlCandidates() {
15920
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
15921
+ return [
15922
+ // Bundled HTTP entry: import.meta.url is dist/http.js.
15923
+ path.join(moduleDir, "apps/content-calendar/mcp-app.html"),
15924
+ // Source/tsx/vitest: import.meta.url is src/apps/content-calendar.ts.
15925
+ path.resolve(moduleDir, "../../dist/apps/content-calendar/mcp-app.html"),
15926
+ // Local development fallback; never the sole package path.
15927
+ path.resolve(process.cwd(), "dist/apps/content-calendar/mcp-app.html")
15928
+ ];
15929
+ }
15930
+ async function readCalendarHtml() {
15931
+ for (const candidate of calendarHtmlCandidates()) {
15932
+ try {
15933
+ return await fs.readFile(candidate, "utf-8");
15934
+ } catch (error) {
15935
+ if (error.code !== "ENOENT") throw error;
15936
+ }
15937
+ }
15938
+ throw new Error("calendar_bundle_missing");
15939
+ }
15068
15940
  function registerContentCalendarApp(server) {
15069
15941
  registerAppTool(
15070
15942
  server,
15071
15943
  "open_content_calendar",
15072
15944
  {
15073
15945
  title: "Content Calendar",
15074
- description: "Open an interactive drag-drop calendar showing the user's scheduled posts for the current week. Users can reschedule via drag, filter by platform, drill into any post, or quick-create a new post. Backed by list_recent_posts, schedule_post, and find_next_slots \u2014 no new tools needed.",
15946
+ 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.",
15075
15947
  inputSchema: {
15076
- start_date: z31.string().optional().describe(
15948
+ project_id: z31.string().uuid().optional().describe(
15949
+ "Brand/project ID. Defaults to the authenticated key's project or the account default."
15950
+ ),
15951
+ start_date: z31.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe(
15077
15952
  "ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday."
15078
15953
  )
15079
15954
  },
15080
15955
  outputSchema: {
15081
15956
  start_date: z31.string(),
15957
+ project_id: z31.string(),
15082
15958
  posts: z31.array(RecentPostOutputSchema),
15083
15959
  scopes: z31.array(z31.string())
15084
15960
  },
15085
15961
  _meta: {
15086
15962
  ui: {
15087
- resourceUri: CALENDAR_URI,
15088
- csp: {
15089
- "img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
15090
- "connect-src": ["'self'"]
15091
- }
15963
+ resourceUri: CALENDAR_URI
15092
15964
  }
15093
15965
  }
15094
15966
  },
15095
- async ({ start_date }, extra) => {
15096
- const userScopes = extra.authInfo?.scopes ?? [];
15967
+ async ({ project_id, start_date }) => {
15968
+ const userScopes = getRequestScopes() ?? getAuthenticatedScopes();
15969
+ const resolvedProjectId = project_id ?? await getDefaultProjectId();
15970
+ if (!resolvedProjectId) {
15971
+ return {
15972
+ content: [
15973
+ {
15974
+ type: "text",
15975
+ text: "No project_id was provided and no default project is configured."
15976
+ }
15977
+ ],
15978
+ isError: true
15979
+ };
15980
+ }
15097
15981
  const fromDate = start_date ?? startOfCurrentWeekMonday();
15982
+ if (!isStrictIsoDate(fromDate)) {
15983
+ return {
15984
+ content: [{ type: "text", text: "start_date must be a valid YYYY-MM-DD date." }],
15985
+ isError: true
15986
+ };
15987
+ }
15098
15988
  const { data: result, error } = await callEdgeFunction(
15099
15989
  "mcp-data",
15100
15990
  {
15101
- action: "recent-posts",
15102
- days: 14,
15103
- limit: 50
15991
+ action: "scheduled-posts",
15992
+ start_date: `${fromDate}T00:00:00.000Z`,
15993
+ end_date: endOfWeek(fromDate),
15994
+ statuses: ["pending", "scheduled", "draft"],
15995
+ projectId: resolvedProjectId,
15996
+ project_id: resolvedProjectId
15104
15997
  },
15105
15998
  { timeoutMs: 15e3 }
15106
15999
  );
@@ -15109,19 +16002,16 @@ function registerContentCalendarApp(server) {
15109
16002
  content: [
15110
16003
  {
15111
16004
  type: "text",
15112
- text: `Failed to load posts: ${error || result?.error || "Unknown error"}`
16005
+ text: "The content calendar could not load posts. Please retry."
15113
16006
  }
15114
16007
  ],
15115
16008
  isError: true
15116
16009
  };
15117
16010
  }
15118
- const posts = (result.posts ?? []).filter((p) => {
15119
- const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
15120
- if (!ts) return false;
15121
- return ts.split("T")[0] >= fromDate;
15122
- });
16011
+ const posts = (result.posts ?? []).map(publicRecentPost).filter((post) => post !== null);
15123
16012
  const structuredContent = {
15124
16013
  start_date: fromDate,
16014
+ project_id: resolvedProjectId,
15125
16015
  posts,
15126
16016
  scopes: userScopes
15127
16017
  };
@@ -15140,37 +16030,60 @@ function registerContentCalendarApp(server) {
15140
16030
  server,
15141
16031
  CALENDAR_URI,
15142
16032
  CALENDAR_URI,
15143
- { mimeType: RESOURCE_MIME_TYPE },
16033
+ {
16034
+ mimeType: RESOURCE_MIME_TYPE,
16035
+ description: "Self-contained Social Neuron project content calendar.",
16036
+ _meta: { ui: { csp: CALENDAR_CSP } }
16037
+ },
15144
16038
  async () => {
15145
- const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
15146
16039
  try {
15147
- const html = await fs.readFile(htmlPath, "utf-8");
16040
+ const html = await readCalendarHtml();
15148
16041
  return {
15149
- contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: html }]
16042
+ contents: [
16043
+ {
16044
+ uri: CALENDAR_URI,
16045
+ mimeType: RESOURCE_MIME_TYPE,
16046
+ text: html,
16047
+ _meta: { ui: { csp: CALENDAR_CSP } }
16048
+ }
16049
+ ]
15150
16050
  };
15151
- } catch (err) {
16051
+ } catch {
15152
16052
  const errorHtml = `<!DOCTYPE html>
15153
16053
  <html><head><title>Content Calendar \u2014 unavailable</title></head>
15154
16054
  <body style="font-family:sans-serif;padding:24px;color:#444;">
15155
16055
  <h2>Content Calendar app bundle missing</h2>
15156
- <p>The server registered <code>open_content_calendar</code> but
15157
- <code>apps/content-calendar/dist/mcp-app.html</code> is not built.
15158
- Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
15159
- <p style="color:#999;font-size:12px;">${err.message}</p>
16056
+ <p>The interactive bundle is unavailable on this deployment. Please contact Social Neuron support.</p>
15160
16057
  </body></html>`;
15161
16058
  return {
15162
- contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }]
16059
+ contents: [
16060
+ {
16061
+ uri: CALENDAR_URI,
16062
+ mimeType: RESOURCE_MIME_TYPE,
16063
+ text: errorHtml,
16064
+ _meta: { ui: { csp: CALENDAR_CSP } }
16065
+ }
16066
+ ]
15163
16067
  };
15164
16068
  }
15165
16069
  }
15166
16070
  );
15167
16071
  }
15168
- var CALENDAR_URI, RecentPostOutputSchema;
16072
+ var CALENDAR_URI, CALENDAR_CSP, RecentPostOutputSchema;
15169
16073
  var init_content_calendar = __esm({
15170
16074
  "src/apps/content-calendar.ts"() {
15171
16075
  "use strict";
15172
16076
  init_edge_function();
15173
- CALENDAR_URI = "ui://content-calendar/mcp-app.html";
16077
+ init_request_context();
16078
+ init_supabase();
16079
+ CALENDAR_URI = "ui://content-calendar/v1/mcp-app.html";
16080
+ CALENDAR_CSP = {
16081
+ // The HTML is fully self-contained. Tool calls travel over the host bridge,
16082
+ // not fetch/XHR, so the secure default is no network or remote resources.
16083
+ connectDomains: [],
16084
+ resourceDomains: [],
16085
+ frameDomains: []
16086
+ };
15174
16087
  RecentPostOutputSchema = z31.object({
15175
16088
  id: z31.string(),
15176
16089
  platform: z31.string(),
@@ -15184,8 +16097,257 @@ var init_content_calendar = __esm({
15184
16097
  }
15185
16098
  });
15186
16099
 
15187
- // src/tools/connections.ts
16100
+ // src/apps/analytics-pulse.ts
16101
+ import {
16102
+ registerAppResource as registerAppResource2,
16103
+ registerAppTool as registerAppTool2,
16104
+ RESOURCE_MIME_TYPE as RESOURCE_MIME_TYPE2
16105
+ } from "@modelcontextprotocol/ext-apps/server";
15188
16106
  import { z as z32 } from "zod";
16107
+ import fs2 from "node:fs/promises";
16108
+ import path2 from "node:path";
16109
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
16110
+ function finiteMetric(value) {
16111
+ const parsed = Number(value ?? 0);
16112
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
16113
+ }
16114
+ function publicAnalyticsPost(value) {
16115
+ if (!value || typeof value !== "object") return null;
16116
+ const row = value;
16117
+ const post = row.posts && typeof row.posts === "object" ? row.posts : {};
16118
+ const platform3 = typeof row.platform === "string" ? row.platform : typeof post.platform === "string" ? post.platform : null;
16119
+ const capturedAt = typeof row.captured_at === "string" ? row.captured_at : null;
16120
+ if (!platform3 || !capturedAt) return null;
16121
+ const views = finiteMetric(row.views);
16122
+ const likes = finiteMetric(row.likes);
16123
+ const comments = finiteMetric(row.comments);
16124
+ const shares = finiteMetric(row.shares);
16125
+ const engagement = likes + comments + shares;
16126
+ return {
16127
+ platform: platform3,
16128
+ title: typeof post.title === "string" ? post.title : null,
16129
+ views,
16130
+ likes,
16131
+ comments,
16132
+ shares,
16133
+ engagement_rate: views > 0 ? Number((engagement / views * 100).toFixed(2)) : 0,
16134
+ captured_at: capturedAt,
16135
+ published_at: typeof post.published_at === "string" ? post.published_at : null
16136
+ };
16137
+ }
16138
+ function latestAnalyticsRows(values) {
16139
+ const rows = values.filter((value) => Boolean(value) && typeof value === "object").sort((a, b) => String(b.captured_at ?? "").localeCompare(String(a.captured_at ?? "")));
16140
+ const seen = /* @__PURE__ */ new Set();
16141
+ const latest = [];
16142
+ for (const row of rows) {
16143
+ if (typeof row.post_id !== "string" || typeof row.platform !== "string") continue;
16144
+ const key = `${row.post_id}:${row.platform.toLowerCase()}`;
16145
+ if (seen.has(key)) continue;
16146
+ seen.add(key);
16147
+ latest.push(row);
16148
+ }
16149
+ return latest;
16150
+ }
16151
+ function analyticsHtmlCandidates() {
16152
+ const moduleDir = path2.dirname(fileURLToPath2(import.meta.url));
16153
+ return [
16154
+ path2.join(moduleDir, "apps/analytics-pulse/mcp-app.html"),
16155
+ path2.resolve(moduleDir, "../../dist/apps/analytics-pulse/mcp-app.html"),
16156
+ path2.resolve(process.cwd(), "dist/apps/analytics-pulse/mcp-app.html")
16157
+ ];
16158
+ }
16159
+ async function readAnalyticsHtml() {
16160
+ for (const candidate of analyticsHtmlCandidates()) {
16161
+ try {
16162
+ return await fs2.readFile(candidate, "utf-8");
16163
+ } catch (error) {
16164
+ if (error.code !== "ENOENT") throw error;
16165
+ }
16166
+ }
16167
+ throw new Error("analytics_bundle_missing");
16168
+ }
16169
+ function registerAnalyticsPulseApp(server) {
16170
+ registerAppTool2(
16171
+ server,
16172
+ "open_analytics_pulse",
16173
+ {
16174
+ title: "Analytics Pulse",
16175
+ 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.",
16176
+ inputSchema: {
16177
+ project_id: z32.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default."),
16178
+ platform: z32.enum(PLATFORM_VALUES).optional().describe("Optional platform filter."),
16179
+ days: z32.number().int().min(1).max(365).optional().describe("Lookback window. Defaults to 30 days.")
16180
+ },
16181
+ outputSchema: {
16182
+ project_id: z32.string(),
16183
+ platform: z32.string().nullable(),
16184
+ days: z32.number(),
16185
+ summary: z32.object({
16186
+ views: z32.number(),
16187
+ engagement: z32.number(),
16188
+ engagement_rate: z32.number(),
16189
+ posts: z32.number()
16190
+ }),
16191
+ platform_totals: z32.array(
16192
+ z32.object({
16193
+ platform: z32.string(),
16194
+ views: z32.number(),
16195
+ engagement: z32.number(),
16196
+ posts: z32.number()
16197
+ })
16198
+ ),
16199
+ posts: z32.array(AnalyticsPostOutputSchema)
16200
+ },
16201
+ _meta: { ui: { resourceUri: ANALYTICS_URI } }
16202
+ },
16203
+ async ({ project_id, platform: platform3, days }) => {
16204
+ const resolvedProjectId = project_id ?? await getDefaultProjectId();
16205
+ if (!resolvedProjectId) {
16206
+ return {
16207
+ content: [{ type: "text", text: "No project_id was provided and no default project is configured." }],
16208
+ isError: true
16209
+ };
16210
+ }
16211
+ const lookbackDays = days ?? 30;
16212
+ const { data: result, error } = await callEdgeFunction(
16213
+ "mcp-data",
16214
+ {
16215
+ action: "analytics",
16216
+ projectId: resolvedProjectId,
16217
+ project_id: resolvedProjectId,
16218
+ days: lookbackDays,
16219
+ // The table stores cumulative snapshots. Request a wider window and
16220
+ // deduplicate below so refresh frequency cannot inflate the totals.
16221
+ limit: 100,
16222
+ latestOnly: true,
16223
+ ...platform3 ? { platform: platform3 } : {}
16224
+ },
16225
+ { timeoutMs: 15e3 }
16226
+ );
16227
+ if (error || !result?.success) {
16228
+ return {
16229
+ content: [{ type: "text", text: "The analytics dashboard could not load data. Please retry." }],
16230
+ isError: true
16231
+ };
16232
+ }
16233
+ const posts = latestAnalyticsRows(result.rows ?? []).map(publicAnalyticsPost).filter((post) => post !== null).sort((a, b) => b.views - a.views);
16234
+ const totals = /* @__PURE__ */ new Map();
16235
+ let totalViews = 0;
16236
+ let totalEngagement = 0;
16237
+ for (const post of posts) {
16238
+ const engagement = post.likes + post.comments + post.shares;
16239
+ totalViews += post.views;
16240
+ totalEngagement += engagement;
16241
+ const aggregate = totals.get(post.platform) ?? {
16242
+ platform: post.platform,
16243
+ views: 0,
16244
+ engagement: 0,
16245
+ posts: 0
16246
+ };
16247
+ aggregate.views += post.views;
16248
+ aggregate.engagement += engagement;
16249
+ aggregate.posts += 1;
16250
+ totals.set(post.platform, aggregate);
16251
+ }
16252
+ const structuredContent = {
16253
+ project_id: resolvedProjectId,
16254
+ platform: platform3 ?? null,
16255
+ days: lookbackDays,
16256
+ summary: {
16257
+ views: totalViews,
16258
+ engagement: totalEngagement,
16259
+ engagement_rate: totalViews > 0 ? Number((totalEngagement / totalViews * 100).toFixed(2)) : 0,
16260
+ posts: posts.length
16261
+ },
16262
+ platform_totals: [...totals.values()].sort((a, b) => b.views - a.views),
16263
+ posts
16264
+ };
16265
+ return {
16266
+ structuredContent,
16267
+ content: [
16268
+ {
16269
+ type: "text",
16270
+ text: `Loaded ${posts.length} analytics record${posts.length === 1 ? "" : "s"} for the last ${lookbackDays} days.`
16271
+ }
16272
+ ]
16273
+ };
16274
+ }
16275
+ );
16276
+ registerAppResource2(
16277
+ server,
16278
+ ANALYTICS_URI,
16279
+ ANALYTICS_URI,
16280
+ {
16281
+ mimeType: RESOURCE_MIME_TYPE2,
16282
+ description: "Self-contained Social Neuron project analytics dashboard.",
16283
+ _meta: { ui: { csp: ANALYTICS_CSP } }
16284
+ },
16285
+ async () => {
16286
+ try {
16287
+ const html = await readAnalyticsHtml();
16288
+ return {
16289
+ contents: [
16290
+ {
16291
+ uri: ANALYTICS_URI,
16292
+ mimeType: RESOURCE_MIME_TYPE2,
16293
+ text: html,
16294
+ _meta: { ui: { csp: ANALYTICS_CSP } }
16295
+ }
16296
+ ]
16297
+ };
16298
+ } catch {
16299
+ return {
16300
+ contents: [
16301
+ {
16302
+ uri: ANALYTICS_URI,
16303
+ mimeType: RESOURCE_MIME_TYPE2,
16304
+ 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>",
16305
+ _meta: { ui: { csp: ANALYTICS_CSP } }
16306
+ }
16307
+ ]
16308
+ };
16309
+ }
16310
+ }
16311
+ );
16312
+ }
16313
+ var ANALYTICS_URI, ANALYTICS_CSP, PLATFORM_VALUES, AnalyticsPostOutputSchema;
16314
+ var init_analytics_pulse = __esm({
16315
+ "src/apps/analytics-pulse.ts"() {
16316
+ "use strict";
16317
+ init_edge_function();
16318
+ init_supabase();
16319
+ ANALYTICS_URI = "ui://analytics-pulse/v1/mcp-app.html";
16320
+ ANALYTICS_CSP = {
16321
+ connectDomains: [],
16322
+ resourceDomains: [],
16323
+ frameDomains: []
16324
+ };
16325
+ PLATFORM_VALUES = [
16326
+ "youtube",
16327
+ "tiktok",
16328
+ "instagram",
16329
+ "twitter",
16330
+ "linkedin",
16331
+ "facebook",
16332
+ "threads",
16333
+ "bluesky"
16334
+ ];
16335
+ AnalyticsPostOutputSchema = z32.object({
16336
+ platform: z32.string(),
16337
+ title: z32.string().nullable(),
16338
+ views: z32.number(),
16339
+ likes: z32.number(),
16340
+ comments: z32.number(),
16341
+ shares: z32.number(),
16342
+ engagement_rate: z32.number(),
16343
+ captured_at: z32.string(),
16344
+ published_at: z32.string().nullable()
16345
+ });
16346
+ }
16347
+ });
16348
+
16349
+ // src/tools/connections.ts
16350
+ import { z as z33 } from "zod";
15189
16351
  function findActiveAccount(accounts, platform3) {
15190
16352
  const target = platform3.toLowerCase();
15191
16353
  return accounts.find((a) => {
@@ -15198,16 +16360,16 @@ function registerConnectionTools(server) {
15198
16360
  "start_platform_connection",
15199
16361
  "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.",
15200
16362
  {
15201
- platform: z32.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
15202
- project_id: z32.string().optional().describe(
16363
+ platform: z33.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
16364
+ project_id: z33.string().optional().describe(
15203
16365
  "Brand/project ID to bind the new social account to. Use this when connecting a brand-specific account."
15204
16366
  ),
15205
- response_format: z32.enum(["text", "json"]).optional().describe("Response format. Default: text.")
16367
+ response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
15206
16368
  },
15207
16369
  async ({ platform: platform3, project_id, response_format }) => {
15208
16370
  const format = response_format ?? "text";
15209
16371
  const userId = await getDefaultUserId();
15210
- const rl = checkRateLimit("read", `start_platform_connection:${userId}`);
16372
+ const rl = checkRateLimit("posting", `start_platform_connection:${userId}`);
15211
16373
  if (!rl.allowed) {
15212
16374
  return {
15213
16375
  content: [
@@ -15287,13 +16449,13 @@ function registerConnectionTools(server) {
15287
16449
  "wait_for_connection",
15288
16450
  "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.",
15289
16451
  {
15290
- platform: z32.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
15291
- project_id: z32.string().optional().describe(
16452
+ platform: z33.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
16453
+ project_id: z33.string().optional().describe(
15292
16454
  "Brand/project ID to scope the connection poll. Use the same project_id passed to start_platform_connection."
15293
16455
  ),
15294
- timeout_s: z32.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
15295
- poll_interval_s: z32.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
15296
- response_format: z32.enum(["text", "json"]).optional().describe("Response format. Default: text.")
16456
+ timeout_s: z33.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
16457
+ poll_interval_s: z33.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
16458
+ response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
15297
16459
  },
15298
16460
  async ({ platform: platform3, project_id, timeout_s, poll_interval_s, response_format }) => {
15299
16461
  const format = response_format ?? "text";
@@ -15449,7 +16611,7 @@ var init_connections = __esm({
15449
16611
  });
15450
16612
 
15451
16613
  // src/tools/harness.ts
15452
- import { z as z33 } from "zod";
16614
+ import { z as z34 } from "zod";
15453
16615
  function registerHarnessTools(server, _ctx) {
15454
16616
  server.tool(
15455
16617
  "write_agent_reflection",
@@ -15554,38 +16716,38 @@ var init_harness = __esm({
15554
16716
  "engager"
15555
16717
  ];
15556
16718
  writeReflectionSchema = {
15557
- reflection_text: z33.string().min(1).max(4e3).describe("The verbal reflection text produced by the agent. 1\u20134000 characters."),
15558
- generated_by_agent: z33.enum(ALLOWED_AGENTS).describe(
16719
+ reflection_text: z34.string().min(1).max(4e3).describe("The verbal reflection text produced by the agent. 1\u20134000 characters."),
16720
+ generated_by_agent: z34.enum(ALLOWED_AGENTS).describe(
15559
16721
  "Which agent produced this reflection. Must be one of: conductor, brand-brain, drafter, publisher, analyst, engager."
15560
16722
  ),
15561
- provenance: z33.object({
15562
- content_history_id: z33.string().optional().describe("Related content_history row UUID."),
15563
- outcome_event_id: z33.string().optional().describe("Related outcome event UUID."),
15564
- prm_score_ids: z33.array(z33.string()).optional().describe("PRM score IDs that informed this reflection."),
15565
- handoff_ids: z33.array(z33.string()).optional().describe("Handoff event IDs that triggered this reflection.")
16723
+ provenance: z34.object({
16724
+ content_history_id: z34.string().optional().describe("Related content_history row UUID."),
16725
+ outcome_event_id: z34.string().optional().describe("Related outcome event UUID."),
16726
+ prm_score_ids: z34.array(z34.string()).optional().describe("PRM score IDs that informed this reflection."),
16727
+ handoff_ids: z34.array(z34.string()).optional().describe("Handoff event IDs that triggered this reflection.")
15566
16728
  }).strict().describe(
15567
16729
  "Source evidence for this reflection. Only these four keys are accepted \u2014 unknown keys are rejected to prevent spurious provenance claims."
15568
16730
  ),
15569
- brand_id: z33.string().describe("Brand profile UUID this reflection belongs to."),
15570
- pipeline_id: z33.string().optional().describe("Optional: pipeline run UUID."),
15571
- post_id: z33.string().optional().describe("Optional: post UUID if reflection targets a post.")
16731
+ brand_id: z34.string().describe("Brand profile UUID this reflection belongs to."),
16732
+ pipeline_id: z34.string().optional().describe("Optional: pipeline run UUID."),
16733
+ post_id: z34.string().optional().describe("Optional: post UUID if reflection targets a post.")
15572
16734
  };
15573
16735
  readReflectionSchema = {
15574
- brand_id: z33.string().describe("Brand profile UUID to read reflections for."),
15575
- generated_by_agent: z33.enum(ALLOWED_AGENTS).optional().describe(
16736
+ brand_id: z34.string().describe("Brand profile UUID to read reflections for."),
16737
+ generated_by_agent: z34.enum(ALLOWED_AGENTS).optional().describe(
15576
16738
  "Optional filter: only return reflections produced by this agent. One of: conductor, brand-brain, drafter, publisher, analyst, engager."
15577
16739
  ),
15578
- limit: z33.number().int().min(1).max(100).optional().describe(
16740
+ limit: z34.number().int().min(1).max(100).optional().describe(
15579
16741
  "Maximum number of reflections to return. Clamped to [1, 100]. Default: 50. Results are ordered by created_at DESC, then id ASC (deterministic tiebreak)."
15580
16742
  )
15581
16743
  };
15582
16744
  recordOutcomeSchema = {
15583
- decision_event_id: z33.string().describe("UUID of the decision_events row to record an outcome for."),
15584
- horizon: z33.enum(["1h", "6h", "24h"]).describe(
16745
+ decision_event_id: z34.string().describe("UUID of the decision_events row to record an outcome for."),
16746
+ horizon: z34.enum(["1h", "6h", "24h"]).describe(
15585
16747
  "Observation horizon. Only horizon=24h with a non-null reward triggers a learning-loop update; 1h/6h are stored but inert for learning."
15586
16748
  ),
15587
- reward: z33.number().min(0).max(1).describe("Normalised reward signal in [0, 1]. Higher = better outcome."),
15588
- outcome_metrics: z33.record(z33.string(), z33.number()).optional().describe(
16749
+ reward: z34.number().min(0).max(1).describe("Normalised reward signal in [0, 1]. Higher = better outcome."),
16750
+ outcome_metrics: z34.record(z34.string(), z34.number()).optional().describe(
15589
16751
  'Optional map of raw metric name \u2192 value (e.g. {"likes": 42, "reach": 1200}). Stored verbatim; not used for learning directly.'
15590
16752
  )
15591
16753
  };
@@ -15593,7 +16755,7 @@ var init_harness = __esm({
15593
16755
  });
15594
16756
 
15595
16757
  // src/tools/hermes.ts
15596
- import { z as z34 } from "zod";
16758
+ import { z as z35 } from "zod";
15597
16759
  function asEnvelope25(data) {
15598
16760
  return {
15599
16761
  _meta: {
@@ -15609,12 +16771,12 @@ function registerHermesTools(server) {
15609
16771
  "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.",
15610
16772
  {
15611
16773
  platform: PLATFORM.describe("Target platform for the draft."),
15612
- copy: z34.string().min(1).max(8e3).describe("The draft post body."),
15613
- project_id: z34.string().optional().describe("SN project UUID. Optional but recommended."),
15614
- media_url: z34.string().url().optional().describe("Optional cover/media URL (R2 signed URL)."),
15615
- hermes_run_id: z34.string().optional().describe("Agent run id, for traceability."),
15616
- source_intel_ids: z34.array(z34.string()).optional().describe("Optional list of intel_signals.id rows that informed this draft."),
15617
- response_format: z34.enum(["text", "json"]).optional()
16774
+ copy: z35.string().min(1).max(8e3).describe("The draft post body."),
16775
+ project_id: z35.string().optional().describe("SN project UUID. Optional but recommended."),
16776
+ media_url: z35.string().url().optional().describe("Optional cover/media URL (R2 signed URL)."),
16777
+ hermes_run_id: z35.string().optional().describe("Agent run id, for traceability."),
16778
+ source_intel_ids: z35.array(z35.string()).optional().describe("Optional list of intel_signals.id rows that informed this draft."),
16779
+ response_format: z35.enum(["text", "json"]).optional()
15618
16780
  },
15619
16781
  async ({ platform: platform3, copy, project_id, media_url, hermes_run_id, response_format }) => {
15620
16782
  const { data, error } = await callEdgeFunction(
@@ -15652,15 +16814,15 @@ function registerHermesTools(server) {
15652
16814
  "record_voice_lesson",
15653
16815
  '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)".',
15654
16816
  {
15655
- project_id: z34.string().describe("SN project UUID."),
15656
- lesson: z34.string().min(1).max(500).describe('One-sentence rule (e.g. "lowercase IG hooks beat title-case").'),
15657
- evidence: z34.object({
15658
- engagement_lift_pct: z34.number().describe("Engagement lift vs baseline, in percentage points."),
15659
- sample_size: z34.number().int().min(1).describe("Number of posts behind this lesson."),
15660
- top_examples: z34.array(z34.string()).optional().describe("Up to 3 hook examples from the top quartile.")
16817
+ project_id: z35.string().describe("SN project UUID."),
16818
+ lesson: z35.string().min(1).max(500).describe('One-sentence rule (e.g. "lowercase IG hooks beat title-case").'),
16819
+ evidence: z35.object({
16820
+ engagement_lift_pct: z35.number().describe("Engagement lift vs baseline, in percentage points."),
16821
+ sample_size: z35.number().int().min(1).describe("Number of posts behind this lesson."),
16822
+ top_examples: z35.array(z35.string()).optional().describe("Up to 3 hook examples from the top quartile.")
15661
16823
  }).describe("Quantitative evidence backing the lesson."),
15662
- applies_to: z34.array(PLATFORM).min(1).describe("Platforms this lesson applies to."),
15663
- response_format: z34.enum(["text", "json"]).optional()
16824
+ applies_to: z35.array(PLATFORM).min(1).describe("Platforms this lesson applies to."),
16825
+ response_format: z35.enum(["text", "json"]).optional()
15664
16826
  },
15665
16827
  async ({ project_id, lesson, evidence, applies_to, response_format }) => {
15666
16828
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -15696,10 +16858,10 @@ function registerHermesTools(server) {
15696
16858
  "record_observation",
15697
16859
  '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.',
15698
16860
  {
15699
- summary: z34.string().min(1).max(2e3).describe("One-paragraph summary, shown to the account owner."),
15700
- deltas: z34.record(z34.string(), z34.union([z34.number(), z34.string(), z34.boolean()])).optional().describe("Optional structured key/value payload (e.g. {topic_x_er_pct: 23})."),
15701
- run_id: z34.string().optional().describe("Agent run id for traceability."),
15702
- response_format: z34.enum(["text", "json"]).optional()
16861
+ summary: z35.string().min(1).max(2e3).describe("One-paragraph summary, shown to the account owner."),
16862
+ 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})."),
16863
+ run_id: z35.string().optional().describe("Agent run id for traceability."),
16864
+ response_format: z35.enum(["text", "json"]).optional()
15703
16865
  },
15704
16866
  async ({ summary, deltas, run_id, response_format }) => {
15705
16867
  const { data, error } = await callEdgeFunction("mcp-data", { action: "record-observation", summary, deltas, run_id });
@@ -15726,13 +16888,13 @@ function registerHermesTools(server) {
15726
16888
  "record_intel_signal",
15727
16889
  "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.",
15728
16890
  {
15729
- source: z34.string().min(1).max(100).describe('Watcher name (e.g. "news-watch", "hackernews-watch").'),
15730
- url: z34.string().url().max(2e3).describe("Canonical URL of the source. Used for dedupe."),
15731
- topic: z34.string().max(200).optional().describe("Best-fit topic key."),
15732
- title: z34.string().max(500).optional(),
15733
- summary: z34.string().max(4e3).optional().describe("One-paragraph summary of the signal."),
15734
- score: z34.number().optional().describe("Relevance score from the watcher (0\u201310 typical)."),
15735
- response_format: z34.enum(["text", "json"]).optional()
16891
+ source: z35.string().min(1).max(100).describe('Watcher name (e.g. "news-watch", "hackernews-watch").'),
16892
+ url: z35.string().url().max(2e3).describe("Canonical URL of the source. Used for dedupe."),
16893
+ topic: z35.string().max(200).optional().describe("Best-fit topic key."),
16894
+ title: z35.string().max(500).optional(),
16895
+ summary: z35.string().max(4e3).optional().describe("One-paragraph summary of the signal."),
16896
+ score: z35.number().optional().describe("Relevance score from the watcher (0\u201310 typical)."),
16897
+ response_format: z35.enum(["text", "json"]).optional()
15736
16898
  },
15737
16899
  async ({ source, url, topic, title, summary, score, response_format }) => {
15738
16900
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -15770,16 +16932,16 @@ function registerHermesTools(server) {
15770
16932
  "record_campaign_spend",
15771
16933
  "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.",
15772
16934
  {
15773
- campaign_id: z34.string().min(1).max(200).describe("Campaign identifier (slug)."),
15774
- category: z34.enum([
16935
+ campaign_id: z35.string().min(1).max(200).describe("Campaign identifier (slug)."),
16936
+ category: z35.enum([
15775
16937
  "hermes_drafts",
15776
16938
  "carousel_renders",
15777
16939
  "analytics_pulls",
15778
16940
  "paid_amplification",
15779
16941
  "other"
15780
16942
  ]).describe("Cost category."),
15781
- amount_usd: z34.number().min(0).describe("Amount in USD; supports 4 decimal places."),
15782
- response_format: z34.enum(["text", "json"]).optional()
16943
+ amount_usd: z35.number().min(0).describe("Amount in USD; supports 4 decimal places."),
16944
+ response_format: z35.enum(["text", "json"]).optional()
15783
16945
  },
15784
16946
  async ({ campaign_id, category, amount_usd, response_format }) => {
15785
16947
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -15814,7 +16976,7 @@ function registerHermesTools(server) {
15814
16976
  "get_active_campaigns",
15815
16977
  "List currently-running campaigns with thesis, budget, hero format, and current spend. Use to bias drafts toward active campaign themes.",
15816
16978
  {
15817
- response_format: z34.enum(["text", "json"]).optional()
16979
+ response_format: z35.enum(["text", "json"]).optional()
15818
16980
  },
15819
16981
  async ({ response_format }) => {
15820
16982
  const { data, error } = await callEdgeFunction("mcp-data", { action: "get-active-campaigns" });
@@ -15863,7 +17025,7 @@ var init_hermes = __esm({
15863
17025
  "use strict";
15864
17026
  init_edge_function();
15865
17027
  init_version();
15866
- PLATFORM = z34.enum([
17028
+ PLATFORM = z35.enum([
15867
17029
  "instagram",
15868
17030
  "twitter",
15869
17031
  "linkedin",
@@ -15909,7 +17071,20 @@ var init_skills_manifest = __esm({
15909
17071
  });
15910
17072
 
15911
17073
  // src/tools/skills.ts
15912
- import { z as z35 } from "zod";
17074
+ import { z as z36 } from "zod";
17075
+ function renderCatalogRow(row) {
17076
+ const lines = [];
17077
+ const platform3 = row.platform ? ` \xB7 ${row.platform}` : "";
17078
+ lines.push(`${row.slug} (${row.kind}${platform3})`);
17079
+ const desc = row.frontmatter && typeof row.frontmatter.description === "string" ? row.frontmatter.description : null;
17080
+ if (desc) lines.push(` ${desc}`);
17081
+ const lock = row.locked ? ` \u{1F512} (upgrade to unlock)` : "";
17082
+ lines.push(` Tier: ${row.tier_minimum}${lock}`);
17083
+ lines.push(
17084
+ ` Model: ${row.model_id ?? "n/a"} \xB7 ${row.body_chars} chars` + (row.updated_at ? ` \xB7 updated ${row.updated_at}` : "")
17085
+ );
17086
+ return lines.join("\n");
17087
+ }
15913
17088
  function asEnvelope26(data) {
15914
17089
  return {
15915
17090
  _meta: {
@@ -15937,19 +17112,88 @@ function registerSkillsTools(server) {
15937
17112
  "list_skills",
15938
17113
  '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.',
15939
17114
  {
15940
- studio: z35.enum(STUDIO_VALUES).optional().describe("Filter to one studio (video, avatar, carousel, voice, caption, edit)."),
15941
- featured_only: z35.boolean().optional().describe("Return only featured (recommended) skills. Defaults to false."),
15942
- response_format: z35.enum(["text", "json"]).optional().describe("Response format. Defaults to text \u2014 human-readable summary.")
17115
+ studio: z36.enum(STUDIO_VALUES).optional().describe(
17116
+ "Filter to one studio (video, avatar, carousel, voice, caption, edit)."
17117
+ ),
17118
+ featured_only: z36.boolean().optional().describe(
17119
+ "Return only featured (recommended) skills. Defaults to false."
17120
+ ),
17121
+ response_format: z36.enum(["text", "json"]).optional().describe(
17122
+ "Response format. Defaults to text \u2014 human-readable summary."
17123
+ )
15943
17124
  },
15944
17125
  async ({ studio, featured_only, response_format }) => {
15945
- const skills = listSkills({ studio, featuredOnly: featured_only });
15946
17126
  const format = response_format ?? "text";
17127
+ const { data: efData, error: efError } = await callEdgeFunction("mcp-data", { action: "get-skills" });
17128
+ const catalogRows = efData?.skills;
17129
+ if (!efError && Array.isArray(catalogRows) && catalogRows.length > 0) {
17130
+ const workflows = listSkills({ studio, featuredOnly: featured_only });
17131
+ const filteredGuideRows = catalogRows.filter(
17132
+ (row) => !studio && (!featured_only || row.frontmatter?.featured === true)
17133
+ );
17134
+ const guides = filteredGuideRows.map((row) => ({
17135
+ ...row,
17136
+ use_with: "get_skill"
17137
+ }));
17138
+ const total = guides.length + workflows.length;
17139
+ if (format === "json") {
17140
+ return {
17141
+ content: [
17142
+ {
17143
+ type: "text",
17144
+ text: JSON.stringify(
17145
+ asEnvelope26({
17146
+ count: total,
17147
+ guides,
17148
+ workflows: workflows.map((w) => ({
17149
+ ...w,
17150
+ use_with: "run_skill"
17151
+ }))
17152
+ }),
17153
+ null,
17154
+ 2
17155
+ )
17156
+ }
17157
+ ]
17158
+ };
17159
+ }
17160
+ const guideBlocks = filteredGuideRows.map(
17161
+ (row) => `${renderCatalogRow(row)}
17162
+ \u2192 Read it: get_skill(slug: "${row.slug}")`
17163
+ ).join("\n\n");
17164
+ const workflowBlocks = workflows.map(renderSkillSummary).join("\n\n");
17165
+ const header2 = `${total} skill${total === 1 ? "" : "s"} available (${guides.length} guide${guides.length === 1 ? "" : "s"} \xB7 ${workflows.length} workflow${workflows.length === 1 ? "" : "s"})
17166
+ ${"=".repeat(40)}`;
17167
+ const sections = [
17168
+ guides.length > 0 ? `GUIDES \u2014 living how-to documents. Fetch with get_skill(slug).
17169
+
17170
+ ${guideBlocks}` : "",
17171
+ workflows.length > 0 ? `WORKFLOWS \u2014 executable content pipelines. Launch with run_skill(skill_id).
17172
+
17173
+ ${workflowBlocks}` : ""
17174
+ ].filter(Boolean);
17175
+ return {
17176
+ content: [
17177
+ {
17178
+ type: "text",
17179
+ text: `${header2}
17180
+
17181
+ ${sections.join("\n\n")}`
17182
+ }
17183
+ ]
17184
+ };
17185
+ }
17186
+ const skills = listSkills({ studio, featuredOnly: featured_only });
15947
17187
  if (format === "json") {
15948
17188
  return {
15949
17189
  content: [
15950
17190
  {
15951
17191
  type: "text",
15952
- text: JSON.stringify(asEnvelope26({ count: skills.length, skills }), null, 2)
17192
+ text: JSON.stringify(
17193
+ asEnvelope26({ count: skills.length, skills }),
17194
+ null,
17195
+ 2
17196
+ )
15953
17197
  }
15954
17198
  ]
15955
17199
  };
@@ -15959,7 +17203,9 @@ function registerSkillsTools(server) {
15959
17203
  content: [
15960
17204
  {
15961
17205
  type: "text",
15962
- 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(", ")
17206
+ 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(
17207
+ ", "
17208
+ )
15963
17209
  }
15964
17210
  ]
15965
17211
  };
@@ -15978,18 +17224,22 @@ ${blocks}` }]
15978
17224
  "run_skill",
15979
17225
  "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.",
15980
17226
  {
15981
- skill_id: z35.string().describe(
17227
+ skill_id: z36.string().describe(
15982
17228
  'The id of the skill to run (e.g. "skill-brand-locked-viral-hook-reel"). Use list_skills to discover available ids.'
15983
17229
  ),
15984
- topic: z35.string().min(1).describe('What the content is about (e.g. "why we built Social Neuron").'),
15985
- audience: z35.string().optional().describe(
17230
+ topic: z36.string().min(1).describe(
17231
+ 'What the content is about (e.g. "why we built Social Neuron").'
17232
+ ),
17233
+ audience: z36.string().optional().describe(
15986
17234
  'Who the content is for (e.g. "first-time founders"). Defaults to brand persona.'
15987
17235
  ),
15988
- hook: z35.string().optional().describe(
17236
+ hook: z36.string().optional().describe(
15989
17237
  "Optional explicit hook. If omitted, the skill drafts and scores 3-5 candidates."
15990
17238
  ),
15991
- cta: z35.string().optional().describe("Optional call-to-action override. Defaults to brand standard CTA."),
15992
- response_format: z35.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
17239
+ cta: z36.string().optional().describe(
17240
+ "Optional call-to-action override. Defaults to brand standard CTA."
17241
+ ),
17242
+ response_format: z36.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
15993
17243
  },
15994
17244
  async ({ skill_id, topic, audience, hook, cta, response_format }) => {
15995
17245
  const skill = getSkill(skill_id);
@@ -16057,19 +17307,96 @@ ${blocks}` }]
16057
17307
  };
16058
17308
  }
16059
17309
  );
17310
+ server.tool(
17311
+ "get_skill",
17312
+ `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).`,
17313
+ {
17314
+ slug: z36.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9-]*$/).describe(
17315
+ 'The skill slug (e.g. "tiktok-content"). Use list_skills to discover slugs.'
17316
+ ),
17317
+ response_format: z36.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
17318
+ },
17319
+ async ({ slug, response_format }) => {
17320
+ const format = response_format ?? "text";
17321
+ const { data, error } = await callEdgeFunction("mcp-data", {
17322
+ action: "get-skill",
17323
+ slug
17324
+ });
17325
+ if (error) {
17326
+ return toolError(
17327
+ "upstream_error",
17328
+ "The skill catalogue could not be loaded. Please retry."
17329
+ );
17330
+ }
17331
+ const skill = data?.skill;
17332
+ if (!skill) {
17333
+ return toolError("not_found", `No skill found with slug "${slug}".`, {
17334
+ recover_with: ["Call list_skills to see available skills."]
17335
+ });
17336
+ }
17337
+ if (skill.locked) {
17338
+ return toolError(
17339
+ "permission_denied",
17340
+ "This skill requires a higher plan tier.",
17341
+ {
17342
+ details: { slug: skill.slug, tier_minimum: skill.tier_minimum },
17343
+ recover_with: [
17344
+ "Upgrade the account or choose an unlocked skill from list_skills."
17345
+ ]
17346
+ }
17347
+ );
17348
+ }
17349
+ if (format === "json") {
17350
+ return {
17351
+ content: [
17352
+ {
17353
+ type: "text",
17354
+ text: JSON.stringify(asEnvelope26(skill), null, 2)
17355
+ }
17356
+ ]
17357
+ };
17358
+ }
17359
+ const platform3 = skill.platform ? ` \xB7 ${skill.platform}` : "";
17360
+ const lock = skill.locked ? " \u{1F512} (upgrade to unlock)" : "";
17361
+ const header = [
17362
+ `${skill.slug} (${skill.kind}${platform3})`,
17363
+ "=".repeat(40),
17364
+ `Tier: ${skill.tier_minimum}${lock} \xB7 version ${skill.version}` + (skill.recipe_slug ? ` \xB7 recipe: ${skill.recipe_slug}` : ""),
17365
+ ""
17366
+ ].join("\n");
17367
+ const compiled = skill.compiled_section ? `
17368
+
17369
+ --- What's working now ---
17370
+ ${skill.compiled_section}` : "";
17371
+ return {
17372
+ content: [
17373
+ { type: "text", text: `${header}${skill.body}${compiled}` }
17374
+ ]
17375
+ };
17376
+ }
17377
+ );
16060
17378
  }
16061
17379
  var STUDIO_VALUES;
16062
17380
  var init_skills = __esm({
16063
17381
  "src/tools/skills.ts"() {
16064
17382
  "use strict";
16065
17383
  init_version();
17384
+ init_tool_error();
17385
+ init_edge_function();
16066
17386
  init_skills_manifest();
16067
- STUDIO_VALUES = ["video", "avatar", "carousel", "voice", "caption", "edit"];
17387
+ STUDIO_VALUES = [
17388
+ "video",
17389
+ "avatar",
17390
+ "carousel",
17391
+ "voice",
17392
+ "caption",
17393
+ "edit"
17394
+ ];
16068
17395
  }
16069
17396
  });
16070
17397
 
16071
17398
  // src/tools/loopPulse.ts
16072
- import { z as z36 } from "zod";
17399
+ import { z as z37 } from "zod";
16073
17400
  function asEnvelope27(data) {
16074
17401
  return {
16075
17402
  _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
@@ -16081,7 +17408,7 @@ function registerLoopPulseTools(server) {
16081
17408
  "get_loop_pulse",
16082
17409
  '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.',
16083
17410
  {
16084
- response_format: z36.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
17411
+ response_format: z37.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
16085
17412
  },
16086
17413
  async ({ response_format }) => {
16087
17414
  const format = response_format ?? "text";
@@ -16131,7 +17458,7 @@ var init_loopPulse = __esm({
16131
17458
  });
16132
17459
 
16133
17460
  // src/tools/banditState.ts
16134
- import { z as z37 } from "zod";
17461
+ import { z as z38 } from "zod";
16135
17462
  function asEnvelope28(data) {
16136
17463
  return {
16137
17464
  _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
@@ -16143,8 +17470,8 @@ function registerBanditStateTools(server) {
16143
17470
  "get_bandit_state",
16144
17471
  "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).",
16145
17472
  {
16146
- project_id: z37.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
16147
- platform: z37.enum([
17473
+ project_id: z38.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
17474
+ platform: z38.enum([
16148
17475
  "instagram",
16149
17476
  "tiktok",
16150
17477
  "youtube",
@@ -16156,7 +17483,7 @@ function registerBanditStateTools(server) {
16156
17483
  ]).optional().describe(
16157
17484
  "Lowercase platform name. Omit to return both platform-scoped and legacy platform-agnostic arms."
16158
17485
  ),
16159
- arm_type: z37.enum([
17486
+ arm_type: z38.enum([
16160
17487
  "hook_family",
16161
17488
  "hook_type",
16162
17489
  "format",
@@ -16170,8 +17497,8 @@ function registerBanditStateTools(server) {
16170
17497
  "posting_time_bucket",
16171
17498
  "content_format"
16172
17499
  ]).optional().describe("Arm dimension. Omit to return all types grouped."),
16173
- top_k: z37.number().min(1).max(50).optional().describe("Max arms per (arm_type) group. Default 5."),
16174
- response_format: z37.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
17500
+ top_k: z38.number().min(1).max(50).optional().describe("Max arms per (arm_type) group. Default 5."),
17501
+ response_format: z38.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
16175
17502
  },
16176
17503
  async ({ project_id, platform: platform3, arm_type, top_k, response_format }) => {
16177
17504
  const format = response_format ?? "text";
@@ -16234,12 +17561,134 @@ var init_banditState = __esm({
16234
17561
  }
16235
17562
  });
16236
17563
 
17564
+ // src/tools/lifecycle.ts
17565
+ import { z as z39 } from "zod";
17566
+ function envelope(data) {
17567
+ return {
17568
+ _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
17569
+ data
17570
+ };
17571
+ }
17572
+ function lifecycleFailure(error) {
17573
+ if (/not_found|not found/i.test(error)) {
17574
+ return toolError("not_found", "The requested object was not found in this project.");
17575
+ }
17576
+ if (/not_cancellable|publishing_in_progress|schedule_conflict|post_in_progress/i.test(error)) {
17577
+ return toolError("validation_error", "The object can no longer be cancelled in its current state.", {
17578
+ recover_with: ["Refresh its status before deciding the next action."]
17579
+ });
17580
+ }
17581
+ return toolError("upstream_error", "The lifecycle operation could not be completed. Please retry.");
17582
+ }
17583
+ async function projectContext(projectId) {
17584
+ return projectId ?? await getDefaultProjectId();
17585
+ }
17586
+ async function invokeLifecycle(action, projectId, identifiers) {
17587
+ const resolvedProjectId = await projectContext(projectId);
17588
+ if (!resolvedProjectId) {
17589
+ return toolError("validation_error", "A project_id is required because no default project is configured.");
17590
+ }
17591
+ const { data, error } = await callEdgeFunction("mcp-data", {
17592
+ action,
17593
+ projectId: resolvedProjectId,
17594
+ project_id: resolvedProjectId,
17595
+ ...identifiers
17596
+ });
17597
+ if (error) return lifecycleFailure(error);
17598
+ if (!data?.success) return toolError("upstream_error", "The lifecycle operation returned no result.");
17599
+ const result = envelope(data);
17600
+ return {
17601
+ structuredContent: result,
17602
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
17603
+ };
17604
+ }
17605
+ function registerLifecycleTools(server) {
17606
+ server.tool(
17607
+ "cancel_async_job",
17608
+ "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.",
17609
+ {
17610
+ job_id: z39.string().uuid().describe("Owned async_jobs ID returned by a generation tool."),
17611
+ project_id: PROJECT_ID,
17612
+ confirm: CONFIRM
17613
+ },
17614
+ async ({ job_id, project_id }) => invokeLifecycle("cancel-async-job", project_id, { job_id })
17615
+ );
17616
+ server.tool(
17617
+ "cancel_scheduled_post",
17618
+ "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.",
17619
+ {
17620
+ post_id: z39.string().uuid().describe("Owned scheduled post ID."),
17621
+ project_id: PROJECT_ID,
17622
+ confirm: CONFIRM
17623
+ },
17624
+ async ({ post_id, project_id }) => invokeLifecycle("cancel-scheduled-post", project_id, { post_id })
17625
+ );
17626
+ server.tool(
17627
+ "delete_carousel",
17628
+ "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.",
17629
+ {
17630
+ content_id: z39.string().uuid().describe("Owned carousel content_history ID."),
17631
+ project_id: PROJECT_ID,
17632
+ confirm: CONFIRM
17633
+ },
17634
+ async ({ content_id, project_id }) => invokeLifecycle("delete-carousel", project_id, { content_id })
17635
+ );
17636
+ server.tool(
17637
+ "delete_content_plan",
17638
+ "Permanently delete an owned content plan in one project. This does not cancel posts that were already scheduled from the plan.",
17639
+ {
17640
+ plan_id: z39.string().uuid().describe("Owned content plan ID."),
17641
+ project_id: PROJECT_ID,
17642
+ confirm: CONFIRM
17643
+ },
17644
+ async ({ plan_id, project_id }) => invokeLifecycle("delete-content-plan", project_id, { plan_id })
17645
+ );
17646
+ server.tool(
17647
+ "delete_autopilot_config",
17648
+ "Permanently delete an owned autopilot configuration in one project. Historical runs and already-published posts are retained.",
17649
+ {
17650
+ config_id: z39.string().uuid().describe("Owned autopilot configuration ID."),
17651
+ project_id: PROJECT_ID,
17652
+ confirm: CONFIRM
17653
+ },
17654
+ async ({ config_id, project_id }) => invokeLifecycle("delete-autopilot-config", project_id, { config_id })
17655
+ );
17656
+ }
17657
+ var PROJECT_ID, CONFIRM;
17658
+ var init_lifecycle = __esm({
17659
+ "src/tools/lifecycle.ts"() {
17660
+ "use strict";
17661
+ init_edge_function();
17662
+ init_supabase();
17663
+ init_tool_error();
17664
+ init_version();
17665
+ PROJECT_ID = z39.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default.");
17666
+ CONFIRM = z39.literal(true).describe("Must be true only after the user explicitly confirms this destructive action.");
17667
+ }
17668
+ });
17669
+
16237
17670
  // src/lib/register-tools.ts
17671
+ function redactBase64Payloads(args) {
17672
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
17673
+ let changed = false;
17674
+ const out = { ...args };
17675
+ for (const key of BASE64_PAYLOAD_KEYS) {
17676
+ const value = out[key];
17677
+ if (typeof value !== "string" || value.length < 64) continue;
17678
+ const body = value.replace(DATA_URI_PREFIX2, "");
17679
+ if (STRICT_BASE64.test(body)) {
17680
+ out[key] = `[base64:${body.length} chars omitted from prose scan]`;
17681
+ changed = true;
17682
+ }
17683
+ }
17684
+ return changed ? out : args;
17685
+ }
16238
17686
  function wrapToolWithScanner(toolName, handler) {
16239
17687
  return async function scannerWrappedHandler(...handlerArgs) {
16240
17688
  const args = handlerArgs[0];
16241
17689
  const ctx = handlerArgs[1];
16242
- const inputText = args === void 0 ? "{}" : typeof args === "string" ? args : JSON.stringify(args);
17690
+ const scanArgs = redactBase64Payloads(args);
17691
+ const inputText = scanArgs === void 0 ? "{}" : typeof scanArgs === "string" ? scanArgs : JSON.stringify(scanArgs);
16243
17692
  const inputScan = scan(inputText, {
16244
17693
  mode: "block",
16245
17694
  source: "mcp_tool_input",
@@ -16267,6 +17716,16 @@ function wrapToolWithScanner(toolName, handler) {
16267
17716
  source: "mcp_tool_output",
16268
17717
  user_id: ctx?.userId
16269
17718
  });
17719
+ if (!outputScan.passed) {
17720
+ try {
17721
+ ctx?.logScan?.(toolName, "output", outputScan);
17722
+ } catch {
17723
+ }
17724
+ return toolError(
17725
+ "server_error",
17726
+ "The response exceeded the safe output limit and was not returned."
17727
+ );
17728
+ }
16270
17729
  if (outputScan.sanitized_text !== void 0) {
16271
17730
  try {
16272
17731
  ctx?.logScan?.(toolName, "output", outputScan);
@@ -16275,7 +17734,10 @@ function wrapToolWithScanner(toolName, handler) {
16275
17734
  try {
16276
17735
  return JSON.parse(outputScan.sanitized_text);
16277
17736
  } catch {
16278
- return result;
17737
+ return toolError(
17738
+ "server_error",
17739
+ "The response could not be returned safely. Please retry or contact support."
17740
+ );
16279
17741
  }
16280
17742
  }
16281
17743
  return result;
@@ -16325,8 +17787,7 @@ function applyScopeEnforcement(server, scopeResolver) {
16325
17787
  details: {
16326
17788
  source: "wrapper",
16327
17789
  // A thrown exception escaped the handler — an unclassified fault.
16328
- error_type: "server_error",
16329
- exception: err instanceof Error ? err.message.slice(0, 200) : "unknown"
17790
+ error_type: "server_error"
16330
17791
  }
16331
17792
  });
16332
17793
  throw err;
@@ -16458,12 +17919,14 @@ function registerAllTools(server, options) {
16458
17919
  registerSkillsTools(server);
16459
17920
  registerLoopPulseTools(server);
16460
17921
  registerBanditStateTools(server);
17922
+ registerLifecycleTools(server);
16461
17923
  if (!options?.skipApps) {
16462
17924
  registerContentCalendarApp(server);
17925
+ registerAnalyticsPulseApp(server);
16463
17926
  }
16464
17927
  applyAnnotations(server);
16465
17928
  }
16466
- var RESPONSE_CHAR_LIMIT;
17929
+ var BASE64_PAYLOAD_KEYS, DATA_URI_PREFIX2, STRICT_BASE64, RESPONSE_CHAR_LIMIT;
16467
17930
  var init_register_tools = __esm({
16468
17931
  "src/lib/register-tools.ts"() {
16469
17932
  "use strict";
@@ -16505,12 +17968,17 @@ var init_register_tools = __esm({
16505
17968
  init_niche_research();
16506
17969
  init_hyperframes();
16507
17970
  init_content_calendar();
17971
+ init_analytics_pulse();
16508
17972
  init_connections();
16509
17973
  init_harness();
16510
17974
  init_hermes();
16511
17975
  init_skills();
16512
17976
  init_loopPulse();
16513
17977
  init_banditState();
17978
+ init_lifecycle();
17979
+ BASE64_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["file_data", "fileData"]);
17980
+ DATA_URI_PREFIX2 = /^data:[\w.+-]+\/[\w.+-]+;base64,/;
17981
+ STRICT_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
16514
17982
  RESPONSE_CHAR_LIMIT = 1e5;
16515
17983
  }
16516
17984
  });
@@ -16532,7 +18000,11 @@ function getCallHandler() {
16532
18000
  return cachedCallHandler;
16533
18001
  }
16534
18002
  function restToolNames() {
16535
- return new Set(TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal).map((t) => t.name));
18003
+ return new Set(
18004
+ TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal && !t.hiddenFromPublicCount).map(
18005
+ (t) => t.name
18006
+ )
18007
+ );
16536
18008
  }
16537
18009
  async function invokeToolRest(name, args) {
16538
18010
  const handler = getCallHandler();
@@ -17098,7 +18570,7 @@ function printSnUsage() {
17098
18570
  console.error("");
17099
18571
  console.error("Discovery:");
17100
18572
  console.error(" tools [--scope <scope>] [--module <module>] [--json]");
17101
- console.error(" info [--json]");
18573
+ console.error(" info [--offline] [--json]");
17102
18574
  console.error("");
17103
18575
  console.error("Content:");
17104
18576
  console.error(
@@ -17231,10 +18703,11 @@ var setup_exports = {};
17231
18703
  __export(setup_exports, {
17232
18704
  generatePKCE: () => generatePKCE,
17233
18705
  getAppBaseUrl: () => getAppBaseUrl,
18706
+ isValidSetupApiKey: () => isValidSetupApiKey,
17234
18707
  runLogout: () => runLogout,
17235
18708
  runSetup: () => runSetup
17236
18709
  });
17237
- import { createHash as createHash4, randomBytes, randomUUID as randomUUID4 } from "node:crypto";
18710
+ import { createHash as createHash3, randomBytes as randomBytes2, randomUUID as randomUUID4 } from "node:crypto";
17238
18711
  import { createServer } from "node:http";
17239
18712
  import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
17240
18713
  import { homedir as homedir3, platform as platform2 } from "node:os";
@@ -17243,12 +18716,15 @@ function base64url(buffer) {
17243
18716
  return buffer.toString("base64url");
17244
18717
  }
17245
18718
  function generatePKCE() {
17246
- const verifierBytes = randomBytes(32);
18719
+ const verifierBytes = randomBytes2(32);
17247
18720
  const codeVerifier = base64url(verifierBytes);
17248
- const challengeHash = createHash4("sha256").update(codeVerifier).digest();
18721
+ const challengeHash = createHash3("sha256").update(codeVerifier).digest();
17249
18722
  const codeChallenge = base64url(challengeHash);
17250
18723
  return { codeVerifier, codeChallenge };
17251
18724
  }
18725
+ function isValidSetupApiKey(value) {
18726
+ return typeof value === "string" && value.length >= 32 && value.length <= 512 && /^snk_live_[A-Za-z0-9_-]+$/.test(value);
18727
+ }
17252
18728
  function getAppBaseUrl() {
17253
18729
  return process.env.SOCIALNEURON_APP_URL || "https://www.socialneuron.com";
17254
18730
  }
@@ -17310,11 +18786,24 @@ function configureMcpClient(configPath) {
17310
18786
  return false;
17311
18787
  }
17312
18788
  }
17313
- function readBody(req) {
18789
+ function readBody(req, maxBytes = 16 * 1024) {
17314
18790
  return new Promise((resolve3, reject) => {
17315
18791
  const chunks = [];
17316
- req.on("data", (chunk) => chunks.push(chunk));
17317
- req.on("end", () => resolve3(Buffer.concat(chunks).toString("utf-8")));
18792
+ let total = 0;
18793
+ let tooLarge = false;
18794
+ req.on("data", (chunk) => {
18795
+ if (tooLarge) return;
18796
+ total += chunk.length;
18797
+ if (total > maxBytes) {
18798
+ tooLarge = true;
18799
+ reject(new Error("request_too_large"));
18800
+ return;
18801
+ }
18802
+ chunks.push(chunk);
18803
+ });
18804
+ req.on("end", () => {
18805
+ if (!tooLarge) resolve3(Buffer.concat(chunks).toString("utf-8"));
18806
+ });
17318
18807
  req.on("error", reject);
17319
18808
  });
17320
18809
  }
@@ -17327,14 +18816,13 @@ async function completePkceExchange(codeVerifier, state) {
17327
18816
  body: JSON.stringify({ code_verifier: codeVerifier, state })
17328
18817
  });
17329
18818
  if (!response.ok) {
17330
- const text = await response.text();
17331
- console.error(` PKCE exchange failed: ${text}`);
18819
+ console.error(` PKCE exchange failed (HTTP ${response.status}).`);
17332
18820
  return false;
17333
18821
  }
17334
18822
  const data = await response.json();
17335
18823
  return data.success === true;
17336
- } catch (err) {
17337
- console.error(` PKCE exchange error: ${err instanceof Error ? err.message : String(err)}`);
18824
+ } catch {
18825
+ console.error(" PKCE exchange failed because the authentication service was unavailable.");
17338
18826
  return false;
17339
18827
  }
17340
18828
  }
@@ -17345,7 +18833,8 @@ async function runSetup() {
17345
18833
  console.error("");
17346
18834
  console.error(" Privacy Notice:");
17347
18835
  console.error(" - Your API key is stored locally in your OS keychain");
17348
- console.error(" - Tool invocations are logged for usage metering (no content stored)");
18836
+ console.error(" - CLI telemetry records tool name, status, and duration \u2014 not tool input/output");
18837
+ console.error(" - Service-side content processing follows https://socialneuron.com/privacy");
17349
18838
  console.error(" - Set DO_NOT_TRACK=1 to disable telemetry");
17350
18839
  console.error(" - Data export/delete: https://www.socialneuron.com/settings");
17351
18840
  console.error("");
@@ -17369,7 +18858,6 @@ async function runSetup() {
17369
18858
  const open = (await import("open")).default;
17370
18859
  await open(authorizeUrl.toString());
17371
18860
  console.error(" Opening browser for authorization...");
17372
- console.error(` URL: ${authorizeUrl.toString()}`);
17373
18861
  console.error("");
17374
18862
  console.error(" Waiting for authorization (timeout: 120s)...");
17375
18863
  } catch {
@@ -17381,12 +18869,20 @@ async function runSetup() {
17381
18869
  console.error(" Waiting for authorization (timeout: 120s)...");
17382
18870
  }
17383
18871
  const result = await new Promise((resolve3) => {
18872
+ const callbackOrigin = new URL(baseUrl).origin;
17384
18873
  const timeout = setTimeout(() => {
17385
18874
  server.close();
17386
18875
  resolve3({ error: "Authorization timed out after 120 seconds." });
17387
18876
  }, 12e4);
17388
18877
  server.on("request", async (req, res) => {
17389
- res.setHeader("Access-Control-Allow-Origin", "*");
18878
+ const requestOrigin = req.headers.origin;
18879
+ if (requestOrigin !== callbackOrigin) {
18880
+ res.writeHead(403, { "Content-Type": "application/json" });
18881
+ res.end(JSON.stringify({ error: "Origin not allowed" }));
18882
+ return;
18883
+ }
18884
+ res.setHeader("Access-Control-Allow-Origin", callbackOrigin);
18885
+ res.setHeader("Vary", "Origin");
17390
18886
  res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
17391
18887
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
17392
18888
  res.setHeader("Access-Control-Allow-Private-Network", "true");
@@ -17404,9 +18900,9 @@ async function runSetup() {
17404
18900
  res.end(JSON.stringify({ error: "State mismatch" }));
17405
18901
  return;
17406
18902
  }
17407
- if (!data.api_key) {
18903
+ if (!isValidSetupApiKey(data.api_key)) {
17408
18904
  res.writeHead(400, { "Content-Type": "application/json" });
17409
- res.end(JSON.stringify({ error: "Missing api_key" }));
18905
+ res.end(JSON.stringify({ error: "Invalid api_key" }));
17410
18906
  return;
17411
18907
  }
17412
18908
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -17448,9 +18944,9 @@ async function runSetup() {
17448
18944
  console.error(` Key prefix: ${apiKey.substring(0, 12)}...`);
17449
18945
  const configPaths = getConfigPaths();
17450
18946
  let configured = false;
17451
- for (const { path: path2, name } of configPaths) {
17452
- if (configureMcpClient(path2)) {
17453
- console.error(` Configured ${name}: ${path2}`);
18947
+ for (const { path: path3, name } of configPaths) {
18948
+ if (configureMcpClient(path3)) {
18949
+ console.error(` Configured ${name}: ${path3}`);
17454
18950
  configured = true;
17455
18951
  }
17456
18952
  }
@@ -17489,12 +18985,12 @@ __export(validation_cache_exports, {
17489
18985
  readValidationCache: () => readValidationCache,
17490
18986
  writeValidationCache: () => writeValidationCache
17491
18987
  });
17492
- import { createHash as createHash5 } from "node:crypto";
18988
+ import { createHash as createHash4 } from "node:crypto";
17493
18989
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, chmodSync } from "node:fs";
17494
18990
  import { join as join4 } from "node:path";
17495
18991
  import { homedir as homedir4 } from "node:os";
17496
18992
  function keyFingerprint(apiKey) {
17497
- return `sha256:${createHash5("sha256").update(apiKey, "utf8").digest("hex")}`;
18993
+ return `sha256:${createHash4("sha256").update(apiKey, "utf8").digest("hex")}`;
17498
18994
  }
17499
18995
  function readValidationCache(apiKey) {
17500
18996
  try {
@@ -17635,8 +19131,7 @@ async function runLoginDevice() {
17635
19131
  body: JSON.stringify({})
17636
19132
  });
17637
19133
  if (!response.ok) {
17638
- const text = await response.text();
17639
- console.error(` Error: Failed to create device code. ${text}`);
19134
+ console.error(` Error: Failed to create device code (HTTP ${response.status}).`);
17640
19135
  process.exit(1);
17641
19136
  }
17642
19137
  const data = await response.json();
@@ -17886,7 +19381,7 @@ async function runHealthCheck(options) {
17886
19381
  checks.push({
17887
19382
  name: "Connectivity",
17888
19383
  ok: false,
17889
- detail: err instanceof Error ? err.message : "Failed to reach server"
19384
+ detail: "Failed to reach the authentication service."
17890
19385
  });
17891
19386
  }
17892
19387
  }
@@ -17910,7 +19405,7 @@ async function runHealthCheck(options) {
17910
19405
  checks.push({
17911
19406
  name: "Connectivity",
17912
19407
  ok: false,
17913
- detail: err instanceof Error ? err.message : "Network error"
19408
+ detail: "Network request failed."
17914
19409
  });
17915
19410
  }
17916
19411
  if (apiKey) {