@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/http.js CHANGED
@@ -56,25 +56,140 @@ __export(credentials_exports, {
56
56
  saveSupabaseUrl: () => saveSupabaseUrl
57
57
  });
58
58
  import { execFileSync } from "node:child_process";
59
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
59
+ import {
60
+ closeSync,
61
+ constants,
62
+ existsSync,
63
+ fchmodSync,
64
+ fstatSync,
65
+ ftruncateSync,
66
+ lstatSync,
67
+ mkdirSync,
68
+ openSync,
69
+ readFileSync,
70
+ unlinkSync,
71
+ writeFileSync
72
+ } from "node:fs";
60
73
  import { homedir, platform } from "node:os";
61
74
  import { join } from "node:path";
75
+ function assertSafeCredentialPaths() {
76
+ if (platform() === "win32") return;
77
+ const uid = process.getuid?.();
78
+ if (existsSync(CONFIG_DIR)) {
79
+ const directory = lstatSync(CONFIG_DIR);
80
+ if (directory.isSymbolicLink() || !directory.isDirectory()) {
81
+ throw new Error("Unsafe Social Neuron credential directory. Refusing to use it.");
82
+ }
83
+ if (uid !== void 0 && directory.uid !== uid) {
84
+ throw new Error("Social Neuron credential directory is not owned by the current user.");
85
+ }
86
+ }
87
+ if (existsSync(CREDENTIALS_FILE)) {
88
+ const file = lstatSync(CREDENTIALS_FILE);
89
+ if (file.isSymbolicLink() || !file.isFile()) {
90
+ throw new Error("Unsafe Social Neuron credential file. Refusing to use it.");
91
+ }
92
+ if (uid !== void 0 && file.uid !== uid) {
93
+ throw new Error("Social Neuron credential file is not owned by the current user.");
94
+ }
95
+ }
96
+ }
97
+ function openValidatedCredentialPath(target, kind, flags) {
98
+ let fd;
99
+ try {
100
+ fd = openSync(
101
+ target,
102
+ flags | constants.O_NOFOLLOW | (kind === "directory" ? constants.O_DIRECTORY : 0)
103
+ );
104
+ } catch (error) {
105
+ if (error.code === "ENOENT") return null;
106
+ throw new Error(`Unsafe Social Neuron credential ${kind}. Refusing to use it.`);
107
+ }
108
+ const opened = fstatSync(fd);
109
+ const uid = process.getuid?.();
110
+ if ((kind === "directory" ? !opened.isDirectory() : !opened.isFile()) || uid !== void 0 && opened.uid !== uid || kind === "file" && opened.nlink !== 1) {
111
+ closeSync(fd);
112
+ throw new Error(`Unsafe Social Neuron credential ${kind}. Refusing to use it.`);
113
+ }
114
+ return fd;
115
+ }
116
+ function hardenCredentialPath(target, kind, mode) {
117
+ const fd = openValidatedCredentialPath(target, kind, constants.O_RDONLY);
118
+ if (fd === null) return;
119
+ try {
120
+ const opened = fstatSync(fd);
121
+ if ((opened.mode & 63) !== 0) fchmodSync(fd, mode);
122
+ } finally {
123
+ closeSync(fd);
124
+ }
125
+ }
126
+ function hardenCredentialPermissions() {
127
+ if (platform() === "win32") return;
128
+ hardenCredentialPath(CONFIG_DIR, "directory", 448);
129
+ hardenCredentialPath(CREDENTIALS_FILE, "file", 384);
130
+ }
62
131
  function readCredentialsFile() {
132
+ if (platform() !== "win32") hardenCredentialPath(CONFIG_DIR, "directory", 448);
133
+ const fd = platform() === "win32" ? (() => {
134
+ try {
135
+ return openSync(CREDENTIALS_FILE, constants.O_RDONLY);
136
+ } catch (error) {
137
+ if (error.code === "ENOENT") return null;
138
+ throw error;
139
+ }
140
+ })() : openValidatedCredentialPath(CREDENTIALS_FILE, "file", constants.O_RDONLY);
141
+ if (fd === null) return {};
63
142
  try {
64
- if (!existsSync(CREDENTIALS_FILE)) return {};
65
- const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
66
- return JSON.parse(raw);
67
- } catch {
68
- return {};
143
+ if (platform() !== "win32") fchmodSync(fd, 384);
144
+ const parsed = JSON.parse(readFileSync(fd, "utf-8"));
145
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
146
+ throw new Error("Social Neuron credential file is invalid.");
147
+ }
148
+ return parsed;
149
+ } finally {
150
+ closeSync(fd);
69
151
  }
70
152
  }
71
153
  function writeCredentialsFile(data) {
72
154
  if (!existsSync(CONFIG_DIR)) {
73
155
  mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
74
156
  }
75
- writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2) + "\n", {
76
- mode: 384
77
- });
157
+ assertSafeCredentialPaths();
158
+ const payload = JSON.stringify(data, null, 2) + "\n";
159
+ if (platform() === "win32") {
160
+ writeFileSync(CREDENTIALS_FILE, payload, { mode: 384 });
161
+ } else {
162
+ let fd;
163
+ try {
164
+ fd = openSync(
165
+ CREDENTIALS_FILE,
166
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
167
+ 384
168
+ );
169
+ } catch (error) {
170
+ if (error.code !== "EEXIST") {
171
+ throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
172
+ }
173
+ try {
174
+ fd = openSync(CREDENTIALS_FILE, constants.O_WRONLY | constants.O_NOFOLLOW);
175
+ } catch {
176
+ throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
177
+ }
178
+ }
179
+ try {
180
+ const opened = fstatSync(fd);
181
+ const uid = process.getuid?.();
182
+ if (!opened.isFile() || opened.nlink !== 1 || uid !== void 0 && opened.uid !== uid) {
183
+ throw new Error("Unsafe Social Neuron credential file. Refusing to write it.");
184
+ }
185
+ fchmodSync(fd, 384);
186
+ ftruncateSync(fd, 0);
187
+ writeFileSync(fd, payload);
188
+ } finally {
189
+ closeSync(fd);
190
+ }
191
+ }
192
+ hardenCredentialPermissions();
78
193
  }
79
194
  function macKeychainRead(service) {
80
195
  try {
@@ -284,7 +399,6 @@ async function validateApiKey(apiKey, _attempt = 0) {
284
399
  }
285
400
  );
286
401
  if (!response.ok) {
287
- const text = await response.text();
288
402
  const retryable = response.status === 429 || response.status >= 500;
289
403
  if (retryable && _attempt < VALIDATE_MAX_RETRIES) {
290
404
  await sleep(300 * (_attempt + 1));
@@ -293,11 +407,19 @@ async function validateApiKey(apiKey, _attempt = 0) {
293
407
  return {
294
408
  valid: false,
295
409
  retryable,
296
- error: `Validation failed (HTTP ${response.status}): ${text}`
410
+ error: `Validation failed (HTTP ${response.status}).`
297
411
  };
298
412
  }
299
- return await response.json();
300
- } catch (err) {
413
+ const result = await response.json();
414
+ if (!result.valid) {
415
+ return {
416
+ valid: false,
417
+ retryable: false,
418
+ error: "API key is invalid, expired, or revoked."
419
+ };
420
+ }
421
+ return result;
422
+ } catch {
301
423
  if (_attempt < VALIDATE_MAX_RETRIES) {
302
424
  await sleep(300 * (_attempt + 1));
303
425
  return validateApiKey(apiKey, _attempt + 1);
@@ -305,7 +427,7 @@ async function validateApiKey(apiKey, _attempt = 0) {
305
427
  return {
306
428
  valid: false,
307
429
  retryable: true,
308
- error: err instanceof Error ? err.message : String(err)
430
+ error: "Authentication service is temporarily unavailable."
309
431
  };
310
432
  }
311
433
  }
@@ -326,10 +448,12 @@ __export(posthog_exports, {
326
448
  initPostHog: () => initPostHog,
327
449
  shutdownPostHog: () => shutdownPostHog
328
450
  });
329
- import { createHash } from "node:crypto";
451
+ import { createHmac, randomBytes } from "node:crypto";
330
452
  import { PostHog } from "posthog-node";
331
453
  function hashUserId(userId) {
332
- return createHash("sha256").update(`${POSTHOG_SALT}:${userId}`).digest("hex").substring(0, 32);
454
+ const hmac = createHmac("sha256", POSTHOG_PSEUDONYMIZATION_KEY);
455
+ hmac.update(userId);
456
+ return hmac.digest("hex").substring(0, 32);
333
457
  }
334
458
  function initPostHog() {
335
459
  if (isTelemetryDisabled()) return;
@@ -365,13 +489,13 @@ async function shutdownPostHog() {
365
489
  client = null;
366
490
  }
367
491
  }
368
- var POSTHOG_SALT, client;
492
+ var POSTHOG_PSEUDONYMIZATION_KEY, client;
369
493
  var init_posthog = __esm({
370
494
  "src/lib/posthog.ts"() {
371
495
  "use strict";
372
496
  init_supabase();
373
497
  init_request_context();
374
- POSTHOG_SALT = "socialneuron-mcp-ph-v1";
498
+ POSTHOG_PSEUDONYMIZATION_KEY = process.env.POSTHOG_PSEUDONYMIZATION_KEY || randomBytes(32).toString("hex");
375
499
  client = null;
376
500
  }
377
501
  });
@@ -658,6 +782,9 @@ var TOOL_SCOPES = {
658
782
  generate_carousel: "mcp:write",
659
783
  create_carousel: "mcp:write",
660
784
  upload_media: "mcp:write",
785
+ cancel_async_job: "mcp:write",
786
+ delete_carousel: "mcp:write",
787
+ delete_content_plan: "mcp:write",
661
788
  // mcp:read (media)
662
789
  get_media_url: "mcp:read",
663
790
  // F4 Hyperframes — HTML composition runtime
@@ -665,6 +792,8 @@ var TOOL_SCOPES = {
665
792
  render_hyperframes: "mcp:write",
666
793
  // mcp:distribute
667
794
  schedule_post: "mcp:distribute",
795
+ reschedule_post: "mcp:distribute",
796
+ cancel_scheduled_post: "mcp:distribute",
668
797
  start_platform_connection: "mcp:distribute",
669
798
  // mcp:analytics
670
799
  refresh_platform_analytics: "mcp:analytics",
@@ -679,6 +808,7 @@ var TOOL_SCOPES = {
679
808
  list_autopilot_configs: "mcp:autopilot",
680
809
  update_autopilot_config: "mcp:autopilot",
681
810
  get_autopilot_status: "mcp:autopilot",
811
+ delete_autopilot_config: "mcp:autopilot",
682
812
  // Recipes
683
813
  list_recipes: "mcp:read",
684
814
  get_recipe_details: "mcp:read",
@@ -722,6 +852,7 @@ var TOOL_SCOPES = {
722
852
  detect_anomalies: "mcp:analytics",
723
853
  // mcp:read (Apps — entry tool for the Content Calendar MCP App; reads recent posts)
724
854
  open_content_calendar: "mcp:read",
855
+ open_analytics_pulse: "mcp:read",
725
856
  // mcp:write (Agentic harness — learning loop write-back)
726
857
  write_agent_reflection: "mcp:write",
727
858
  record_outcome: "mcp:write",
@@ -737,6 +868,7 @@ var TOOL_SCOPES = {
737
868
  get_active_campaigns: "mcp:read",
738
869
  // mcp:read / mcp:write (Skills)
739
870
  list_skills: "mcp:read",
871
+ get_skill: "mcp:read",
740
872
  run_skill: "mcp:write",
741
873
  // mcp:read (Loop observability — growth-loop KPIs + content learning state)
742
874
  get_loop_pulse: "mcp:read",
@@ -750,6 +882,9 @@ function hasScope(userScopes, required) {
750
882
  }
751
883
  return false;
752
884
  }
885
+ function getAllScopes() {
886
+ return Object.keys(SCOPE_HIERARCHY);
887
+ }
753
888
 
754
889
  // src/lib/tool-annotations.ts
755
890
  var ACRONYMS = {
@@ -808,6 +943,11 @@ var SCOPE_DEFAULTS = {
808
943
  var OVERRIDES = {
809
944
  // Destructive or overwrite tools
810
945
  delete_comment: { destructiveHint: true },
946
+ cancel_async_job: { destructiveHint: true, idempotentHint: true },
947
+ cancel_scheduled_post: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
948
+ delete_carousel: { destructiveHint: true, idempotentHint: true },
949
+ delete_content_plan: { destructiveHint: true, idempotentHint: true },
950
+ delete_autopilot_config: { destructiveHint: true, idempotentHint: true },
811
951
  moderate_comment: { destructiveHint: true },
812
952
  save_brand_profile: { destructiveHint: true, idempotentHint: true },
813
953
  update_platform_voice: { destructiveHint: true, idempotentHint: true },
@@ -1041,6 +1181,26 @@ var TOOL_CATALOG = [
1041
1181
  module: "carousel",
1042
1182
  scope: "mcp:write"
1043
1183
  },
1184
+ {
1185
+ name: "cancel_async_job",
1186
+ description: "Cancel an owned pending async job and refund an eligible debit",
1187
+ module: "lifecycle",
1188
+ scope: "mcp:write",
1189
+ task_intent: "Stop a queued generation or render before worker execution",
1190
+ use_when: "The user explicitly wants to cancel a pending job and has confirmed the action.",
1191
+ avoid_when: "The job is already processing or terminal; check_status instead.",
1192
+ next_tools: ["check_status", "get_credit_balance"]
1193
+ },
1194
+ {
1195
+ name: "delete_carousel",
1196
+ description: "Delete an owned carousel content record from one project",
1197
+ module: "lifecycle",
1198
+ scope: "mcp:write",
1199
+ task_intent: "Remove an unwanted carousel record from Social Neuron",
1200
+ use_when: "The user explicitly confirms deletion and understands stored media follows normal retention.",
1201
+ avoid_when: "The user expects an already-published social post or retained media object to be removed.",
1202
+ next_tools: ["list_recent_posts"]
1203
+ },
1044
1204
  // media
1045
1205
  {
1046
1206
  name: "upload_media",
@@ -1061,6 +1221,26 @@ var TOOL_CATALOG = [
1061
1221
  module: "distribution",
1062
1222
  scope: "mcp:distribute"
1063
1223
  },
1224
+ {
1225
+ name: "reschedule_post",
1226
+ description: "Atomically move an unclaimed scheduled post to a new time within its brand project",
1227
+ module: "distribution",
1228
+ scope: "mcp:distribute",
1229
+ task_intent: "Move an already scheduled post without creating a duplicate publication",
1230
+ use_when: "A pending or scheduled post needs a different future publication time.",
1231
+ avoid_when: "The post is already publishing or published; create a new post instead.",
1232
+ next_tools: ["list_recent_posts", "open_content_calendar"]
1233
+ },
1234
+ {
1235
+ name: "cancel_scheduled_post",
1236
+ description: "Cancel an owned scheduled post before publishing begins",
1237
+ module: "lifecycle",
1238
+ scope: "mcp:distribute",
1239
+ task_intent: "Unschedule a post without publishing it",
1240
+ use_when: "The user explicitly confirms cancellation of a draft, pending, or scheduled post.",
1241
+ avoid_when: "The post is already publishing or published.",
1242
+ next_tools: ["list_recent_posts", "open_content_calendar"]
1243
+ },
1064
1244
  {
1065
1245
  name: "list_recent_posts",
1066
1246
  description: "List recently published or scheduled posts",
@@ -1251,6 +1431,16 @@ var TOOL_CATALOG = [
1251
1431
  module: "planning",
1252
1432
  scope: "mcp:write"
1253
1433
  },
1434
+ {
1435
+ name: "delete_content_plan",
1436
+ description: "Permanently delete an owned content plan from one project",
1437
+ module: "lifecycle",
1438
+ scope: "mcp:write",
1439
+ task_intent: "Remove an obsolete saved content plan",
1440
+ use_when: "The user explicitly confirms permanent deletion of a specific plan.",
1441
+ avoid_when: "The plan has scheduled posts the user also expects to cancel; cancel those posts separately.",
1442
+ next_tools: ["plan_content_week", "list_recent_posts"]
1443
+ },
1254
1444
  {
1255
1445
  name: "submit_content_plan_for_approval",
1256
1446
  description: "Submit a content plan for team approval",
@@ -1345,6 +1535,16 @@ var TOOL_CATALOG = [
1345
1535
  module: "autopilot",
1346
1536
  scope: "mcp:autopilot"
1347
1537
  },
1538
+ {
1539
+ name: "delete_autopilot_config",
1540
+ description: "Permanently delete an owned autopilot configuration from one project",
1541
+ module: "lifecycle",
1542
+ scope: "mcp:autopilot",
1543
+ task_intent: "Remove an autopilot configuration while retaining historical run records",
1544
+ use_when: "The user explicitly confirms deletion of the configuration.",
1545
+ avoid_when: "The user only wants to pause or edit automation, or expects historical posts to be deleted.",
1546
+ next_tools: ["list_autopilot_configs"]
1547
+ },
1348
1548
  // extraction
1349
1549
  {
1350
1550
  name: "extract_url_content",
@@ -1461,7 +1661,13 @@ var TOOL_CATALOG = [
1461
1661
  // apps (MCP Apps — interactive UI inside the host)
1462
1662
  {
1463
1663
  name: "open_content_calendar",
1464
- description: "Open 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.",
1664
+ description: "Open a project-scoped interactive calendar inside MCP App-capable hosts. Uses reschedule_post for conflict-safe drag/drop and schedule_post for new posts.",
1665
+ module: "apps",
1666
+ scope: "mcp:read"
1667
+ },
1668
+ {
1669
+ name: "open_analytics_pulse",
1670
+ description: "Open a project-scoped interactive performance dashboard with views, engagement, platform mix, and top posts.",
1465
1671
  module: "apps",
1466
1672
  scope: "mcp:read"
1467
1673
  },
@@ -1576,6 +1782,12 @@ var TOOL_CATALOG = [
1576
1782
  module: "skills",
1577
1783
  scope: "mcp:read"
1578
1784
  },
1785
+ {
1786
+ name: "get_skill",
1787
+ description: "Fetch the full body and current compiled guidance for one Social Neuron skill by slug.",
1788
+ module: "skills",
1789
+ scope: "mcp:read"
1790
+ },
1579
1791
  {
1580
1792
  name: "run_skill",
1581
1793
  description: "Run a Social Neuron workflow skill end-to-end (brand-locked content production). Returns a structured run preview with the step plan, credit cost, and a deep-link to launch in the SN dashboard.",
@@ -1635,6 +1847,7 @@ var ANTHROPIC_DIRECTORY_EXCLUDED_TOOLS = /* @__PURE__ */ new Set([
1635
1847
  "list_hyperframes_blocks",
1636
1848
  "render_hyperframes",
1637
1849
  "list_skills",
1850
+ "get_skill",
1638
1851
  "run_skill"
1639
1852
  ]);
1640
1853
  function resolveToolProfile(value) {
@@ -1649,7 +1862,7 @@ function isToolAllowedByProfile(toolName, profile) {
1649
1862
  }
1650
1863
  function publicToolsForProfile(profile) {
1651
1864
  return TOOL_CATALOG.filter(
1652
- (tool) => !tool.localOnly && !tool.internal && isToolAllowedByProfile(tool.name, profile)
1865
+ (tool) => !tool.localOnly && !tool.internal && !tool.hiddenFromPublicCount && isToolAllowedByProfile(tool.name, profile)
1653
1866
  );
1654
1867
  }
1655
1868
  function applyToolProfile(server, profile) {
@@ -1695,7 +1908,8 @@ var constants_default = {
1695
1908
  ssn: "\\d{3}-\\d{2}-\\d{4}",
1696
1909
  ip: "(?<![\\d.])(?:\\d{1,3}\\.){3}\\d{1,3}(?![\\d.])"
1697
1910
  },
1698
- MAX_LENGTH: 1e4
1911
+ MAX_LENGTH: 1e4,
1912
+ MAX_OUTPUT_LENGTH: 1e6
1699
1913
  };
1700
1914
 
1701
1915
  // src/lib/agent-harness/constants.ts
@@ -1756,7 +1970,8 @@ function scrubPii(text, _role) {
1756
1970
  function scan(text, options) {
1757
1971
  const flagged = /* @__PURE__ */ new Set();
1758
1972
  let risk = 0;
1759
- if (text.length > CONSTANTS.MAX_LENGTH) {
1973
+ const maxLength = options.source === "mcp_tool_output" ? CONSTANTS.MAX_OUTPUT_LENGTH : CONSTANTS.MAX_LENGTH;
1974
+ if (text.length > maxLength) {
1760
1975
  return {
1761
1976
  passed: false,
1762
1977
  risk_score: 1,
@@ -1803,6 +2018,85 @@ import { z } from "zod";
1803
2018
  // src/lib/edge-function.ts
1804
2019
  init_supabase();
1805
2020
  init_request_context();
2021
+ var SAFE_GATEWAY_ERROR_CODES = /* @__PURE__ */ new Set([
2022
+ "daily_limit_reached",
2023
+ "insufficient_credits",
2024
+ "project_scope_mismatch",
2025
+ "schedule_conflict",
2026
+ "post_not_found",
2027
+ "post_not_reschedulable",
2028
+ "post_in_progress",
2029
+ "not_cancellable",
2030
+ "publishing_in_progress",
2031
+ "plan_upgrade_required",
2032
+ "rate_limited",
2033
+ "validation_error",
2034
+ "permission_denied",
2035
+ "not_found"
2036
+ ]);
2037
+ function safeGatewayError(responseText, status) {
2038
+ try {
2039
+ const parsed = JSON.parse(responseText);
2040
+ const nested = parsed.error && typeof parsed.error === "object" ? parsed.error : null;
2041
+ const candidates = [
2042
+ nested?.error_type,
2043
+ nested?.code,
2044
+ parsed.error_type,
2045
+ parsed.error_code,
2046
+ parsed.code,
2047
+ typeof parsed.error === "string" ? parsed.error : null
2048
+ ];
2049
+ for (const value of candidates) {
2050
+ if (typeof value !== "string") continue;
2051
+ const normalized = value.trim().toLowerCase();
2052
+ if (SAFE_GATEWAY_ERROR_CODES.has(normalized)) return normalized;
2053
+ const embedded = [...SAFE_GATEWAY_ERROR_CODES].find((code) => normalized.includes(code));
2054
+ if (embedded) return embedded;
2055
+ }
2056
+ } catch {
2057
+ }
2058
+ return `Backend request failed (HTTP ${status}).`;
2059
+ }
2060
+ var SAFE_BILLING_STATUSES = /* @__PURE__ */ new Set([
2061
+ "reserved",
2062
+ "charged",
2063
+ "refunded",
2064
+ "failed_no_charge",
2065
+ "refund_pending",
2066
+ "not_charged"
2067
+ ]);
2068
+ var SAFE_JOB_STATUSES = /* @__PURE__ */ new Set([
2069
+ "queued",
2070
+ "pending",
2071
+ "processing",
2072
+ "completed",
2073
+ "failed",
2074
+ "cancelled",
2075
+ "canceled"
2076
+ ]);
2077
+ function safeFailureData(responseText) {
2078
+ try {
2079
+ const parsed = JSON.parse(responseText);
2080
+ const metric = (name) => {
2081
+ const value = parsed[name];
2082
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
2083
+ };
2084
+ const billingStatus = typeof parsed.billing_status === "string" && SAFE_BILLING_STATUSES.has(parsed.billing_status) ? parsed.billing_status : void 0;
2085
+ const failureReason = parsed.failure_reason === "generation_failed" || parsed.failure_reason === "authentication_failed" || parsed.failure_reason === "cancelled_by_user" ? parsed.failure_reason : void 0;
2086
+ const jobStatus = typeof parsed.status === "string" && SAFE_JOB_STATUSES.has(parsed.status) ? parsed.status : void 0;
2087
+ const safe = {
2088
+ ...jobStatus ? { status: jobStatus } : {},
2089
+ ...metric("credits_reserved") !== void 0 ? { credits_reserved: metric("credits_reserved") } : {},
2090
+ ...metric("credits_charged") !== void 0 ? { credits_charged: metric("credits_charged") } : {},
2091
+ ...metric("credits_refunded") !== void 0 ? { credits_refunded: metric("credits_refunded") } : {},
2092
+ ...billingStatus ? { billing_status: billingStatus } : {},
2093
+ ...failureReason ? { failure_reason: failureReason } : {}
2094
+ };
2095
+ return Object.keys(safe).length > 0 ? safe : null;
2096
+ } catch {
2097
+ return null;
2098
+ }
2099
+ }
1806
2100
  function getApiKeyOrNull() {
1807
2101
  const envKey = process.env.SOCIALNEURON_API_KEY;
1808
2102
  if (envKey && envKey.trim().length) return envKey.trim();
@@ -1873,33 +2167,28 @@ async function callEdgeFunction(functionName, body, options) {
1873
2167
  clearTimeout(timer);
1874
2168
  const responseText = await response.text();
1875
2169
  if (!response.ok) {
1876
- let errorMessage;
1877
- try {
1878
- const errorJson = JSON.parse(responseText);
1879
- errorMessage = errorJson.error || errorJson.message || responseText;
1880
- } catch {
1881
- errorMessage = responseText || `HTTP ${response.status}`;
1882
- }
2170
+ const errorCode = safeGatewayError(responseText, response.status);
2171
+ const failureData = safeFailureData(responseText);
1883
2172
  if (response.status === 401) {
1884
2173
  return {
1885
- data: null,
2174
+ data: failureData,
1886
2175
  error: `Authentication failed (HTTP 401). Run 'npx @socialneuron/mcp-server login' to re-authenticate.`
1887
2176
  };
1888
2177
  }
1889
2178
  if (response.status === 403) {
1890
2179
  return {
1891
- data: null,
1892
- error: `Forbidden (HTTP 403): ${errorMessage}. This action isn't permitted for your account, plan, or scope \u2014 your connection is still valid.`
2180
+ data: failureData,
2181
+ error: `Forbidden (HTTP 403): ${errorCode}. This action isn't permitted for your account, plan, or scope \u2014 your connection is still valid.`
1893
2182
  };
1894
2183
  }
1895
2184
  if (response.status === 429) {
1896
2185
  const retryAfter = response.headers.get("retry-after") || "60";
1897
2186
  return {
1898
- data: null,
2187
+ data: failureData,
1899
2188
  error: `Rate limit exceeded (HTTP 429). Wait ${retryAfter}s before retrying. Reduce request frequency or upgrade your plan.`
1900
2189
  };
1901
2190
  }
1902
- return { data: null, error: errorMessage };
2191
+ return { data: failureData, error: errorCode };
1903
2192
  }
1904
2193
  try {
1905
2194
  const data = JSON.parse(responseText);
@@ -1915,8 +2204,7 @@ async function callEdgeFunction(functionName, body, options) {
1915
2204
  error: `Edge Function '${functionName}' timed out after ${timeoutMs}ms`
1916
2205
  };
1917
2206
  }
1918
- const message = err instanceof Error ? err.message : String(err);
1919
- return { data: null, error: message };
2207
+ return { data: null, error: "Network request failed. Please retry." };
1920
2208
  }
1921
2209
  }
1922
2210
 
@@ -1933,6 +2221,45 @@ var CATEGORY_CONFIGS = {
1933
2221
  read: { maxTokens: 60, refillRate: 60 / 60 }
1934
2222
  // 60 req/min — default
1935
2223
  };
2224
+ var GENERATION_TOOLS = /* @__PURE__ */ new Set([
2225
+ "generate_content",
2226
+ "adapt_content",
2227
+ "generate_video",
2228
+ "generate_image",
2229
+ "create_storyboard",
2230
+ "generate_voiceover",
2231
+ "generate_carousel",
2232
+ "create_carousel",
2233
+ "render_demo_video",
2234
+ "render_template_video",
2235
+ "render_hyperframes",
2236
+ "execute_recipe",
2237
+ "run_content_pipeline",
2238
+ "plan_content_week",
2239
+ "extract_brand",
2240
+ "generate_performance_digest"
2241
+ ]);
2242
+ var POSTING_TOOLS = /* @__PURE__ */ new Set([
2243
+ "schedule_post",
2244
+ "reschedule_post",
2245
+ "schedule_content_plan",
2246
+ "reply_to_comment",
2247
+ "post_comment",
2248
+ "moderate_comment",
2249
+ "delete_comment",
2250
+ "start_platform_connection",
2251
+ "refresh_platform_analytics"
2252
+ ]);
2253
+ function rateLimitCategoryForTool(toolName) {
2254
+ if (!toolName) return "read";
2255
+ if (GENERATION_TOOLS.has(toolName)) return "generation";
2256
+ if (POSTING_TOOLS.has(toolName)) return "posting";
2257
+ if (toolName === "upload_media") return "upload";
2258
+ if (toolName === "capture_screenshot" || toolName === "capture_app_page") return "screenshot";
2259
+ const scope = TOOL_SCOPES[toolName];
2260
+ if (scope === "mcp:write" || scope === "mcp:distribute" || scope === "mcp:analytics" || scope === "mcp:comments" || scope === "mcp:autopilot") return "posting";
2261
+ return "read";
2262
+ }
1936
2263
  var RateLimiter = class {
1937
2264
  tokens;
1938
2265
  lastRefill;
@@ -1997,7 +2324,7 @@ function checkRateLimit(category, key) {
1997
2324
  init_supabase();
1998
2325
 
1999
2326
  // src/lib/version.ts
2000
- var MCP_VERSION = "1.8.0";
2327
+ var MCP_VERSION = "1.8.2";
2001
2328
 
2002
2329
  // src/tools/ideation.ts
2003
2330
  function asEnvelope(data) {
@@ -2033,8 +2360,8 @@ function registerIdeationTools(server) {
2033
2360
  brand_voice: z.string().max(500).optional().describe(
2034
2361
  'Tone directive (e.g. "direct, no jargon, second person" or "witty Gen-Z energy with emoji"). Leave blank to auto-load from project brand profile if project_id is set.'
2035
2362
  ),
2036
- model: z.enum(["gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"]).optional().describe(
2037
- "AI model to use. Defaults to gemini-2.5-flash. Use gemini-2.5-pro for highest quality."
2363
+ model: z.enum(["gemini-2.5-flash", "gemini-2.5-pro"]).optional().describe(
2364
+ "AI model to use. Defaults to gemini-2.5-flash. Use gemini-2.5-pro for highest quality. Retired provider models are intentionally not accepted."
2038
2365
  ),
2039
2366
  project_id: z.string().uuid().optional().describe(
2040
2367
  "Project ID to auto-load brand profile and performance context for prompt enrichment."
@@ -2043,7 +2370,7 @@ function registerIdeationTools(server) {
2043
2370
  async ({ prompt, content_type, platform: platform2, brand_voice, model, project_id }) => {
2044
2371
  try {
2045
2372
  const userId = await getDefaultUserId();
2046
- const rl = checkRateLimit("posting", userId);
2373
+ const rl = checkRateLimit("generation", `generate_content:${userId}`);
2047
2374
  if (!rl.allowed) {
2048
2375
  return {
2049
2376
  content: [
@@ -2300,7 +2627,7 @@ Content Type: ${content_type}`;
2300
2627
  async ({ content, source_platform, target_platform, brand_voice, project_id }) => {
2301
2628
  try {
2302
2629
  const userId = await getDefaultUserId();
2303
- const rl = checkRateLimit("posting", userId);
2630
+ const rl = checkRateLimit("generation", `adapt_content:${userId}`);
2304
2631
  if (!rl.allowed) {
2305
2632
  return {
2306
2633
  content: [
@@ -2407,11 +2734,12 @@ function buildCheckStatusPayload(job, liveStatus) {
2407
2734
  const progress = liveStatus?.progress ?? null;
2408
2735
  const resultUrl = liveStatus?.resultUrl ?? job.result_url ?? null;
2409
2736
  const r2Key = resultUrl && !resultUrl.startsWith("http") ? resultUrl : null;
2410
- const errorMessage = liveStatus?.error ?? job.error_message ?? null;
2737
+ const rawError = liveStatus?.error ?? job.error_message ?? null;
2738
+ const errorMessage = rawError && status === "failed" ? "Generation failed. Retry or choose another model." : rawError && (status === "cancelled" || status === "canceled") ? "Cancelled by user." : null;
2411
2739
  const allUrls = job.result_metadata?.all_urls ?? null;
2412
2740
  const modelRequested = job.result_metadata?.model_requested ?? null;
2413
2741
  const modelDelivered = job.result_metadata?.model_delivered ?? null;
2414
- const fallbackReason = job.result_metadata?.fallback_reason ?? null;
2742
+ const fallbackReason = job.result_metadata?.fallback_reason ? "Requested model was unavailable; a fallback model was used." : null;
2415
2743
  return {
2416
2744
  job_id: job.id,
2417
2745
  job_type: job.job_type,
@@ -2423,6 +2751,11 @@ function buildCheckStatusPayload(job, liveStatus) {
2423
2751
  all_urls: allUrls,
2424
2752
  error: errorMessage,
2425
2753
  credits_cost: job.credits_cost,
2754
+ credits_reserved: job.credits_reserved ?? null,
2755
+ credits_charged: job.credits_charged ?? null,
2756
+ credits_refunded: job.credits_refunded ?? null,
2757
+ billing_status: job.billing_status ?? "unknown",
2758
+ failure_reason: job.failure_reason ?? null,
2426
2759
  created_at: job.created_at,
2427
2760
  completed_at: job.completed_at,
2428
2761
  model_requested: modelRequested,
@@ -2512,6 +2845,13 @@ function checkAssetBudget(requestedCount = 1) {
2512
2845
  }
2513
2846
 
2514
2847
  // src/tools/content.ts
2848
+ function buildFallbackDisclosureLine(meta) {
2849
+ const requested = meta?.model_requested;
2850
+ const delivered = meta?.model_delivered;
2851
+ if (!requested || !delivered || requested === delivered) return null;
2852
+ const reason = meta?.fallback_reason ? " because the requested model was unavailable" : "";
2853
+ return `Note: requested "${requested}" but delivered "${delivered}"${reason} \u2014 cost never exceeds the requested model's price.`;
2854
+ }
2515
2855
  function asEnvelope2(data) {
2516
2856
  return {
2517
2857
  _meta: {
@@ -2619,7 +2959,7 @@ function registerContentTools(server) {
2619
2959
  isError: true
2620
2960
  };
2621
2961
  }
2622
- const rateLimit = checkRateLimit("posting", `generate_video:${userId}`);
2962
+ const rateLimit = checkRateLimit("generation", `generate_video:${userId}`);
2623
2963
  if (!rateLimit.allowed) {
2624
2964
  return {
2625
2965
  content: [
@@ -2717,7 +3057,7 @@ function registerContentTools(server) {
2717
3057
  );
2718
3058
  server.tool(
2719
3059
  "generate_image",
2720
- "Start an async AI image generation job \u2014 returns a job_id immediately. Poll with check_status every 5-15s until complete. Costs 2-10 credits depending on model. Use for social media posts, carousel slides, or as input to generate_video (image-to-video).",
3060
+ "Start an async AI image generation job \u2014 returns a job_id immediately. Poll with check_status every 5-15s until complete. Costs 15-50 credits depending on model. Use for social media posts, carousel slides, or as input to generate_video (image-to-video). Pass project_id so the asset is stored with the correct brand/project.",
2721
3061
  {
2722
3062
  prompt: z2.string().max(2e3).describe(
2723
3063
  "Text prompt describing the image to generate. Be specific about style, composition, colors, lighting, and subject matter."
@@ -2739,9 +3079,10 @@ function registerContentTools(server) {
2739
3079
  image_url: z2.string().optional().describe(
2740
3080
  "Reference image URL for image-to-image generation. Required for ideogram model. Optional for others."
2741
3081
  ),
3082
+ project_id: z2.string().optional().describe("Project ID to associate the generated image with."),
2742
3083
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
2743
3084
  },
2744
- async ({ prompt, model, aspect_ratio, image_url, response_format }) => {
3085
+ async ({ prompt, model, aspect_ratio, image_url, project_id, response_format }) => {
2745
3086
  const format = response_format ?? "text";
2746
3087
  const userId = await getDefaultUserId();
2747
3088
  const assetBudget = checkAssetBudget();
@@ -2759,7 +3100,7 @@ function registerContentTools(server) {
2759
3100
  isError: true
2760
3101
  };
2761
3102
  }
2762
- const rateLimit = checkRateLimit("posting", `generate_image:${userId}`);
3103
+ const rateLimit = checkRateLimit("generation", `generate_image:${userId}`);
2763
3104
  if (!rateLimit.allowed) {
2764
3105
  return {
2765
3106
  content: [
@@ -2777,7 +3118,8 @@ function registerContentTools(server) {
2777
3118
  prompt,
2778
3119
  model,
2779
3120
  aspectRatio: aspect_ratio ?? "1:1",
2780
- imageUrl: image_url
3121
+ imageUrl: image_url,
3122
+ ...project_id && { projectId: project_id }
2781
3123
  },
2782
3124
  { timeoutMs: 3e4 }
2783
3125
  );
@@ -2816,7 +3158,8 @@ function registerContentTools(server) {
2816
3158
  jobId,
2817
3159
  taskId: data.taskId,
2818
3160
  asyncJobId: data.asyncJobId,
2819
- model: data.model
3161
+ model: data.model,
3162
+ projectId: project_id ?? null
2820
3163
  }),
2821
3164
  null,
2822
3165
  2
@@ -2897,20 +3240,28 @@ function registerContentTools(server) {
2897
3240
  }
2898
3241
  );
2899
3242
  if (liveStatus) {
3243
+ const livePayload = buildCheckStatusPayload(job, liveStatus);
2900
3244
  const lines2 = [
2901
3245
  `Job: ${job.id}`,
2902
3246
  `Type: ${job.job_type}`,
2903
3247
  `Model: ${job.model}`,
2904
- `Status: ${liveStatus.status}`,
2905
- `Progress: ${liveStatus.progress}%`
3248
+ `Status: ${livePayload.status}`,
3249
+ `Progress: ${livePayload.progress}%`
2906
3250
  ];
2907
- if (liveStatus.resultUrl) {
2908
- lines2.push(`Result URL: ${liveStatus.resultUrl}`);
3251
+ if (livePayload.result_url) {
3252
+ lines2.push(`Result URL: ${livePayload.result_url}`);
2909
3253
  }
2910
- if (liveStatus.error) {
2911
- lines2.push(`Error: ${liveStatus.error}`);
3254
+ if (livePayload.error) {
3255
+ lines2.push(`Error: ${livePayload.error}`);
2912
3256
  }
3257
+ const fallbackDisclosure2 = buildFallbackDisclosureLine(job.result_metadata);
3258
+ if (fallbackDisclosure2) lines2.push(fallbackDisclosure2);
2913
3259
  lines2.push(`Credits: ${job.credits_cost}`);
3260
+ if (job.billing_status) {
3261
+ lines2.push(
3262
+ `Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
3263
+ );
3264
+ }
2914
3265
  lines2.push(`Created: ${job.created_at}`);
2915
3266
  if (format === "json") {
2916
3267
  return {
@@ -2928,7 +3279,7 @@ function registerContentTools(server) {
2928
3279
  // `job` + `liveStatus`; do not duplicate them here.
2929
3280
  asEnvelope2({
2930
3281
  ...liveStatus,
2931
- ...buildCheckStatusPayload(job, liveStatus)
3282
+ ...livePayload
2932
3283
  }),
2933
3284
  null,
2934
3285
  2
@@ -2971,7 +3322,14 @@ function registerContentTools(server) {
2971
3322
  if (job.error_message) {
2972
3323
  lines.push(`Error: ${job.error_message}`);
2973
3324
  }
3325
+ const fallbackDisclosure = buildFallbackDisclosureLine(job.result_metadata);
3326
+ if (fallbackDisclosure) lines.push(fallbackDisclosure);
2974
3327
  lines.push(`Credits: ${job.credits_cost}`);
3328
+ if (job.billing_status) {
3329
+ lines.push(
3330
+ `Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
3331
+ );
3332
+ }
2975
3333
  lines.push(`Created: ${job.created_at}`);
2976
3334
  if (job.completed_at) {
2977
3335
  lines.push(`Completed: ${job.completed_at}`);
@@ -2997,7 +3355,7 @@ function registerContentTools(server) {
2997
3355
  );
2998
3356
  server.tool(
2999
3357
  "create_storyboard",
3000
- "Plan a multi-scene video storyboard with AI-generated prompts, durations, captions, and voiceover text per frame. Use before generate_video or generate_image to create cohesive multi-shot content. Include brand_context from get_brand_profile for consistent visual branding across frames.",
3358
+ "Plan a multi-scene video storyboard with AI-generated prompts, durations, captions, and voiceover text per frame. Use before generate_video or generate_image to create cohesive multi-shot content. Include brand_context from get_brand_profile and project_id for consistent, project-scoped production. Costs 10 credits.",
3001
3359
  {
3002
3360
  concept: z2.string().max(2e3).describe(
3003
3361
  'The video concept/idea. Include: hook, key messages, target audience, and desired outcome (e.g., "TikTok ad for VPN app targeting privacy-conscious millennials, hook with shocking stat about data leaks").'
@@ -3021,6 +3379,7 @@ function registerContentTools(server) {
3021
3379
  style: z2.string().optional().describe(
3022
3380
  'Visual style direction (e.g., "cinematic", "anime", "documentary", "motion graphics").'
3023
3381
  ),
3382
+ project_id: z2.string().optional().describe("Project ID for brand-scoped generation and attribution."),
3024
3383
  response_format: z2.enum(["text", "json"]).optional().describe(
3025
3384
  "Response format. Defaults to json for structured storyboard data."
3026
3385
  )
@@ -3032,6 +3391,7 @@ function registerContentTools(server) {
3032
3391
  target_duration,
3033
3392
  num_scenes,
3034
3393
  style,
3394
+ project_id,
3035
3395
  response_format
3036
3396
  }) => {
3037
3397
  const format = response_format ?? "json";
@@ -3114,7 +3474,8 @@ Return ONLY valid JSON in this exact format:
3114
3474
  prompt: storyboardPrompt,
3115
3475
  type: "storyboard",
3116
3476
  model: "gemini-2.5-flash",
3117
- responseFormat: "json"
3477
+ responseFormat: "json",
3478
+ ...project_id && { projectId: project_id }
3118
3479
  },
3119
3480
  { timeoutMs: 6e4 }
3120
3481
  );
@@ -3129,7 +3490,18 @@ Return ONLY valid JSON in this exact format:
3129
3490
  isError: true
3130
3491
  };
3131
3492
  }
3132
- const rawContent = data?.content ?? "";
3493
+ const rawContent = data?.content?.trim() || data?.text?.trim() || "";
3494
+ if (!rawContent) {
3495
+ return {
3496
+ content: [
3497
+ {
3498
+ type: "text",
3499
+ text: "Storyboard generation failed: the AI service returned an empty response."
3500
+ }
3501
+ ],
3502
+ isError: true
3503
+ };
3504
+ }
3133
3505
  addCreditsUsed(estimatedCost);
3134
3506
  if (format === "json") {
3135
3507
  try {
@@ -3165,16 +3537,17 @@ Return ONLY valid JSON in this exact format:
3165
3537
  );
3166
3538
  server.tool(
3167
3539
  "generate_voiceover",
3168
- "Generate a voiceover audio file for video narration. Returns an R2-hosted audio URL. Use after create_storyboard to add narration to each scene, or standalone for podcast intros and ad reads. Costs ~2 credits per generation.",
3540
+ "Generate a voiceover audio file for video narration. Returns an R2-hosted audio URL. Use after create_storyboard to add narration to each scene, or standalone for podcast intros and ad reads. Pass project_id to keep the asset with the correct brand/project. Costs 15 credits per generation.",
3169
3541
  {
3170
3542
  text: z2.string().max(5e3).describe("The script/text to convert to speech."),
3171
3543
  voice: z2.enum(["rachel", "domi"]).optional().describe(
3172
3544
  "Voice selection. rachel=warm female, domi=confident female. Defaults to rachel."
3173
3545
  ),
3174
3546
  speed: z2.number().min(0.5).max(2).optional().describe("Speech speed multiplier. 1.0 is normal. Defaults to 1.0."),
3547
+ project_id: z2.string().optional().describe("Project ID to associate the generated voiceover with."),
3175
3548
  response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
3176
3549
  },
3177
- async ({ text, voice, speed, response_format }) => {
3550
+ async ({ text, voice, speed, project_id, response_format }) => {
3178
3551
  const format = response_format ?? "text";
3179
3552
  const userId = await getDefaultUserId();
3180
3553
  const estimatedCost = 15;
@@ -3186,7 +3559,7 @@ Return ONLY valid JSON in this exact format:
3186
3559
  };
3187
3560
  }
3188
3561
  const rateLimit = checkRateLimit(
3189
- "posting",
3562
+ "generation",
3190
3563
  `generate_voiceover:${userId}`
3191
3564
  );
3192
3565
  if (!rateLimit.allowed) {
@@ -3209,7 +3582,8 @@ Return ONLY valid JSON in this exact format:
3209
3582
  {
3210
3583
  text,
3211
3584
  voiceId: ELEVENLABS_VOICE_IDS[voice ?? "rachel"],
3212
- speed: speed ?? 1
3585
+ speed: speed ?? 1,
3586
+ ...project_id && { projectId: project_id }
3213
3587
  },
3214
3588
  { timeoutMs: 6e4 }
3215
3589
  );
@@ -3245,7 +3619,8 @@ Return ONLY valid JSON in this exact format:
3245
3619
  asEnvelope2({
3246
3620
  audioUrl: data.audioUrl,
3247
3621
  durationSeconds: data.durationSeconds,
3248
- voice: voice ?? "rachel"
3622
+ voice: voice ?? "rachel",
3623
+ projectId: project_id ?? null
3249
3624
  }),
3250
3625
  null,
3251
3626
  2
@@ -3351,7 +3726,7 @@ Return ONLY valid JSON in this exact format:
3351
3726
  }
3352
3727
  const userId = await getDefaultUserId();
3353
3728
  const rateLimit = checkRateLimit(
3354
- "posting",
3729
+ "generation",
3355
3730
  `generate_carousel:${userId}`
3356
3731
  );
3357
3732
  if (!rateLimit.allowed) {
@@ -3453,7 +3828,7 @@ Return ONLY valid JSON in this exact format:
3453
3828
 
3454
3829
  // src/tools/distribution.ts
3455
3830
  import { z as z3 } from "zod";
3456
- import { createHash as createHash2 } from "node:crypto";
3831
+ import { createHash } from "node:crypto";
3457
3832
 
3458
3833
  // src/lib/sanitize-error.ts
3459
3834
  var ERROR_PATTERNS = [
@@ -3492,9 +3867,6 @@ var ERROR_PATTERNS = [
3492
3867
  ];
3493
3868
  function sanitizeError(error) {
3494
3869
  const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
3495
- if (process.env.NODE_ENV !== "production") {
3496
- console.error("[Error]", msg);
3497
- }
3498
3870
  for (const [pattern, userMessage] of ERROR_PATTERNS) {
3499
3871
  if (pattern.test(msg)) {
3500
3872
  return userMessage;
@@ -3665,6 +4037,7 @@ function evaluateQuality(input) {
3665
4037
  const firstLine = caption.split("\n")[0]?.trim() ?? "";
3666
4038
  const hashtags = countHashtags(caption);
3667
4039
  const threshold = Math.min(35, Math.max(0, input.threshold ?? 26));
4040
+ const isShortFormX = platforms.length > 0 && platforms.every((p) => p === "twitter") && caption.length <= 280;
3668
4041
  const blockedTerms = [
3669
4042
  ...(input.brandAvoidPatterns ?? []).map((t) => t.trim()).filter(Boolean),
3670
4043
  ...(input.customBannedTerms ?? []).map((t) => t.trim()).filter(Boolean)
@@ -3674,11 +4047,12 @@ function evaluateQuality(input) {
3674
4047
  if (firstLine.length >= 20 && firstLine.length <= 120) hookScore += 1;
3675
4048
  if (/[!?]/.test(firstLine) || /\b\d+(\.\d+)?\b/.test(firstLine)) hookScore += 1;
3676
4049
  if (/\b(how|why|stop|avoid|build|launch|scale|grow|mistake)\b/i.test(firstLine)) hookScore += 1;
4050
+ if (isShortFormX) hookScore = Math.max(hookScore, 3);
3677
4051
  categories.push({
3678
4052
  name: "Hook Strength",
3679
4053
  score: Math.min(5, hookScore),
3680
4054
  maxScore: 5,
3681
- detail: "First line should create curiosity/value within 120 chars."
4055
+ detail: isShortFormX ? "Short-form X: keyword-hook heuristics neutralized; substance judge arbitrates." : "First line should create curiosity/value within 120 chars."
3682
4056
  });
3683
4057
  let clarityScore = 2;
3684
4058
  if (caption.length >= 80 && caption.length <= 1200) clarityScore += 2;
@@ -3704,7 +4078,8 @@ function evaluateQuality(input) {
3704
4078
  detail: "Length, title, and hashtag usage should match target platforms."
3705
4079
  });
3706
4080
  let brandScore = 3;
3707
- const brandKeyword = input.brandKeyword ?? process.env.SOCIALNEURON_BRAND_KEYWORD?.trim();
4081
+ const rawBrandKeyword = input.brandKeyword ?? process.env.SOCIALNEURON_BRAND_KEYWORD?.trim();
4082
+ const brandKeyword = rawBrandKeyword ? rawBrandKeyword.slice(0, 80).replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : null;
3708
4083
  if (brandKeyword && new RegExp("\\b" + brandKeyword + "\\b", "i").test(title + " " + caption))
3709
4084
  brandScore += 1;
3710
4085
  if (!/\b(you|your|customer|audience)\b/i.test(caption)) brandScore -= 1;
@@ -3715,11 +4090,12 @@ function evaluateQuality(input) {
3715
4090
  brandScore -= Math.min(2, matched.length);
3716
4091
  }
3717
4092
  }
4093
+ if (isShortFormX) brandScore = Math.max(brandScore, 3);
3718
4094
  categories.push({
3719
4095
  name: "Brand Alignment",
3720
4096
  score: Math.max(0, Math.min(5, brandScore)),
3721
4097
  maxScore: 5,
3722
- detail: "Voice should match brand context and audience focus."
4098
+ detail: isShortFormX ? "Short-form X: second-person-address heuristic neutralized; blocked terms still enforced." : "Voice should match brand context and audience focus."
3723
4099
  });
3724
4100
  let noveltyScore = 2;
3725
4101
  if (/\b(case study|framework|workflow|playbook|breakdown|behind the scenes)\b/i.test(caption))
@@ -3730,21 +4106,23 @@ function evaluateQuality(input) {
3730
4106
  const matched = blockedTerms.filter((term) => lowerCombined.includes(term.toLowerCase()));
3731
4107
  if (matched.length > 0) noveltyScore -= Math.min(2, matched.length);
3732
4108
  }
4109
+ if (isShortFormX) noveltyScore = Math.max(noveltyScore, 3);
3733
4110
  categories.push({
3734
4111
  name: "Novelty",
3735
4112
  score: Math.max(0, Math.min(5, noveltyScore)),
3736
4113
  maxScore: 5,
3737
- detail: "Avoid generic phrasing; include distinct angle."
4114
+ detail: isShortFormX ? "Short-form X: keyword-novelty neutralized; substance judge arbitrates." : "Avoid generic phrasing; include distinct angle."
3738
4115
  });
3739
4116
  let ctaScore = 2;
3740
4117
  if (/\b(comment|reply|share|save|follow|subscribe|click|try|book|download)\b/i.test(caption))
3741
4118
  ctaScore += 2;
3742
4119
  if (/\?$/.test(firstLine)) ctaScore += 1;
4120
+ if (isShortFormX) ctaScore = Math.max(ctaScore, 3);
3743
4121
  categories.push({
3744
4122
  name: "CTA Strength",
3745
4123
  score: Math.min(5, ctaScore),
3746
4124
  maxScore: 5,
3747
- detail: "Should include a clear next action."
4125
+ detail: isShortFormX ? "Short-form X: explicit CTA optional; native posts may close without one." : "Should include a clear next action."
3748
4126
  });
3749
4127
  let safetyScore = 5;
3750
4128
  if (/\b(guarantee|guaranteed|no risk|risk-free|always works|100%)\b/i.test(caption))
@@ -3802,7 +4180,9 @@ var PLATFORM_CASE_MAP = {
3802
4180
  threads: "Threads",
3803
4181
  bluesky: "Bluesky"
3804
4182
  };
3805
- var TIKTOK_AUDIT_APPROVED = false;
4183
+ var TIKTOK_AUDIT_APPROVED = !["false", "0", "no"].includes(
4184
+ (process.env.TIKTOK_AUDIT_APPROVED ?? "").toLowerCase()
4185
+ );
3806
4186
  var MCP_NOT_LIVE_FOR_POSTING = /* @__PURE__ */ new Set(["LinkedIn", "Pinterest", "Reddit"]);
3807
4187
  function asEnvelope3(data) {
3808
4188
  return {
@@ -3813,6 +4193,106 @@ function asEnvelope3(data) {
3813
4193
  data
3814
4194
  };
3815
4195
  }
4196
+ function publicConnectedAccount(value) {
4197
+ if (!value || typeof value !== "object") return null;
4198
+ const row = value;
4199
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
4200
+ return null;
4201
+ }
4202
+ return {
4203
+ id: row.id,
4204
+ platform: row.platform,
4205
+ status: row.status,
4206
+ ...typeof row.effective_status === "string" ? { effective_status: row.effective_status } : {},
4207
+ username: typeof row.username === "string" ? row.username : null,
4208
+ created_at: row.created_at,
4209
+ ...typeof row.updated_at === "string" || row.updated_at === null ? { updated_at: row.updated_at } : {},
4210
+ ...typeof row.expires_at === "string" || row.expires_at === null ? { expires_at: row.expires_at } : {},
4211
+ ...typeof row.has_refresh_token === "boolean" ? { has_refresh_token: row.has_refresh_token } : {},
4212
+ ...typeof row.project_id === "string" || row.project_id === null ? { project_id: row.project_id } : {}
4213
+ };
4214
+ }
4215
+ function publicPostRecord(value) {
4216
+ if (!value || typeof value !== "object") return null;
4217
+ const row = value;
4218
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
4219
+ return null;
4220
+ }
4221
+ const nullableString = (field) => typeof row[field] === "string" ? row[field] : null;
4222
+ return {
4223
+ id: row.id,
4224
+ platform: row.platform,
4225
+ status: row.status,
4226
+ title: nullableString("title"),
4227
+ external_post_id: nullableString("external_post_id"),
4228
+ published_at: nullableString("published_at"),
4229
+ scheduled_at: nullableString("scheduled_at"),
4230
+ created_at: row.created_at
4231
+ };
4232
+ }
4233
+ var VIDEO_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4234
+ "mp4",
4235
+ "mov",
4236
+ "m4v",
4237
+ "webm",
4238
+ "avi",
4239
+ "mkv",
4240
+ "mpeg",
4241
+ "mpg"
4242
+ ]);
4243
+ var IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4244
+ "jpg",
4245
+ "jpeg",
4246
+ "png",
4247
+ "webp",
4248
+ "gif",
4249
+ "avif",
4250
+ "heic"
4251
+ ]);
4252
+ function mediaTypeFromPath(value) {
4253
+ try {
4254
+ const pathname = value.startsWith("http") ? new URL(value).pathname : value;
4255
+ const extension = pathname.split(".").pop()?.toLowerCase();
4256
+ if (!extension) return null;
4257
+ if (VIDEO_FILE_EXTENSIONS.has(extension)) return "VIDEO";
4258
+ if (IMAGE_FILE_EXTENSIONS.has(extension)) return "IMAGE";
4259
+ } catch {
4260
+ return null;
4261
+ }
4262
+ return null;
4263
+ }
4264
+ function inferScheduleMediaType(explicit, singleCandidates, collectionCandidates) {
4265
+ if (explicit) return explicit;
4266
+ const populatedCollection = collectionCandidates.find(
4267
+ (values) => Array.isArray(values) && values.length > 0
4268
+ );
4269
+ if (populatedCollection) {
4270
+ if (populatedCollection.length > 1) return "CAROUSEL_ALBUM";
4271
+ for (const values of collectionCandidates) {
4272
+ if (!values || values.length !== 1) continue;
4273
+ const inferred = mediaTypeFromPath(values[0]);
4274
+ if (inferred) return inferred;
4275
+ }
4276
+ return null;
4277
+ }
4278
+ for (const value of singleCandidates) {
4279
+ if (typeof value !== "string" || value.length === 0) continue;
4280
+ const inferred = mediaTypeFromPath(value);
4281
+ if (inferred) return inferred;
4282
+ }
4283
+ return null;
4284
+ }
4285
+ async function validatePublishMediaUrl(url) {
4286
+ try {
4287
+ if (new URL(url).protocol !== "https:") {
4288
+ return "Media URLs must use HTTPS.";
4289
+ }
4290
+ } catch {
4291
+ return "Media URL is invalid.";
4292
+ }
4293
+ const check = await validateUrlForSSRF(url);
4294
+ return check.isValid ? null : check.error || "Media URL failed safety validation.";
4295
+ }
3816
4296
  function accountEffectiveStatus(account) {
3817
4297
  return account.effective_status || account.status;
3818
4298
  }
@@ -3831,22 +4311,11 @@ function requestedAccountIdForPlatform(accountId, accountIds, platform2) {
3831
4311
  if (!accountIds) return void 0;
3832
4312
  return accountIds[platform2] || accountIds[platform2.toLowerCase()];
3833
4313
  }
3834
- function isAlreadyR2Signed(url) {
3835
- try {
3836
- const u = new URL(url);
3837
- return u.searchParams.has("X-Amz-Signature");
3838
- } catch {
3839
- return false;
3840
- }
3841
- }
3842
4314
  async function rehostExternalUrl(mediaUrl, projectId) {
3843
4315
  const ssrf = await validateUrlForSSRF(mediaUrl);
3844
4316
  if (!ssrf.isValid) {
3845
4317
  return { error: ssrf.error ?? "URL rejected by SSRF check" };
3846
4318
  }
3847
- if (isAlreadyR2Signed(mediaUrl)) {
3848
- return { signedUrl: mediaUrl, r2Key: "" };
3849
- }
3850
4319
  const { data, error } = await callEdgeFunction(
3851
4320
  "upload-to-r2",
3852
4321
  { url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
@@ -3865,19 +4334,19 @@ function registerDistributionTools(server) {
3865
4334
  media_url: z3.string().optional().describe(
3866
4335
  "URL of the media file to post. Any public HTTPS URL works \u2014 including ephemeral generator URLs (Replicate, OpenAI, DALL-E). The server persists non-R2 URLs into R2 before posting so scheduled posts and byte-upload platforms (X, LinkedIn, YouTube, Bluesky) do not 404 when the source URL expires. Set auto_rehost=false to skip. Not needed if media_urls, r2_key, or job_id is provided."
3867
4336
  ),
3868
- media_urls: z3.array(z3.string()).optional().describe(
4337
+ media_urls: z3.array(z3.string().url()).min(2).max(10).optional().describe(
3869
4338
  "Array of 2-10 image URLs for carousel posts. Same rehosting rules as media_url \u2014 ephemeral URLs are persisted automatically. Use with media_type=CAROUSEL_ALBUM."
3870
4339
  ),
3871
4340
  r2_key: z3.string().optional().describe(
3872
4341
  "R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
3873
4342
  ),
3874
- r2_keys: z3.array(z3.string()).optional().describe(
4343
+ r2_keys: z3.array(z3.string()).min(2).max(10).optional().describe(
3875
4344
  "Array of R2 object keys for carousel posts. Each is signed on demand. Alternative to media_urls."
3876
4345
  ),
3877
4346
  job_id: z3.string().optional().describe(
3878
4347
  "Async job ID from generate_image/generate_video. Resolves the completed job's R2 key and signs it. Alternative to media_url/r2_key."
3879
4348
  ),
3880
- job_ids: z3.array(z3.string()).optional().describe(
4349
+ job_ids: z3.array(z3.string()).min(2).max(10).optional().describe(
3881
4350
  "Array of async job IDs for carousel posts. Each resolved to its R2 key. Alternative to media_urls/r2_keys."
3882
4351
  ),
3883
4352
  platform_metadata: z3.object({
@@ -3907,7 +4376,10 @@ function registerDistributionTools(server) {
3907
4376
  category_id: z3.string().optional(),
3908
4377
  tags: z3.array(z3.string()).optional(),
3909
4378
  made_for_kids: z3.boolean().optional(),
3910
- notify_subscribers: z3.boolean().optional()
4379
+ notify_subscribers: z3.boolean().optional(),
4380
+ contains_synthetic_media: z3.boolean().optional().describe(
4381
+ "YouTube altered-or-synthetic-content disclosure. Defaults to true for MCP posts; set false explicitly for verified non-AI media."
4382
+ )
3911
4383
  }).optional(),
3912
4384
  facebook: z3.object({
3913
4385
  page_id: z3.string().optional().describe("Facebook Page ID to post to."),
@@ -3990,14 +4462,8 @@ function registerDistributionTools(server) {
3990
4462
  auto_rehost: z3.boolean().optional().describe(
3991
4463
  "Whether to persist non-R2 media_url/media_urls into R2 before posting. Default: true. Set to false only if you know the source URL will outlive the scheduling window and every target platform supports URL ingest."
3992
4464
  ),
3993
- visual_gate_result: z3.object({ passed: z3.boolean() }).passthrough().optional().describe(
3994
- "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."
3995
- ),
3996
- origin: z3.enum(["human", "hermes", "user"]).optional().describe(
3997
- "Originator lineage for the post. Marks who scheduled it; use the agent value when calling from an autonomous workflow, otherwise default 'human'."
3998
- ),
3999
- hermes_run_id: z3.string().optional().describe(
4000
- "Optional agent run identifier for traceability between a draft and the run that produced it."
4465
+ idempotency_key: z3.string().regex(/^[a-zA-Z0-9_-]{8,128}$/).optional().describe(
4466
+ "Stable 8-128 character retry key (letters, numbers, underscore, hyphen). Reuse the same key when retrying the same publish request to prevent duplicate posts."
4001
4467
  )
4002
4468
  },
4003
4469
  async ({
@@ -4020,9 +4486,7 @@ function registerDistributionTools(server) {
4020
4486
  platform_metadata,
4021
4487
  account_id,
4022
4488
  account_ids,
4023
- visual_gate_result,
4024
- origin,
4025
- hermes_run_id
4489
+ idempotency_key
4026
4490
  }) => {
4027
4491
  const format = response_format ?? "text";
4028
4492
  if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
@@ -4051,6 +4515,8 @@ function registerDistributionTools(server) {
4051
4515
  }
4052
4516
  let resolvedMediaUrl = media_url;
4053
4517
  let resolvedMediaUrls = media_urls;
4518
+ let resolvedMediaUrlIsTrustedR2 = false;
4519
+ let resolvedMediaUrlsAreTrustedR2 = (media_urls ?? []).map(() => false);
4054
4520
  const signR2Key = async (key) => {
4055
4521
  const cleanKey = key.startsWith("r2://") ? key.slice(5) : key;
4056
4522
  const { data: signData } = await callEdgeFunction(
@@ -4068,8 +4534,11 @@ function registerDistributionTools(server) {
4068
4534
  );
4069
4535
  const resultUrl = jobData?.job?.result_url;
4070
4536
  if (!resultUrl) return null;
4071
- if (!resultUrl.startsWith("http")) return signR2Key(resultUrl);
4072
- return resultUrl;
4537
+ if (!resultUrl.startsWith("http")) {
4538
+ const signed = await signR2Key(resultUrl);
4539
+ return signed ? { url: signed, trustedR2: true } : null;
4540
+ }
4541
+ return { url: resultUrl, trustedR2: false };
4073
4542
  };
4074
4543
  try {
4075
4544
  if (r2_key && !resolvedMediaUrl) {
@@ -4086,6 +4555,7 @@ function registerDistributionTools(server) {
4086
4555
  };
4087
4556
  }
4088
4557
  resolvedMediaUrl = signed;
4558
+ resolvedMediaUrlIsTrustedR2 = true;
4089
4559
  } else if (job_id && !resolvedMediaUrl && !r2_key) {
4090
4560
  const resolved = await resolveJobId(job_id);
4091
4561
  if (!resolved) {
@@ -4099,7 +4569,8 @@ function registerDistributionTools(server) {
4099
4569
  isError: true
4100
4570
  };
4101
4571
  }
4102
- resolvedMediaUrl = resolved;
4572
+ resolvedMediaUrl = resolved.url;
4573
+ resolvedMediaUrlIsTrustedR2 = resolved.trustedR2;
4103
4574
  }
4104
4575
  if (r2_keys && r2_keys.length > 0 && !resolvedMediaUrls) {
4105
4576
  const signed = await Promise.all(r2_keys.map(signR2Key));
@@ -4116,6 +4587,7 @@ function registerDistributionTools(server) {
4116
4587
  };
4117
4588
  }
4118
4589
  resolvedMediaUrls = signed;
4590
+ resolvedMediaUrlsAreTrustedR2 = signed.map(() => true);
4119
4591
  } else if (job_ids && job_ids.length > 0 && !resolvedMediaUrls && !r2_keys) {
4120
4592
  const resolved = await Promise.all(job_ids.map(resolveJobId));
4121
4593
  const failIdx = resolved.findIndex((r) => !r);
@@ -4130,10 +4602,12 @@ function registerDistributionTools(server) {
4130
4602
  isError: true
4131
4603
  };
4132
4604
  }
4133
- resolvedMediaUrls = resolved;
4605
+ const resolvedJobs = resolved;
4606
+ resolvedMediaUrls = resolvedJobs.map((item) => item.url);
4607
+ resolvedMediaUrlsAreTrustedR2 = resolvedJobs.map((item) => item.trustedR2);
4134
4608
  }
4135
4609
  const shouldRehost = auto_rehost !== false;
4136
- if (shouldRehost && resolvedMediaUrl) {
4610
+ if (shouldRehost && resolvedMediaUrl && !resolvedMediaUrlIsTrustedR2) {
4137
4611
  const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
4138
4612
  if ("error" in rehost) {
4139
4613
  return {
@@ -4147,10 +4621,13 @@ function registerDistributionTools(server) {
4147
4621
  };
4148
4622
  }
4149
4623
  resolvedMediaUrl = rehost.signedUrl;
4624
+ resolvedMediaUrlIsTrustedR2 = true;
4150
4625
  }
4151
4626
  if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
4152
4627
  const rehosted = await Promise.all(
4153
- resolvedMediaUrls.map((u) => rehostExternalUrl(u, project_id))
4628
+ resolvedMediaUrls.map(
4629
+ (u, index) => resolvedMediaUrlsAreTrustedR2[index] ? Promise.resolve({ signedUrl: u, r2Key: "" }) : rehostExternalUrl(u, project_id)
4630
+ )
4154
4631
  );
4155
4632
  const failIdx = rehosted.findIndex((r) => "error" in r);
4156
4633
  if (failIdx !== -1) {
@@ -4166,6 +4643,7 @@ function registerDistributionTools(server) {
4166
4643
  };
4167
4644
  }
4168
4645
  resolvedMediaUrls = rehosted.map((r) => r.signedUrl);
4646
+ resolvedMediaUrlsAreTrustedR2 = resolvedMediaUrls.map(() => true);
4169
4647
  }
4170
4648
  } catch (resolveErr) {
4171
4649
  return {
@@ -4178,6 +4656,42 @@ function registerDistributionTools(server) {
4178
4656
  isError: true
4179
4657
  };
4180
4658
  }
4659
+ const hasResolvedMedia = Boolean(
4660
+ resolvedMediaUrl || resolvedMediaUrls && resolvedMediaUrls.length > 0
4661
+ );
4662
+ const resolvedMediaType = inferScheduleMediaType(
4663
+ media_type,
4664
+ [resolvedMediaUrl, r2_key],
4665
+ [resolvedMediaUrls, r2_keys]
4666
+ );
4667
+ if (hasResolvedMedia && !resolvedMediaType) {
4668
+ return {
4669
+ content: [
4670
+ {
4671
+ type: "text",
4672
+ text: "media_type is required when the media format cannot be inferred from a file extension. Set IMAGE, VIDEO, or CAROUSEL_ALBUM explicitly."
4673
+ }
4674
+ ],
4675
+ isError: true
4676
+ };
4677
+ }
4678
+ const finalUrls = [resolvedMediaUrl, ...resolvedMediaUrls ?? []].filter(
4679
+ (value) => typeof value === "string" && value.length > 0
4680
+ );
4681
+ for (const url of finalUrls) {
4682
+ const validationError = await validatePublishMediaUrl(url);
4683
+ if (validationError) {
4684
+ return {
4685
+ content: [
4686
+ {
4687
+ type: "text",
4688
+ text: `Media URL blocked: ${validationError}`
4689
+ }
4690
+ ],
4691
+ isError: true
4692
+ };
4693
+ }
4694
+ }
4181
4695
  const normalizedPlatforms = platforms.map(
4182
4696
  (p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p
4183
4697
  );
@@ -4303,12 +4817,32 @@ Created with Social Neuron`;
4303
4817
  };
4304
4818
  return true;
4305
4819
  })();
4820
+ const defaultAiDisclosure = (platformKey, field) => {
4821
+ const existing = normalizedPlatformMetadata?.[platformKey];
4822
+ if (existing && existing[field] !== void 0) return;
4823
+ normalizedPlatformMetadata = {
4824
+ ...normalizedPlatformMetadata ?? {},
4825
+ [platformKey]: {
4826
+ ...existing ?? {},
4827
+ [field]: true
4828
+ }
4829
+ };
4830
+ };
4831
+ if (normalizedPlatforms.includes("TikTok")) {
4832
+ defaultAiDisclosure("tiktok", "is_ai_generated");
4833
+ }
4834
+ if (normalizedPlatforms.includes("Instagram")) {
4835
+ defaultAiDisclosure("instagram", "is_ai_generated");
4836
+ }
4837
+ if (normalizedPlatforms.includes("YouTube")) {
4838
+ defaultAiDisclosure("youtube", "contains_synthetic_media");
4839
+ }
4306
4840
  const { data, error } = await callEdgeFunction(
4307
4841
  "schedule-post",
4308
4842
  {
4309
4843
  mediaUrl: resolvedMediaUrl,
4310
4844
  mediaUrls: resolvedMediaUrls,
4311
- mediaType: media_type,
4845
+ mediaType: resolvedMediaType ?? void 0,
4312
4846
  caption: finalCaption,
4313
4847
  platforms: normalizedPlatforms,
4314
4848
  title,
@@ -4321,14 +4855,10 @@ Created with Social Neuron`;
4321
4855
  normalizedPlatformMetadata
4322
4856
  )
4323
4857
  } : {},
4324
- // Forward visual gate verdict + source so the EF can enforce on media posts.
4325
- ...visual_gate_result ? { visualGateResult: visual_gate_result } : {},
4326
- visualGateSource: "mcp",
4327
- // Originator lineage (Hermes integration, 2026-05-22). EF validates origin
4328
- // against the posts.origin CHECK constraint and persists hermesRunId when
4329
- // origin === 'hermes'.
4330
- ...origin ? { origin } : {},
4331
- ...hermes_run_id ? { hermesRunId: hermes_run_id } : {}
4858
+ ...idempotency_key ? { idempotencyKey: idempotency_key } : {}
4859
+ // Attribution is assigned by the authenticated gateway. Visual QA
4860
+ // attestations are server-produced evidence and are deliberately not
4861
+ // accepted from an MCP caller.
4332
4862
  },
4333
4863
  { timeoutMs: 3e4 }
4334
4864
  );
@@ -4395,76 +4925,173 @@ Created with Social Neuron`;
4395
4925
  }
4396
4926
  );
4397
4927
  server.tool(
4398
- "list_connected_accounts",
4399
- "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.",
4928
+ "reschedule_post",
4929
+ "Move an existing pending or scheduled post to a new future time without creating a duplicate. Pass project_id for the post's brand. expected_scheduled_at is recommended: it prevents overwriting a change made in another client after the calendar was loaded.",
4400
4930
  {
4401
- project_id: z3.string().optional().describe(
4402
- "Brand/project ID to scope connected accounts. Use the same project_id when calling schedule_post."
4931
+ post_id: z3.string().uuid().describe("Post ID returned by list_recent_posts."),
4932
+ project_id: z3.string().uuid().optional().describe(
4933
+ "Brand/project ID that owns the post. Defaults to the authenticated key's project or the account default."
4403
4934
  ),
4404
- include_all: z3.boolean().optional().describe("If true, include expired or inactive accounts as well as usable accounts."),
4405
- response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4935
+ scheduled_at: z3.string().datetime({ offset: true }).describe("New future publish time as an ISO 8601 datetime with timezone."),
4936
+ expected_scheduled_at: z3.string().datetime({ offset: true }).optional().describe(
4937
+ "Optional current schedule timestamp. If it changed since you read it, the update is rejected instead of silently overwriting it."
4938
+ ),
4939
+ response_format: z3.enum(["text", "json"]).default("text")
4406
4940
  },
4407
- async ({ project_id, include_all, response_format }) => {
4408
- const format = response_format ?? "text";
4409
- const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
4410
- action: "connected-accounts",
4411
- ...project_id ? { projectId: project_id, project_id } : {},
4412
- ...include_all ? { includeAll: true } : {}
4413
- });
4414
- if (efError || !result?.success) {
4941
+ async ({
4942
+ post_id,
4943
+ project_id,
4944
+ scheduled_at,
4945
+ expected_scheduled_at,
4946
+ response_format
4947
+ }) => {
4948
+ const next = new Date(scheduled_at);
4949
+ if (!Number.isFinite(next.getTime()) || next.getTime() <= Date.now()) {
4415
4950
  return {
4416
4951
  content: [
4417
4952
  {
4418
4953
  type: "text",
4419
- text: `Failed to list connected accounts: ${efError || result?.error || "Unknown error"}`
4954
+ text: "scheduled_at must be a valid future ISO datetime with timezone."
4420
4955
  }
4421
4956
  ],
4422
4957
  isError: true
4423
4958
  };
4424
4959
  }
4425
- const accounts = result.accounts ?? [];
4426
- if (accounts.length === 0) {
4427
- if (format === "json") {
4428
- return {
4429
- content: [
4430
- {
4431
- type: "text",
4432
- text: JSON.stringify(asEnvelope3({ accounts: [] }), null, 2)
4433
- }
4434
- ]
4435
- };
4436
- }
4960
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
4961
+ if (!resolvedProjectId) {
4437
4962
  return {
4438
4963
  content: [
4439
4964
  {
4440
4965
  type: "text",
4441
- text: "No connected social media accounts found. Connect platforms in Social Neuron Settings > Connections."
4966
+ text: "No project_id was provided and no default project is configured."
4442
4967
  }
4443
- ]
4968
+ ],
4969
+ isError: true
4444
4970
  };
4445
4971
  }
4446
- const lines = [
4447
- `${accounts.length} connected account(s)${project_id ? ` for project ${project_id}` : ""}:`,
4448
- ""
4449
- ];
4450
- for (const account of accounts) {
4451
- const name = account.username || "(unnamed)";
4452
- const platformLower = account.platform.toLowerCase();
4453
- const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
4454
- const status = accountEffectiveStatus(account);
4455
- lines.push(
4456
- ` ${platformLower}: ${name} | id=${account.id} | ${project} | status=${status} (connected ${account.created_at.split("T")[0]})`
4457
- );
4458
- }
4459
- if (format === "json") {
4972
+ const userId = await getDefaultUserId();
4973
+ const rateLimit = checkRateLimit("posting", `reschedule_post:${userId}`);
4974
+ if (!rateLimit.allowed) {
4460
4975
  return {
4461
4976
  content: [
4462
4977
  {
4463
4978
  type: "text",
4464
- text: JSON.stringify(asEnvelope3({ accounts }), null, 2)
4979
+ text: `Rate limit exceeded. Retry in ~${rateLimit.retryAfter}s.`
4465
4980
  }
4466
- ]
4467
- };
4981
+ ],
4982
+ isError: true
4983
+ };
4984
+ }
4985
+ const { data: result, error } = await callEdgeFunction("mcp-data", {
4986
+ action: "reschedule-scheduled-post",
4987
+ post_id,
4988
+ projectId: resolvedProjectId,
4989
+ project_id: resolvedProjectId,
4990
+ scheduled_at: next.toISOString(),
4991
+ ...expected_scheduled_at ? { expected_scheduled_at: new Date(expected_scheduled_at).toISOString() } : {}
4992
+ });
4993
+ if (error || !result?.success) {
4994
+ const code = result?.error ?? error ?? "reschedule_failed";
4995
+ const recovery = code === "publishing_in_progress" ? "The worker has already started publishing this post." : code === "schedule_conflict" ? `The schedule changed in another client${result?.current_scheduled_at ? ` to ${result.current_scheduled_at}` : ""}; refresh the calendar before retrying.` : code === "not_found" ? "The post was not found in this project." : code === "not_reschedulable" ? `This post can no longer be rescheduled${result?.status ? ` (status: ${result.status})` : ""}.` : "The post could not be rescheduled.";
4996
+ return {
4997
+ content: [{ type: "text", text: recovery }],
4998
+ isError: true
4999
+ };
5000
+ }
5001
+ const publicResult = {
5002
+ success: true,
5003
+ post_id: result.post_id ?? post_id,
5004
+ project_id: result.project_id ?? resolvedProjectId,
5005
+ previous_scheduled_at: result.previous_scheduled_at ?? null,
5006
+ scheduled_at: result.scheduled_at ?? next.toISOString()
5007
+ };
5008
+ const structuredContent = asEnvelope3(publicResult);
5009
+ return {
5010
+ structuredContent,
5011
+ content: [
5012
+ {
5013
+ type: "text",
5014
+ text: response_format === "json" ? JSON.stringify(structuredContent, null, 2) : `Post ${publicResult.post_id} rescheduled to ${publicResult.scheduled_at}.`
5015
+ }
5016
+ ]
5017
+ };
5018
+ }
5019
+ );
5020
+ server.tool(
5021
+ "list_connected_accounts",
5022
+ "Check which social platforms have active OAuth connections for posting. Call this before schedule_post to verify credentials. Pass project_id to list the accounts for a specific brand/project, then pass the returned account id as account_id/account_ids when posting. If a platform is missing or expired, the user needs to reconnect at socialneuron.com/settings/connections.",
5023
+ {
5024
+ project_id: z3.string().optional().describe(
5025
+ "Brand/project ID to scope connected accounts. Use the same project_id when calling schedule_post."
5026
+ ),
5027
+ include_all: z3.boolean().optional().describe("If true, include expired or inactive accounts as well as usable accounts."),
5028
+ response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5029
+ },
5030
+ async ({ project_id, include_all, response_format }) => {
5031
+ const format = response_format ?? "text";
5032
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
5033
+ action: "connected-accounts",
5034
+ ...project_id ? { projectId: project_id, project_id } : {},
5035
+ ...include_all ? { includeAll: true } : {}
5036
+ });
5037
+ if (efError || !result?.success) {
5038
+ return {
5039
+ content: [
5040
+ {
5041
+ type: "text",
5042
+ text: `Failed to list connected accounts: ${efError || result?.error || "Unknown error"}`
5043
+ }
5044
+ ],
5045
+ isError: true
5046
+ };
5047
+ }
5048
+ const accounts = (result.accounts ?? []).map(publicConnectedAccount).filter((account) => account !== null);
5049
+ if (accounts.length === 0) {
5050
+ if (format === "json") {
5051
+ const structuredContent = asEnvelope3({ accounts: [] });
5052
+ return {
5053
+ structuredContent,
5054
+ content: [
5055
+ {
5056
+ type: "text",
5057
+ text: JSON.stringify(structuredContent, null, 2)
5058
+ }
5059
+ ]
5060
+ };
5061
+ }
5062
+ return {
5063
+ content: [
5064
+ {
5065
+ type: "text",
5066
+ text: "No connected social media accounts found. Connect platforms in Social Neuron Settings > Connections."
5067
+ }
5068
+ ]
5069
+ };
5070
+ }
5071
+ const lines = [
5072
+ `${accounts.length} connected account(s)${project_id ? ` for project ${project_id}` : ""}:`,
5073
+ ""
5074
+ ];
5075
+ for (const account of accounts) {
5076
+ const name = account.username || "(unnamed)";
5077
+ const platformLower = account.platform.toLowerCase();
5078
+ const project = account.project_id ? `project_id=${account.project_id}` : "project_id=unassigned";
5079
+ const status = accountEffectiveStatus(account);
5080
+ lines.push(
5081
+ ` ${platformLower}: ${name} | id=${account.id} | ${project} | status=${status} (connected ${account.created_at.split("T")[0]})`
5082
+ );
5083
+ }
5084
+ if (format === "json") {
5085
+ const structuredContent = asEnvelope3({ accounts });
5086
+ return {
5087
+ structuredContent,
5088
+ content: [
5089
+ {
5090
+ type: "text",
5091
+ text: JSON.stringify(structuredContent, null, 2)
5092
+ }
5093
+ ]
5094
+ };
4468
5095
  }
4469
5096
  return {
4470
5097
  content: [{ type: "text", text: lines.join("\n") }]
@@ -4475,6 +5102,9 @@ Created with Social Neuron`;
4475
5102
  "list_recent_posts",
4476
5103
  "List recent published and scheduled posts with status, platform, title, and timestamps. Use to check what has been posted before planning new content, or to find post IDs for fetch_analytics. Filter by platform or status to narrow results.",
4477
5104
  {
5105
+ project_id: z3.string().uuid().optional().describe(
5106
+ "Brand/project ID to scope posts. Defaults to the authenticated key's project or the account default."
5107
+ ),
4478
5108
  platform: z3.enum([
4479
5109
  "youtube",
4480
5110
  "tiktok",
@@ -4490,13 +5120,27 @@ Created with Social Neuron`;
4490
5120
  limit: z3.number().min(1).max(50).optional().describe("Maximum number of posts to return. Defaults to 20."),
4491
5121
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4492
5122
  },
4493
- async ({ platform: platform2, status, days, limit, response_format }) => {
5123
+ async ({ project_id, platform: platform2, status, days, limit, response_format }) => {
4494
5124
  const format = response_format ?? "text";
4495
5125
  const lookbackDays = days ?? 7;
5126
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
5127
+ if (!resolvedProjectId) {
5128
+ return {
5129
+ content: [
5130
+ {
5131
+ type: "text",
5132
+ text: "No project_id was provided and no default project is configured."
5133
+ }
5134
+ ],
5135
+ isError: true
5136
+ };
5137
+ }
4496
5138
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
4497
5139
  action: "recent-posts",
4498
5140
  days: lookbackDays,
4499
5141
  limit: limit ?? 20,
5142
+ projectId: resolvedProjectId,
5143
+ project_id: resolvedProjectId,
4500
5144
  ...platform2 ? { platform: platform2 } : {},
4501
5145
  ...status ? { status } : {}
4502
5146
  });
@@ -4511,7 +5155,7 @@ Created with Social Neuron`;
4511
5155
  isError: true
4512
5156
  };
4513
5157
  }
4514
- const rows = result.posts ?? [];
5158
+ const rows = (result.posts ?? []).map(publicPostRecord).filter((post) => post !== null);
4515
5159
  if (rows.length === 0) {
4516
5160
  if (format === "json") {
4517
5161
  const structuredContent2 = asEnvelope3({ posts: [] });
@@ -4586,8 +5230,11 @@ Created with Social Neuron`;
4586
5230
  };
4587
5231
  server.tool(
4588
5232
  "find_next_slots",
4589
- "Find optimal posting time slots based on best posting times and existing schedule. Returns non-conflicting slots sorted by engagement score.",
5233
+ "Find optimal posting time slots for one brand/project based on preferred posting times and that project's existing schedule. Returns non-conflicting slots sorted by engagement score.",
4590
5234
  {
5235
+ project_id: z3.string().uuid().optional().describe(
5236
+ "Brand/project ID used for conflict detection. Defaults to the authenticated key's project or the account default."
5237
+ ),
4591
5238
  platforms: z3.array(
4592
5239
  z3.enum([
4593
5240
  "youtube",
@@ -4606,6 +5253,7 @@ Created with Social Neuron`;
4606
5253
  response_format: z3.enum(["text", "json"]).default("text")
4607
5254
  },
4608
5255
  async ({
5256
+ project_id,
4609
5257
  platforms,
4610
5258
  count,
4611
5259
  start_after,
@@ -4613,13 +5261,35 @@ Created with Social Neuron`;
4613
5261
  response_format
4614
5262
  }) => {
4615
5263
  try {
5264
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
5265
+ if (!resolvedProjectId) {
5266
+ return {
5267
+ content: [
5268
+ {
5269
+ type: "text",
5270
+ text: "No project_id was provided and no default project is configured."
5271
+ }
5272
+ ],
5273
+ isError: true
5274
+ };
5275
+ }
4616
5276
  const startDate = start_after ? new Date(start_after) : /* @__PURE__ */ new Date();
5277
+ if (!Number.isFinite(startDate.getTime())) {
5278
+ return {
5279
+ content: [
5280
+ { type: "text", text: "start_after must be a valid ISO datetime." }
5281
+ ],
5282
+ isError: true
5283
+ };
5284
+ }
4617
5285
  const endDate = new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1e3);
4618
5286
  const { data: postsResult, error: postsError } = await callEdgeFunction("mcp-data", {
4619
5287
  action: "scheduled-posts",
4620
5288
  start_date: startDate.toISOString(),
4621
5289
  end_date: endDate.toISOString(),
4622
- statuses: ["scheduled", "draft"]
5290
+ statuses: ["pending", "scheduled", "draft"],
5291
+ projectId: resolvedProjectId,
5292
+ project_id: resolvedProjectId
4623
5293
  });
4624
5294
  const existingPosts = postsError ? [] : postsResult?.posts ?? [];
4625
5295
  const gapMs = min_gap_hours * 60 * 60 * 1e3;
@@ -4659,19 +5329,17 @@ Created with Social Neuron`;
4659
5329
  const slots = candidates.filter((s) => !s.conflict).sort((a, b) => b.engagement_score - a.engagement_score).slice(0, count);
4660
5330
  const conflictsAvoided = candidates.filter((s) => s.conflict).length;
4661
5331
  if (response_format === "json") {
5332
+ const structuredContent = asEnvelope3({
5333
+ slots,
5334
+ total_candidates: candidates.length,
5335
+ conflicts_avoided: conflictsAvoided
5336
+ });
4662
5337
  return {
5338
+ structuredContent,
4663
5339
  content: [
4664
5340
  {
4665
5341
  type: "text",
4666
- text: JSON.stringify(
4667
- asEnvelope3({
4668
- slots,
4669
- total_candidates: candidates.length,
4670
- conflicts_avoided: conflictsAvoided
4671
- }),
4672
- null,
4673
- 2
4674
- )
5342
+ text: JSON.stringify(structuredContent, null, 2)
4675
5343
  }
4676
5344
  ],
4677
5345
  isError: false
@@ -5036,7 +5704,7 @@ Created with Social Neuron`;
5036
5704
  const results = [];
5037
5705
  const buildIdempotencyKey = (post) => {
5038
5706
  const planId = effectivePlanId ?? (typeof workingPlan.plan_id === "string" ? String(workingPlan.plan_id) : "inline");
5039
- const captionHash = createHash2("sha256").update(post.caption).digest("hex").slice(0, 16);
5707
+ const captionHash = createHash("sha256").update(post.caption).digest("hex").slice(0, 16);
5040
5708
  const raw = [
5041
5709
  "schedule_content_plan",
5042
5710
  planId,
@@ -5046,7 +5714,7 @@ Created with Social Neuron`;
5046
5714
  captionHash,
5047
5715
  idempotency_seed ?? ""
5048
5716
  ].join(":");
5049
- return `plan-${createHash2("sha256").update(raw).digest("hex").slice(0, 48)}`;
5717
+ return `plan-${createHash("sha256").update(raw).digest("hex").slice(0, 48)}`;
5050
5718
  };
5051
5719
  const scheduleOne = async (post) => {
5052
5720
  if (!post.schedule_at) {
@@ -5525,7 +6193,10 @@ function registerMediaTools(server) {
5525
6193
  content: [
5526
6194
  {
5527
6195
  type: "text",
5528
- text: `R2 upload failed (HTTP ${putResp.status}): ${await putResp.text().catch(() => "Unknown error")}`
6196
+ // Presigned-store bodies may include provider request IDs,
6197
+ // bucket names, or signed URL fragments. Status is enough
6198
+ // for user recovery and safe diagnostics.
6199
+ text: `R2 upload failed (HTTP ${putResp.status}). Please retry.`
5529
6200
  }
5530
6201
  ],
5531
6202
  isError: true
@@ -5713,7 +6384,7 @@ function asEnvelope4(data) {
5713
6384
  function registerAnalyticsTools(server) {
5714
6385
  server.tool(
5715
6386
  "fetch_analytics",
5716
- "Get post performance metrics \u2014 views, likes, comments, shares, and engagement rate. Filter by platform, time range (default 30 days), or specific content_id. Call refresh_platform_analytics first if data seems stale. Results sorted by most recent capture.",
6387
+ "Get project-scoped post performance metrics \u2014 views, likes, comments, shares, and engagement rate. Filter by platform, time range (default 30 days), or specific content_id. Call refresh_platform_analytics first if data seems stale. Results sorted by most recent capture.",
5717
6388
  {
5718
6389
  platform: z5.enum([
5719
6390
  "youtube",
@@ -5731,19 +6402,26 @@ function registerAnalyticsTools(server) {
5731
6402
  content_id: z5.string().uuid().optional().describe(
5732
6403
  "Filter to a specific content_history ID to see performance of one piece of content."
5733
6404
  ),
6405
+ project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
5734
6406
  limit: z5.number().min(1).max(100).optional().describe("Maximum number of posts to return. Defaults to 20."),
5735
6407
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5736
6408
  },
5737
- async ({ platform: platform2, days, content_id, limit, response_format }) => {
6409
+ async ({ platform: platform2, days, content_id, project_id, limit, response_format }) => {
5738
6410
  const format = response_format ?? "text";
5739
6411
  const lookbackDays = days ?? 30;
5740
6412
  const maxPosts = limit ?? 20;
6413
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
5741
6414
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
5742
6415
  action: "analytics",
5743
6416
  platform: platform2,
5744
6417
  days: lookbackDays,
5745
- limit: maxPosts,
5746
- contentId: content_id
6418
+ // Fetch extra snapshots, then deduplicate to the requested post count.
6419
+ // The backend understands latestOnly after the paired application
6420
+ // deployment; older deployments safely ignore the hint.
6421
+ limit: Math.min(maxPosts * 5, 100),
6422
+ latestOnly: true,
6423
+ contentId: content_id,
6424
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
5747
6425
  });
5748
6426
  if (efError) {
5749
6427
  return {
@@ -5751,7 +6429,14 @@ function registerAnalyticsTools(server) {
5751
6429
  isError: true
5752
6430
  };
5753
6431
  }
5754
- const rows = result?.rows ?? [];
6432
+ const snapshotRows = result?.rows ?? [];
6433
+ const seenSnapshots = /* @__PURE__ */ new Set();
6434
+ const rows = [...snapshotRows].sort((a, b) => b.captured_at.localeCompare(a.captured_at)).filter((row) => {
6435
+ const key = `${row.post_id}:${row.platform.toLowerCase()}`;
6436
+ if (seenSnapshots.has(key)) return false;
6437
+ seenSnapshots.add(key);
6438
+ return true;
6439
+ }).slice(0, maxPosts);
5755
6440
  if (rows.length === 0) {
5756
6441
  if (format === "json") {
5757
6442
  const structuredContent = asEnvelope4({
@@ -5813,14 +6498,19 @@ function registerAnalyticsTools(server) {
5813
6498
  );
5814
6499
  server.tool(
5815
6500
  "refresh_platform_analytics",
5816
- "Queue analytics refresh jobs for 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.",
6501
+ "Queue analytics refresh jobs for posts from the last 7 days in one project across its connected platforms. Call this before fetch_analytics if you need fresh data. Returns immediately \u2014 data updates asynchronously over the next 1-5 minutes.",
5817
6502
  {
6503
+ project_id: z5.string().optional().describe("Project ID. Defaults to the active project context."),
5818
6504
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5819
6505
  },
5820
- async ({ response_format }) => {
6506
+ async ({ project_id, response_format }) => {
5821
6507
  const format = response_format ?? "text";
5822
6508
  const userId = await getDefaultUserId();
5823
- const rateLimit = checkRateLimit("posting", `refresh_platform_analytics:${userId}`);
6509
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
6510
+ const rateLimit = checkRateLimit(
6511
+ "posting",
6512
+ `refresh_platform_analytics:${userId}:${resolvedProjectId ?? "default"}`
6513
+ );
5824
6514
  if (!rateLimit.allowed) {
5825
6515
  return {
5826
6516
  content: [
@@ -5832,7 +6522,10 @@ function registerAnalyticsTools(server) {
5832
6522
  isError: true
5833
6523
  };
5834
6524
  }
5835
- const { data, error } = await callEdgeFunction("fetch-analytics", { userId });
6525
+ const { data, error } = await callEdgeFunction("fetch-analytics", {
6526
+ userId,
6527
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
6528
+ });
5836
6529
  if (error) {
5837
6530
  return {
5838
6531
  content: [{ type: "text", text: `Error refreshing analytics: ${error}` }],
@@ -5861,7 +6554,8 @@ function registerAnalyticsTools(server) {
5861
6554
  success: true,
5862
6555
  postsProcessed: result.postsProcessed,
5863
6556
  queued,
5864
- errored
6557
+ errored,
6558
+ projectId: resolvedProjectId ?? null
5865
6559
  });
5866
6560
  return {
5867
6561
  structuredContent,
@@ -5915,6 +6609,80 @@ function formatAnalytics(summary, days, format) {
5915
6609
  // src/tools/brand.ts
5916
6610
  import { z as z6 } from "zod";
5917
6611
  init_supabase();
6612
+
6613
+ // src/lib/brandUrlInput.ts
6614
+ var PLATFORM_ALIASES = {
6615
+ instagram: "instagram",
6616
+ ig: "instagram",
6617
+ insta: "instagram",
6618
+ tiktok: "tiktok",
6619
+ tt: "tiktok",
6620
+ twitter: "twitter",
6621
+ x: "twitter",
6622
+ linkedin: "linkedin",
6623
+ li: "linkedin",
6624
+ youtube: "youtube",
6625
+ yt: "youtube"
6626
+ };
6627
+ var PROFILE_URL_BUILDERS = {
6628
+ instagram: (handle) => `https://instagram.com/${encodeURIComponent(handle)}`,
6629
+ tiktok: (handle) => `https://tiktok.com/@${encodeURIComponent(handle)}`,
6630
+ twitter: (handle) => `https://x.com/${encodeURIComponent(handle)}`,
6631
+ linkedin: (handle) => `https://linkedin.com/company/${encodeURIComponent(handle)}`,
6632
+ youtube: (handle) => `https://youtube.com/@${encodeURIComponent(handle)}`
6633
+ };
6634
+ var UNICODE_DOT_VARIANTS = /[。.。]/g;
6635
+ function canonicalizeHost(hostname) {
6636
+ return hostname.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, "");
6637
+ }
6638
+ function hostLooksLikeDomain(hostname) {
6639
+ return /\S+\.\S+/.test(canonicalizeHost(hostname));
6640
+ }
6641
+ function resolveHandle(handle, platform2) {
6642
+ const normalized = handle.replace(/^@/, "").trim();
6643
+ if (!normalized) return { url: null, handle: null, platform: null, ambiguous: false };
6644
+ if (!platform2) return { url: null, handle: normalized, platform: null, ambiguous: true };
6645
+ return {
6646
+ url: PROFILE_URL_BUILDERS[platform2](normalized),
6647
+ handle: normalized,
6648
+ platform: platform2,
6649
+ ambiguous: false
6650
+ };
6651
+ }
6652
+ function normalizeBrandUrlInput(raw, explicitPlatform) {
6653
+ const input = (raw ?? "").trim();
6654
+ if (!input) return { url: null, handle: null, platform: null, ambiguous: false };
6655
+ if (/^https?:\/\//i.test(input)) {
6656
+ let parsed;
6657
+ try {
6658
+ parsed = new URL(input);
6659
+ } catch {
6660
+ return { url: null, handle: null, platform: null, ambiguous: false, invalidUrl: true };
6661
+ }
6662
+ if (hostLooksLikeDomain(parsed.hostname)) {
6663
+ return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
6664
+ }
6665
+ return resolveHandle(canonicalizeHost(parsed.hostname), explicitPlatform);
6666
+ }
6667
+ const qualified = input.match(/^([a-z]{1,10}):\s*@?(.+)$/i);
6668
+ if (qualified) {
6669
+ const platform2 = PLATFORM_ALIASES[qualified[1].toLowerCase()];
6670
+ if (platform2) return resolveHandle(qualified[2], platform2);
6671
+ }
6672
+ if (input.startsWith("@")) return resolveHandle(input, explicitPlatform);
6673
+ if (!/\s/.test(input)) {
6674
+ try {
6675
+ const parsed = new URL(`https://${input}`);
6676
+ if (hostLooksLikeDomain(parsed.hostname)) {
6677
+ return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
6678
+ }
6679
+ } catch {
6680
+ }
6681
+ }
6682
+ return resolveHandle(input.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, ""), explicitPlatform);
6683
+ }
6684
+
6685
+ // src/tools/brand.ts
5918
6686
  function asEnvelope5(data) {
5919
6687
  return {
5920
6688
  _meta: {
@@ -5929,12 +6697,31 @@ function registerBrandTools(server) {
5929
6697
  "extract_brand",
5930
6698
  "Analyze a website URL and extract brand identity data including brand name, colors, voice/tone, target audience, and logo. Uses AI-powered analysis of the page HTML. Useful for understanding a brand before generating content for it.",
5931
6699
  {
5932
- url: z6.string().url().describe(
5933
- 'The website URL to analyze for brand identity (e.g. "https://example.com").'
6700
+ url: z6.string().min(1).max(2048).describe(
6701
+ 'The website URL to analyze for brand identity (e.g. "https://example.com"). Bare handles are rejected; supply a full profile URL or use "platform:handle" shorthand (e.g. "instagram:acmefoods").'
5934
6702
  ),
5935
6703
  response_format: z6.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5936
6704
  },
5937
- async ({ url, response_format }) => {
6705
+ async ({ url: rawUrl, response_format }) => {
6706
+ const normalized = normalizeBrandUrlInput(rawUrl);
6707
+ if (normalized.invalidUrl) {
6708
+ return {
6709
+ content: [{ type: "text", text: `URL blocked: "${rawUrl}" is not a valid URL.` }],
6710
+ isError: true
6711
+ };
6712
+ }
6713
+ if (!normalized.url) {
6714
+ return {
6715
+ content: [
6716
+ {
6717
+ type: "text",
6718
+ text: `"${rawUrl}" looks like a handle, not a full URL. Provide a profile URL or use "platform:handle" shorthand (for example "instagram:acmefoods").`
6719
+ }
6720
+ ],
6721
+ isError: true
6722
+ };
6723
+ }
6724
+ const url = normalized.url;
5938
6725
  const ssrfCheck = await validateUrlForSSRF(url);
5939
6726
  if (!ssrfCheck.isValid) {
5940
6727
  return {
@@ -6718,7 +7505,7 @@ function registerRemotionTools(server) {
6718
7505
  },
6719
7506
  async ({ composition_id, output_format, props }) => {
6720
7507
  const userId = await getDefaultUserId();
6721
- const rateLimit = checkRateLimit("screenshot", `render_demo_video:${userId}`);
7508
+ const rateLimit = checkRateLimit("generation", `render_demo_video:${userId}`);
6722
7509
  if (!rateLimit.allowed) {
6723
7510
  return {
6724
7511
  content: [
@@ -6913,6 +7700,7 @@ function registerRemotionTools(server) {
6913
7700
 
6914
7701
  // src/tools/insights.ts
6915
7702
  import { z as z9 } from "zod";
7703
+ init_supabase();
6916
7704
  var MAX_INSIGHT_AGE_DAYS = 30;
6917
7705
  var PLATFORM_ENUM = [
6918
7706
  "youtube",
@@ -6937,22 +7725,25 @@ function asEnvelope6(data) {
6937
7725
  function registerInsightsTools(server) {
6938
7726
  server.tool(
6939
7727
  "get_performance_insights",
6940
- "Query performance insights derived from post analytics. Returns metrics like engagement rate, view velocity, and click rate aggregated over time. Use this to understand what content is performing well.",
7728
+ "Query project-scoped performance insights derived from post analytics. Returns metrics like engagement rate, view velocity, and click rate aggregated over time. Use this to understand what content is performing well.",
6941
7729
  {
6942
7730
  insight_type: z9.enum(["top_hooks", "optimal_timing", "best_models", "competitor_patterns"]).optional().describe("Filter to a specific insight type."),
6943
7731
  days: z9.number().min(1).max(90).optional().describe("Number of days to look back. Defaults to 30. Max 90."),
6944
7732
  limit: z9.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
7733
+ project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
6945
7734
  response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
6946
7735
  },
6947
- async ({ insight_type, days, limit, response_format }) => {
7736
+ async ({ insight_type, days, limit, project_id, response_format }) => {
6948
7737
  const format = response_format ?? "text";
6949
7738
  const lookbackDays = days ?? 30;
6950
7739
  const maxRows = limit ?? 10;
6951
7740
  const effectiveDays = Math.min(lookbackDays, MAX_INSIGHT_AGE_DAYS);
7741
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
6952
7742
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
6953
7743
  action: "performance-insights",
6954
7744
  days: effectiveDays,
6955
- limit: maxRows
7745
+ limit: maxRows,
7746
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
6956
7747
  });
6957
7748
  if (efError || !result?.success) {
6958
7749
  return {
@@ -7039,19 +7830,22 @@ function registerInsightsTools(server) {
7039
7830
  );
7040
7831
  server.tool(
7041
7832
  "get_best_posting_times",
7042
- "Analyze post analytics data to find the best times to post for maximum engagement. Returns the top 5 time slots (day of week + hour) ranked by average engagement.",
7833
+ "Analyze project-scoped post analytics data to find the best times to post for maximum engagement. Returns the top 5 time slots (day of week + hour) ranked by average engagement.",
7043
7834
  {
7044
7835
  platform: z9.enum(PLATFORM_ENUM).optional().describe("Filter to a specific platform."),
7045
7836
  days: z9.number().min(1).max(90).optional().describe("Number of days to analyze. Defaults to 30. Max 90."),
7837
+ project_id: z9.string().optional().describe("Project ID. Defaults to the active project context."),
7046
7838
  response_format: z9.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7047
7839
  },
7048
- async ({ platform: platform2, days, response_format }) => {
7840
+ async ({ platform: platform2, days, project_id, response_format }) => {
7049
7841
  const format = response_format ?? "text";
7050
7842
  const lookbackDays = days ?? 30;
7843
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
7051
7844
  const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
7052
7845
  action: "best-posting-times",
7053
7846
  days: lookbackDays,
7054
- platform: platform2 ?? void 0
7847
+ platform: platform2 ?? void 0,
7848
+ ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
7055
7849
  });
7056
7850
  if (efError || !result?.success) {
7057
7851
  return {
@@ -8144,7 +8938,7 @@ Schedule: ${JSON.stringify(updated.schedule_config)}`
8144
8938
  }
8145
8939
  const statusData = {
8146
8940
  activeConfigs: result?.activeConfigs ?? 0,
8147
- recentRuns: [],
8941
+ recentRuns: result?.recentRuns ?? result?.recent_runs ?? [],
8148
8942
  pendingApprovals: result?.pendingApprovals ?? 0
8149
8943
  };
8150
8944
  if (format === "json") {
@@ -8166,8 +8960,20 @@ ${"=".repeat(40)}
8166
8960
  text += `Pending Approvals: ${statusData.pendingApprovals}
8167
8961
 
8168
8962
  `;
8169
- text += `No recent runs.
8963
+ if (statusData.recentRuns.length === 0) {
8964
+ text += `No recent runs.
8170
8965
  `;
8966
+ } else {
8967
+ text += `Recent Runs (${statusData.recentRuns.length}):
8968
+ `;
8969
+ for (const run of statusData.recentRuns.slice(0, 10)) {
8970
+ const id = String(run.id ?? run.run_id ?? "unknown");
8971
+ const status = String(run.status ?? "unknown");
8972
+ const credits = run.credits_used ?? run.creditsUsed;
8973
+ text += ` ${id}: ${status}${credits == null ? "" : ` (${String(credits)} credits)`}
8974
+ `;
8975
+ }
8976
+ }
8171
8977
  return {
8172
8978
  content: [{ type: "text", text }]
8173
8979
  };
@@ -10348,7 +11154,9 @@ function toolKnowledgeDocument(tool) {
10348
11154
  function getKnowledgeDocuments() {
10349
11155
  return [
10350
11156
  ...STATIC_KNOWLEDGE_DOCUMENTS,
10351
- ...TOOL_CATALOG.filter((t) => !t.internal).map(toolKnowledgeDocument)
11157
+ ...TOOL_CATALOG.filter((t) => !t.internal && !t.hiddenFromPublicCount).map(
11158
+ toolKnowledgeDocument
11159
+ )
10352
11160
  ];
10353
11161
  }
10354
11162
  function tokenize(input) {
@@ -10465,7 +11273,7 @@ function registerDiscoveryTools(server) {
10465
11273
  if (query) {
10466
11274
  results = searchTools(query);
10467
11275
  }
10468
- results = results.filter((t) => !t.internal);
11276
+ results = results.filter((t) => !t.internal && !t.hiddenFromPublicCount);
10469
11277
  if (module) {
10470
11278
  const moduleTools = getToolsByModule(module);
10471
11279
  results = results.filter((t) => moduleTools.some((mt) => mt.name === t.name));
@@ -12287,11 +13095,11 @@ function hexToLab(hex) {
12287
13095
  const lb = srgbToLinear(b);
12288
13096
  const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
12289
13097
  const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
12290
- const z39 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
13098
+ const z41 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
12291
13099
  const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
12292
13100
  const fx = f(x / 0.95047);
12293
13101
  const fy = f(y / 1);
12294
- const fz = f(z39 / 1.08883);
13102
+ const fz = f(z41 / 1.08883);
12295
13103
  return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
12296
13104
  }
12297
13105
  function deltaE2000(lab1, lab2) {
@@ -12524,10 +13332,10 @@ function registerBrandRuntimeTools(server) {
12524
13332
  pagesScraped: meta.pagesScraped || 0
12525
13333
  }
12526
13334
  };
12527
- const envelope = asEnvelope23(runtime);
13335
+ const envelope2 = asEnvelope23(runtime);
12528
13336
  return {
12529
- structuredContent: envelope,
12530
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13337
+ structuredContent: envelope2,
13338
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12531
13339
  };
12532
13340
  }
12533
13341
  );
@@ -12667,9 +13475,9 @@ function registerBrandRuntimeTools(server) {
12667
13475
  }
12668
13476
  const profile = row.profile_data;
12669
13477
  const checkResult = computeBrandConsistency(content, profile);
12670
- const envelope = asEnvelope23(checkResult);
13478
+ const envelope2 = asEnvelope23(checkResult);
12671
13479
  return {
12672
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13480
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12673
13481
  };
12674
13482
  }
12675
13483
  );
@@ -12701,9 +13509,9 @@ function registerBrandRuntimeTools(server) {
12701
13509
  content_colors,
12702
13510
  threshold ?? 10
12703
13511
  );
12704
- const envelope = asEnvelope23(auditResult);
13512
+ const envelope2 = asEnvelope23(auditResult);
12705
13513
  return {
12706
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13514
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12707
13515
  };
12708
13516
  }
12709
13517
  );
@@ -12736,9 +13544,9 @@ function registerBrandRuntimeTools(server) {
12736
13544
  row.profile_data.typography,
12737
13545
  format
12738
13546
  );
12739
- const envelope = asEnvelope23({ format, tokens: output });
13547
+ const envelope2 = asEnvelope23({ format, tokens: output });
12740
13548
  return {
12741
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13549
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12742
13550
  };
12743
13551
  }
12744
13552
  );
@@ -12894,7 +13702,7 @@ function registerCarouselTools(server) {
12894
13702
  };
12895
13703
  }
12896
13704
  const userId = await getDefaultUserId();
12897
- const rateLimit = checkRateLimit("posting", `create_carousel:${userId}`);
13705
+ const rateLimit = checkRateLimit("generation", `create_carousel:${userId}`);
12898
13706
  if (!rateLimit.allowed) {
12899
13707
  return {
12900
13708
  content: [
@@ -12966,7 +13774,12 @@ function registerCarouselTools(server) {
12966
13774
  slideNumber: slide.slideNumber,
12967
13775
  jobId: null,
12968
13776
  model: image_model,
12969
- error: error ?? "No job ID returned"
13777
+ error: error ?? "No job ID returned",
13778
+ creditsReserved: data?.credits_reserved ?? null,
13779
+ creditsCharged: data?.credits_charged ?? null,
13780
+ creditsRefunded: data?.credits_refunded ?? null,
13781
+ billingStatus: data?.billing_status ?? "unknown",
13782
+ failureReason: data?.failure_reason ?? null
12970
13783
  };
12971
13784
  }
12972
13785
  const jobId = data.asyncJobId ?? data.taskId ?? null;
@@ -12978,14 +13791,24 @@ function registerCarouselTools(server) {
12978
13791
  slideNumber: slide.slideNumber,
12979
13792
  jobId,
12980
13793
  model: image_model,
12981
- error: null
13794
+ error: null,
13795
+ creditsReserved: 0,
13796
+ creditsCharged: data.creditsDeducted ?? perImageCost,
13797
+ creditsRefunded: 0,
13798
+ billingStatus: "charged",
13799
+ failureReason: null
12982
13800
  };
12983
13801
  } catch (err) {
12984
13802
  return {
12985
13803
  slideNumber: slide.slideNumber,
12986
13804
  jobId: null,
12987
13805
  model: image_model,
12988
- error: sanitizeError(err)
13806
+ error: sanitizeError(err),
13807
+ creditsReserved: null,
13808
+ creditsCharged: null,
13809
+ creditsRefunded: null,
13810
+ billingStatus: "unknown",
13811
+ failureReason: null
12989
13812
  };
12990
13813
  }
12991
13814
  })
@@ -13010,7 +13833,8 @@ function registerCarouselTools(server) {
13010
13833
  return {
13011
13834
  ...s,
13012
13835
  imageJobId: job?.jobId ?? null,
13013
- imageError: job?.error ?? null
13836
+ imageError: job?.error ?? null,
13837
+ imageBillingStatus: job?.billingStatus ?? "unknown"
13014
13838
  };
13015
13839
  }),
13016
13840
  imageModel: image_model,
@@ -13022,11 +13846,19 @@ function registerCarouselTools(server) {
13022
13846
  jobIds: successfulJobs.map((j) => j.jobId),
13023
13847
  failedSlides: failedJobs.map((j) => ({
13024
13848
  slideNumber: j.slideNumber,
13025
- error: j.error
13849
+ error: j.error,
13850
+ credits_reserved: j.creditsReserved,
13851
+ credits_charged: j.creditsCharged,
13852
+ credits_refunded: j.creditsRefunded,
13853
+ billing_status: j.billingStatus,
13854
+ failure_reason: j.failureReason
13026
13855
  })),
13027
13856
  credits: {
13028
13857
  textGeneration: textCredits,
13029
13858
  imagesEstimated: successfulJobs.length * perImageCost,
13859
+ imagesCharged: imageJobs.reduce((sum, job) => sum + (job.creditsCharged ?? 0), 0),
13860
+ imagesRefunded: imageJobs.reduce((sum, job) => sum + (job.creditsRefunded ?? 0), 0),
13861
+ billingUnknownSlides: imageJobs.filter((job) => job.billingStatus === "unknown").length,
13030
13862
  totalEstimated: textCredits + successfulJobs.length * perImageCost
13031
13863
  }
13032
13864
  }
@@ -13297,7 +14129,7 @@ function registerHyperframesTools(server) {
13297
14129
  );
13298
14130
  server.tool(
13299
14131
  "render_hyperframes",
13300
- "Render an HTML video composition (Hyperframes) to MP4. 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.",
14132
+ "Render an HTML video composition (Hyperframes) to MP4 \u2014 frame-accurate, no React build step. The page MUST expose window.__hf = { duration: <seconds>, seek: (t) => void }; the renderer calls seek(t) per frame (GSAP timelines work when driven from seek). Missing window.__hf causes a terminal timeout; poll check_status and verify its reported billing/refund outcome. Use list_hyperframes_blocks for the pre-built block catalog. Pass project_id to keep the render with the correct brand/project. Returns a job ID \u2014 poll with check_status.",
13301
14133
  {
13302
14134
  composition_html: z30.string().max(5e5).optional().describe(
13303
14135
  "Inline HTML composition (full <html>...</html>). Max 500KB. Use composition_url for larger."
@@ -13309,7 +14141,9 @@ function registerHyperframesTools(server) {
13309
14141
  aspect_ratio: z30.enum(["9:16", "16:9", "1:1"]).optional().describe('Output aspect ratio. Default "9:16".'),
13310
14142
  duration_sec: z30.number().min(0.1).max(600).describe("Output duration in seconds. Required. Max 600 (10 min)."),
13311
14143
  fps: z30.union([z30.literal(24), z30.literal(30), z30.literal(60)]).optional().describe("Frames per second. Default 30."),
13312
- quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master.")
14144
+ quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master."),
14145
+ project_id: z30.string().optional().describe("Project ID to associate the Hyperframes render with."),
14146
+ response_format: z30.enum(["text", "json"]).optional().describe("Response format. Use json for a stable job_id handoff.")
13313
14147
  },
13314
14148
  async ({
13315
14149
  composition_html,
@@ -13318,7 +14152,9 @@ function registerHyperframesTools(server) {
13318
14152
  aspect_ratio,
13319
14153
  duration_sec,
13320
14154
  fps,
13321
- quality
14155
+ quality,
14156
+ project_id,
14157
+ response_format
13322
14158
  }) => {
13323
14159
  const userId = await getDefaultUserId();
13324
14160
  const rateLimit = checkRateLimit("generation", `render_hyperframes:${userId}`);
@@ -13363,23 +14199,36 @@ function registerHyperframesTools(server) {
13363
14199
  aspectRatio: aspect_ratio || "9:16",
13364
14200
  durationSec: duration_sec,
13365
14201
  fps: fps || 30,
13366
- quality: quality || "standard"
14202
+ quality: quality || "standard",
14203
+ ...project_id && { projectId: project_id }
13367
14204
  });
13368
14205
  if (error || !data?.jobId) {
13369
14206
  throw new Error(error || data?.error || "Failed to create Hyperframes render job");
13370
14207
  }
14208
+ const payload = {
14209
+ job_id: data.jobId,
14210
+ jobId: data.jobId,
14211
+ status: data.status,
14212
+ credits_cost: data.creditsCost,
14213
+ credits: data.creditsCost,
14214
+ duration_sec,
14215
+ fps: fps || 30,
14216
+ aspect_ratio: aspect_ratio || "9:16",
14217
+ quality: quality || "standard",
14218
+ project_id: project_id || null
14219
+ };
13371
14220
  return {
13372
14221
  content: [
13373
14222
  {
13374
14223
  type: "text",
13375
- text: [
14224
+ text: response_format === "json" ? JSON.stringify({ data: payload }) : [
13376
14225
  `Hyperframes render job queued.`,
13377
14226
  ` Job ID: ${data.jobId}`,
13378
14227
  ` Credits: ${data.creditsCost}`,
13379
14228
  ` Duration: ${duration_sec}s @ ${fps || 30}fps (${aspect_ratio || "9:16"})`,
13380
14229
  ` Quality: ${quality || "standard"}`,
13381
14230
  ``,
13382
- `Poll with check_status. Note: until F4 Phase 2 wires the runtime, the job will fail with a clear "HyperframesNotInstalled" message.`
14231
+ `Poll with check_status.`
13383
14232
  ].join("\n")
13384
14233
  }
13385
14234
  ]
@@ -13389,7 +14238,7 @@ function registerHyperframesTools(server) {
13389
14238
  content: [
13390
14239
  {
13391
14240
  type: "text",
13392
- text: `Failed to queue Hyperframes render: ${err instanceof Error ? err.message : "Unknown error"}`
14241
+ text: `Failed to queue Hyperframes render: ${sanitizeError(err)}`
13393
14242
  }
13394
14243
  ],
13395
14244
  isError: true
@@ -13408,7 +14257,17 @@ import {
13408
14257
  import { z as z31 } from "zod";
13409
14258
  import fs from "node:fs/promises";
13410
14259
  import path from "node:path";
13411
- var CALENDAR_URI = "ui://content-calendar/mcp-app.html";
14260
+ import { fileURLToPath } from "node:url";
14261
+ init_request_context();
14262
+ init_supabase();
14263
+ var CALENDAR_URI = "ui://content-calendar/v1/mcp-app.html";
14264
+ var CALENDAR_CSP = {
14265
+ // The HTML is fully self-contained. Tool calls travel over the host bridge,
14266
+ // not fetch/XHR, so the secure default is no network or remote resources.
14267
+ connectDomains: [],
14268
+ resourceDomains: [],
14269
+ frameDomains: []
14270
+ };
13412
14271
  var RecentPostOutputSchema = z31.object({
13413
14272
  id: z31.string(),
13414
14273
  platform: z31.string(),
@@ -13421,47 +14280,119 @@ var RecentPostOutputSchema = z31.object({
13421
14280
  });
13422
14281
  function startOfCurrentWeekMonday() {
13423
14282
  const now = /* @__PURE__ */ new Date();
13424
- const day = now.getDay();
14283
+ const day = now.getUTCDay();
13425
14284
  const monday = new Date(now);
13426
- monday.setUTCDate(now.getUTCDate() - day + (day === 0 ? -6 : 1));
14285
+ monday.setUTCDate(now.getUTCDate() - (day + 6) % 7);
14286
+ monday.setUTCHours(0, 0, 0, 0);
13427
14287
  return monday.toISOString().split("T")[0];
13428
14288
  }
14289
+ function endOfWeek(startDate) {
14290
+ const end = /* @__PURE__ */ new Date(`${startDate}T00:00:00.000Z`);
14291
+ end.setUTCDate(end.getUTCDate() + 7);
14292
+ end.setUTCMilliseconds(end.getUTCMilliseconds() - 1);
14293
+ return end.toISOString();
14294
+ }
14295
+ function isStrictIsoDate(value) {
14296
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
14297
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
14298
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
14299
+ }
14300
+ function publicRecentPost(value) {
14301
+ if (!value || typeof value !== "object") return null;
14302
+ const row = value;
14303
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
14304
+ return null;
14305
+ }
14306
+ const optional = (name) => typeof row[name] === "string" ? row[name] : null;
14307
+ return {
14308
+ id: row.id,
14309
+ platform: row.platform,
14310
+ status: row.status,
14311
+ title: optional("title"),
14312
+ external_post_id: optional("external_post_id"),
14313
+ published_at: optional("published_at"),
14314
+ scheduled_at: optional("scheduled_at"),
14315
+ created_at: row.created_at
14316
+ };
14317
+ }
14318
+ function calendarHtmlCandidates() {
14319
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
14320
+ return [
14321
+ // Bundled HTTP entry: import.meta.url is dist/http.js.
14322
+ path.join(moduleDir, "apps/content-calendar/mcp-app.html"),
14323
+ // Source/tsx/vitest: import.meta.url is src/apps/content-calendar.ts.
14324
+ path.resolve(moduleDir, "../../dist/apps/content-calendar/mcp-app.html"),
14325
+ // Local development fallback; never the sole package path.
14326
+ path.resolve(process.cwd(), "dist/apps/content-calendar/mcp-app.html")
14327
+ ];
14328
+ }
14329
+ async function readCalendarHtml() {
14330
+ for (const candidate of calendarHtmlCandidates()) {
14331
+ try {
14332
+ return await fs.readFile(candidate, "utf-8");
14333
+ } catch (error) {
14334
+ if (error.code !== "ENOENT") throw error;
14335
+ }
14336
+ }
14337
+ throw new Error("calendar_bundle_missing");
14338
+ }
13429
14339
  function registerContentCalendarApp(server) {
13430
14340
  registerAppTool(
13431
14341
  server,
13432
14342
  "open_content_calendar",
13433
14343
  {
13434
14344
  title: "Content Calendar",
13435
- description: "Open 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.",
14345
+ description: "Open a project-scoped interactive calendar for the current week. Users can filter, inspect, quick-create, suggest a slot, and reschedule pending posts with optimistic conflict protection.",
13436
14346
  inputSchema: {
13437
- start_date: z31.string().optional().describe(
14347
+ project_id: z31.string().uuid().optional().describe(
14348
+ "Brand/project ID. Defaults to the authenticated key's project or the account default."
14349
+ ),
14350
+ start_date: z31.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe(
13438
14351
  "ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday."
13439
14352
  )
13440
14353
  },
13441
14354
  outputSchema: {
13442
14355
  start_date: z31.string(),
14356
+ project_id: z31.string(),
13443
14357
  posts: z31.array(RecentPostOutputSchema),
13444
14358
  scopes: z31.array(z31.string())
13445
14359
  },
13446
14360
  _meta: {
13447
14361
  ui: {
13448
- resourceUri: CALENDAR_URI,
13449
- csp: {
13450
- "img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
13451
- "connect-src": ["'self'"]
13452
- }
14362
+ resourceUri: CALENDAR_URI
13453
14363
  }
13454
14364
  }
13455
14365
  },
13456
- async ({ start_date }, extra) => {
13457
- const userScopes = extra.authInfo?.scopes ?? [];
14366
+ async ({ project_id, start_date }) => {
14367
+ const userScopes = getRequestScopes() ?? getAuthenticatedScopes();
14368
+ const resolvedProjectId = project_id ?? await getDefaultProjectId();
14369
+ if (!resolvedProjectId) {
14370
+ return {
14371
+ content: [
14372
+ {
14373
+ type: "text",
14374
+ text: "No project_id was provided and no default project is configured."
14375
+ }
14376
+ ],
14377
+ isError: true
14378
+ };
14379
+ }
13458
14380
  const fromDate = start_date ?? startOfCurrentWeekMonday();
14381
+ if (!isStrictIsoDate(fromDate)) {
14382
+ return {
14383
+ content: [{ type: "text", text: "start_date must be a valid YYYY-MM-DD date." }],
14384
+ isError: true
14385
+ };
14386
+ }
13459
14387
  const { data: result, error } = await callEdgeFunction(
13460
14388
  "mcp-data",
13461
14389
  {
13462
- action: "recent-posts",
13463
- days: 14,
13464
- limit: 50
14390
+ action: "scheduled-posts",
14391
+ start_date: `${fromDate}T00:00:00.000Z`,
14392
+ end_date: endOfWeek(fromDate),
14393
+ statuses: ["pending", "scheduled", "draft"],
14394
+ projectId: resolvedProjectId,
14395
+ project_id: resolvedProjectId
13465
14396
  },
13466
14397
  { timeoutMs: 15e3 }
13467
14398
  );
@@ -13470,19 +14401,16 @@ function registerContentCalendarApp(server) {
13470
14401
  content: [
13471
14402
  {
13472
14403
  type: "text",
13473
- text: `Failed to load posts: ${error || result?.error || "Unknown error"}`
14404
+ text: "The content calendar could not load posts. Please retry."
13474
14405
  }
13475
14406
  ],
13476
14407
  isError: true
13477
14408
  };
13478
14409
  }
13479
- const posts = (result.posts ?? []).filter((p) => {
13480
- const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
13481
- if (!ts) return false;
13482
- return ts.split("T")[0] >= fromDate;
13483
- });
14410
+ const posts = (result.posts ?? []).map(publicRecentPost).filter((post) => post !== null);
13484
14411
  const structuredContent = {
13485
14412
  start_date: fromDate,
14413
+ project_id: resolvedProjectId,
13486
14414
  posts,
13487
14415
  scopes: userScopes
13488
14416
  };
@@ -13501,36 +14429,64 @@ function registerContentCalendarApp(server) {
13501
14429
  server,
13502
14430
  CALENDAR_URI,
13503
14431
  CALENDAR_URI,
13504
- { mimeType: RESOURCE_MIME_TYPE },
14432
+ {
14433
+ mimeType: RESOURCE_MIME_TYPE,
14434
+ description: "Self-contained Social Neuron project content calendar.",
14435
+ _meta: { ui: { csp: CALENDAR_CSP } }
14436
+ },
13505
14437
  async () => {
13506
- const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
13507
14438
  try {
13508
- const html = await fs.readFile(htmlPath, "utf-8");
14439
+ const html = await readCalendarHtml();
13509
14440
  return {
13510
- contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: html }]
14441
+ contents: [
14442
+ {
14443
+ uri: CALENDAR_URI,
14444
+ mimeType: RESOURCE_MIME_TYPE,
14445
+ text: html,
14446
+ _meta: { ui: { csp: CALENDAR_CSP } }
14447
+ }
14448
+ ]
13511
14449
  };
13512
- } catch (err) {
14450
+ } catch {
13513
14451
  const errorHtml = `<!DOCTYPE html>
13514
14452
  <html><head><title>Content Calendar \u2014 unavailable</title></head>
13515
14453
  <body style="font-family:sans-serif;padding:24px;color:#444;">
13516
14454
  <h2>Content Calendar app bundle missing</h2>
13517
- <p>The server registered <code>open_content_calendar</code> but
13518
- <code>apps/content-calendar/dist/mcp-app.html</code> is not built.
13519
- Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
13520
- <p style="color:#999;font-size:12px;">${err.message}</p>
14455
+ <p>The interactive bundle is unavailable on this deployment. Please contact Social Neuron support.</p>
13521
14456
  </body></html>`;
13522
14457
  return {
13523
- contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }]
14458
+ contents: [
14459
+ {
14460
+ uri: CALENDAR_URI,
14461
+ mimeType: RESOURCE_MIME_TYPE,
14462
+ text: errorHtml,
14463
+ _meta: { ui: { csp: CALENDAR_CSP } }
14464
+ }
14465
+ ]
13524
14466
  };
13525
14467
  }
13526
14468
  }
13527
14469
  );
13528
14470
  }
13529
14471
 
13530
- // src/tools/connections.ts
14472
+ // src/apps/analytics-pulse.ts
14473
+ import {
14474
+ registerAppResource as registerAppResource2,
14475
+ registerAppTool as registerAppTool2,
14476
+ RESOURCE_MIME_TYPE as RESOURCE_MIME_TYPE2
14477
+ } from "@modelcontextprotocol/ext-apps/server";
13531
14478
  import { z as z32 } from "zod";
14479
+ import fs2 from "node:fs/promises";
14480
+ import path2 from "node:path";
14481
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
13532
14482
  init_supabase();
13533
- var PLATFORM_ENUM4 = [
14483
+ var ANALYTICS_URI = "ui://analytics-pulse/v1/mcp-app.html";
14484
+ var ANALYTICS_CSP = {
14485
+ connectDomains: [],
14486
+ resourceDomains: [],
14487
+ frameDomains: []
14488
+ };
14489
+ var PLATFORM_VALUES = [
13534
14490
  "youtube",
13535
14491
  "tiktok",
13536
14492
  "instagram",
@@ -13538,34 +14494,262 @@ var PLATFORM_ENUM4 = [
13538
14494
  "linkedin",
13539
14495
  "facebook",
13540
14496
  "threads",
13541
- "bluesky",
13542
- "shopify",
13543
- "etsy"
14497
+ "bluesky"
13544
14498
  ];
13545
- function findActiveAccount(accounts, platform2) {
13546
- const target = platform2.toLowerCase();
13547
- return accounts.find((a) => {
13548
- const effectiveStatus = a.effective_status || a.status;
13549
- return a.platform.toLowerCase() === target && (effectiveStatus === "active" || effectiveStatus === "expires_soon");
13550
- }) ?? null;
14499
+ var AnalyticsPostOutputSchema = z32.object({
14500
+ platform: z32.string(),
14501
+ title: z32.string().nullable(),
14502
+ views: z32.number(),
14503
+ likes: z32.number(),
14504
+ comments: z32.number(),
14505
+ shares: z32.number(),
14506
+ engagement_rate: z32.number(),
14507
+ captured_at: z32.string(),
14508
+ published_at: z32.string().nullable()
14509
+ });
14510
+ function finiteMetric(value) {
14511
+ const parsed = Number(value ?? 0);
14512
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
14513
+ }
14514
+ function publicAnalyticsPost(value) {
14515
+ if (!value || typeof value !== "object") return null;
14516
+ const row = value;
14517
+ const post = row.posts && typeof row.posts === "object" ? row.posts : {};
14518
+ const platform2 = typeof row.platform === "string" ? row.platform : typeof post.platform === "string" ? post.platform : null;
14519
+ const capturedAt = typeof row.captured_at === "string" ? row.captured_at : null;
14520
+ if (!platform2 || !capturedAt) return null;
14521
+ const views = finiteMetric(row.views);
14522
+ const likes = finiteMetric(row.likes);
14523
+ const comments = finiteMetric(row.comments);
14524
+ const shares = finiteMetric(row.shares);
14525
+ const engagement = likes + comments + shares;
14526
+ return {
14527
+ platform: platform2,
14528
+ title: typeof post.title === "string" ? post.title : null,
14529
+ views,
14530
+ likes,
14531
+ comments,
14532
+ shares,
14533
+ engagement_rate: views > 0 ? Number((engagement / views * 100).toFixed(2)) : 0,
14534
+ captured_at: capturedAt,
14535
+ published_at: typeof post.published_at === "string" ? post.published_at : null
14536
+ };
13551
14537
  }
13552
- var MAX_CONCURRENT_WAITS_PER_USER = 3;
13553
- var inFlightWaitsByUser = /* @__PURE__ */ new Map();
13554
- function registerConnectionTools(server) {
13555
- server.tool(
14538
+ function latestAnalyticsRows(values) {
14539
+ const rows = values.filter((value) => Boolean(value) && typeof value === "object").sort((a, b) => String(b.captured_at ?? "").localeCompare(String(a.captured_at ?? "")));
14540
+ const seen = /* @__PURE__ */ new Set();
14541
+ const latest = [];
14542
+ for (const row of rows) {
14543
+ if (typeof row.post_id !== "string" || typeof row.platform !== "string") continue;
14544
+ const key = `${row.post_id}:${row.platform.toLowerCase()}`;
14545
+ if (seen.has(key)) continue;
14546
+ seen.add(key);
14547
+ latest.push(row);
14548
+ }
14549
+ return latest;
14550
+ }
14551
+ function analyticsHtmlCandidates() {
14552
+ const moduleDir = path2.dirname(fileURLToPath2(import.meta.url));
14553
+ return [
14554
+ path2.join(moduleDir, "apps/analytics-pulse/mcp-app.html"),
14555
+ path2.resolve(moduleDir, "../../dist/apps/analytics-pulse/mcp-app.html"),
14556
+ path2.resolve(process.cwd(), "dist/apps/analytics-pulse/mcp-app.html")
14557
+ ];
14558
+ }
14559
+ async function readAnalyticsHtml() {
14560
+ for (const candidate of analyticsHtmlCandidates()) {
14561
+ try {
14562
+ return await fs2.readFile(candidate, "utf-8");
14563
+ } catch (error) {
14564
+ if (error.code !== "ENOENT") throw error;
14565
+ }
14566
+ }
14567
+ throw new Error("analytics_bundle_missing");
14568
+ }
14569
+ function registerAnalyticsPulseApp(server) {
14570
+ registerAppTool2(
14571
+ server,
14572
+ "open_analytics_pulse",
14573
+ {
14574
+ title: "Analytics Pulse",
14575
+ description: "Open a project-scoped performance dashboard with views, engagement, platform mix, and top-post metrics. Use it to review results visually before planning the next content cycle.",
14576
+ inputSchema: {
14577
+ project_id: z32.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default."),
14578
+ platform: z32.enum(PLATFORM_VALUES).optional().describe("Optional platform filter."),
14579
+ days: z32.number().int().min(1).max(365).optional().describe("Lookback window. Defaults to 30 days.")
14580
+ },
14581
+ outputSchema: {
14582
+ project_id: z32.string(),
14583
+ platform: z32.string().nullable(),
14584
+ days: z32.number(),
14585
+ summary: z32.object({
14586
+ views: z32.number(),
14587
+ engagement: z32.number(),
14588
+ engagement_rate: z32.number(),
14589
+ posts: z32.number()
14590
+ }),
14591
+ platform_totals: z32.array(
14592
+ z32.object({
14593
+ platform: z32.string(),
14594
+ views: z32.number(),
14595
+ engagement: z32.number(),
14596
+ posts: z32.number()
14597
+ })
14598
+ ),
14599
+ posts: z32.array(AnalyticsPostOutputSchema)
14600
+ },
14601
+ _meta: { ui: { resourceUri: ANALYTICS_URI } }
14602
+ },
14603
+ async ({ project_id, platform: platform2, days }) => {
14604
+ const resolvedProjectId = project_id ?? await getDefaultProjectId();
14605
+ if (!resolvedProjectId) {
14606
+ return {
14607
+ content: [{ type: "text", text: "No project_id was provided and no default project is configured." }],
14608
+ isError: true
14609
+ };
14610
+ }
14611
+ const lookbackDays = days ?? 30;
14612
+ const { data: result, error } = await callEdgeFunction(
14613
+ "mcp-data",
14614
+ {
14615
+ action: "analytics",
14616
+ projectId: resolvedProjectId,
14617
+ project_id: resolvedProjectId,
14618
+ days: lookbackDays,
14619
+ // The table stores cumulative snapshots. Request a wider window and
14620
+ // deduplicate below so refresh frequency cannot inflate the totals.
14621
+ limit: 100,
14622
+ latestOnly: true,
14623
+ ...platform2 ? { platform: platform2 } : {}
14624
+ },
14625
+ { timeoutMs: 15e3 }
14626
+ );
14627
+ if (error || !result?.success) {
14628
+ return {
14629
+ content: [{ type: "text", text: "The analytics dashboard could not load data. Please retry." }],
14630
+ isError: true
14631
+ };
14632
+ }
14633
+ const posts = latestAnalyticsRows(result.rows ?? []).map(publicAnalyticsPost).filter((post) => post !== null).sort((a, b) => b.views - a.views);
14634
+ const totals = /* @__PURE__ */ new Map();
14635
+ let totalViews = 0;
14636
+ let totalEngagement = 0;
14637
+ for (const post of posts) {
14638
+ const engagement = post.likes + post.comments + post.shares;
14639
+ totalViews += post.views;
14640
+ totalEngagement += engagement;
14641
+ const aggregate = totals.get(post.platform) ?? {
14642
+ platform: post.platform,
14643
+ views: 0,
14644
+ engagement: 0,
14645
+ posts: 0
14646
+ };
14647
+ aggregate.views += post.views;
14648
+ aggregate.engagement += engagement;
14649
+ aggregate.posts += 1;
14650
+ totals.set(post.platform, aggregate);
14651
+ }
14652
+ const structuredContent = {
14653
+ project_id: resolvedProjectId,
14654
+ platform: platform2 ?? null,
14655
+ days: lookbackDays,
14656
+ summary: {
14657
+ views: totalViews,
14658
+ engagement: totalEngagement,
14659
+ engagement_rate: totalViews > 0 ? Number((totalEngagement / totalViews * 100).toFixed(2)) : 0,
14660
+ posts: posts.length
14661
+ },
14662
+ platform_totals: [...totals.values()].sort((a, b) => b.views - a.views),
14663
+ posts
14664
+ };
14665
+ return {
14666
+ structuredContent,
14667
+ content: [
14668
+ {
14669
+ type: "text",
14670
+ text: `Loaded ${posts.length} analytics record${posts.length === 1 ? "" : "s"} for the last ${lookbackDays} days.`
14671
+ }
14672
+ ]
14673
+ };
14674
+ }
14675
+ );
14676
+ registerAppResource2(
14677
+ server,
14678
+ ANALYTICS_URI,
14679
+ ANALYTICS_URI,
14680
+ {
14681
+ mimeType: RESOURCE_MIME_TYPE2,
14682
+ description: "Self-contained Social Neuron project analytics dashboard.",
14683
+ _meta: { ui: { csp: ANALYTICS_CSP } }
14684
+ },
14685
+ async () => {
14686
+ try {
14687
+ const html = await readAnalyticsHtml();
14688
+ return {
14689
+ contents: [
14690
+ {
14691
+ uri: ANALYTICS_URI,
14692
+ mimeType: RESOURCE_MIME_TYPE2,
14693
+ text: html,
14694
+ _meta: { ui: { csp: ANALYTICS_CSP } }
14695
+ }
14696
+ ]
14697
+ };
14698
+ } catch {
14699
+ return {
14700
+ contents: [
14701
+ {
14702
+ uri: ANALYTICS_URI,
14703
+ mimeType: RESOURCE_MIME_TYPE2,
14704
+ text: "<!doctype html><html><body><h2>Analytics Pulse unavailable</h2><p>The interactive bundle is unavailable on this deployment. Please contact Social Neuron support.</p></body></html>",
14705
+ _meta: { ui: { csp: ANALYTICS_CSP } }
14706
+ }
14707
+ ]
14708
+ };
14709
+ }
14710
+ }
14711
+ );
14712
+ }
14713
+
14714
+ // src/tools/connections.ts
14715
+ import { z as z33 } from "zod";
14716
+ init_supabase();
14717
+ var PLATFORM_ENUM4 = [
14718
+ "youtube",
14719
+ "tiktok",
14720
+ "instagram",
14721
+ "twitter",
14722
+ "linkedin",
14723
+ "facebook",
14724
+ "threads",
14725
+ "bluesky",
14726
+ "shopify",
14727
+ "etsy"
14728
+ ];
14729
+ function findActiveAccount(accounts, platform2) {
14730
+ const target = platform2.toLowerCase();
14731
+ return accounts.find((a) => {
14732
+ const effectiveStatus = a.effective_status || a.status;
14733
+ return a.platform.toLowerCase() === target && (effectiveStatus === "active" || effectiveStatus === "expires_soon");
14734
+ }) ?? null;
14735
+ }
14736
+ var MAX_CONCURRENT_WAITS_PER_USER = 3;
14737
+ var inFlightWaitsByUser = /* @__PURE__ */ new Map();
14738
+ function registerConnectionTools(server) {
14739
+ server.tool(
13556
14740
  "start_platform_connection",
13557
14741
  "Begin connecting a social platform (Instagram, TikTok, YouTube, etc.). Returns a single-use deep link the user opens in a browser to complete the one-time OAuth handshake on socialneuron.com. This is NOT another OAuth in Claude \u2014 platform connections require a browser session because the social platforms (Meta, Google, TikTok) only accept callbacks on socialneuron.com. After the user clicks the link and approves on the platform, call `wait_for_connection` to detect completion. Link expires in 2 minutes; mint a new one if needed. Use `list_connected_accounts` first to check whether the platform is already connected before calling this.",
13558
14742
  {
13559
- platform: z32.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
13560
- project_id: z32.string().optional().describe(
14743
+ platform: z33.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
14744
+ project_id: z33.string().optional().describe(
13561
14745
  "Brand/project ID to bind the new social account to. Use this when connecting a brand-specific account."
13562
14746
  ),
13563
- response_format: z32.enum(["text", "json"]).optional().describe("Response format. Default: text.")
14747
+ response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
13564
14748
  },
13565
14749
  async ({ platform: platform2, project_id, response_format }) => {
13566
14750
  const format = response_format ?? "text";
13567
14751
  const userId = await getDefaultUserId();
13568
- const rl = checkRateLimit("read", `start_platform_connection:${userId}`);
14752
+ const rl = checkRateLimit("posting", `start_platform_connection:${userId}`);
13569
14753
  if (!rl.allowed) {
13570
14754
  return {
13571
14755
  content: [
@@ -13645,13 +14829,13 @@ function registerConnectionTools(server) {
13645
14829
  "wait_for_connection",
13646
14830
  "Poll until a platform connection becomes active. Use after `start_platform_connection` while the user completes the browser OAuth flow. Returns when the account row appears with status=active, or when the timeout elapses. Default timeout 120s, max 600s.",
13647
14831
  {
13648
- platform: z32.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
13649
- project_id: z32.string().optional().describe(
14832
+ platform: z33.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
14833
+ project_id: z33.string().optional().describe(
13650
14834
  "Brand/project ID to scope the connection poll. Use the same project_id passed to start_platform_connection."
13651
14835
  ),
13652
- timeout_s: z32.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
13653
- poll_interval_s: z32.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
13654
- response_format: z32.enum(["text", "json"]).optional().describe("Response format. Default: text.")
14836
+ timeout_s: z33.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
14837
+ poll_interval_s: z33.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
14838
+ response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
13655
14839
  },
13656
14840
  async ({ platform: platform2, project_id, timeout_s, poll_interval_s, response_format }) => {
13657
14841
  const format = response_format ?? "text";
@@ -13784,7 +14968,7 @@ function registerConnectionTools(server) {
13784
14968
  }
13785
14969
 
13786
14970
  // src/tools/harness.ts
13787
- import { z as z33 } from "zod";
14971
+ import { z as z34 } from "zod";
13788
14972
  var ALLOWED_AGENTS = [
13789
14973
  "conductor",
13790
14974
  "brand-brain",
@@ -13794,38 +14978,38 @@ var ALLOWED_AGENTS = [
13794
14978
  "engager"
13795
14979
  ];
13796
14980
  var writeReflectionSchema = {
13797
- reflection_text: z33.string().min(1).max(4e3).describe("The verbal reflection text produced by the agent. 1\u20134000 characters."),
13798
- generated_by_agent: z33.enum(ALLOWED_AGENTS).describe(
14981
+ reflection_text: z34.string().min(1).max(4e3).describe("The verbal reflection text produced by the agent. 1\u20134000 characters."),
14982
+ generated_by_agent: z34.enum(ALLOWED_AGENTS).describe(
13799
14983
  "Which agent produced this reflection. Must be one of: conductor, brand-brain, drafter, publisher, analyst, engager."
13800
14984
  ),
13801
- provenance: z33.object({
13802
- content_history_id: z33.string().optional().describe("Related content_history row UUID."),
13803
- outcome_event_id: z33.string().optional().describe("Related outcome event UUID."),
13804
- prm_score_ids: z33.array(z33.string()).optional().describe("PRM score IDs that informed this reflection."),
13805
- handoff_ids: z33.array(z33.string()).optional().describe("Handoff event IDs that triggered this reflection.")
14985
+ provenance: z34.object({
14986
+ content_history_id: z34.string().optional().describe("Related content_history row UUID."),
14987
+ outcome_event_id: z34.string().optional().describe("Related outcome event UUID."),
14988
+ prm_score_ids: z34.array(z34.string()).optional().describe("PRM score IDs that informed this reflection."),
14989
+ handoff_ids: z34.array(z34.string()).optional().describe("Handoff event IDs that triggered this reflection.")
13806
14990
  }).strict().describe(
13807
14991
  "Source evidence for this reflection. Only these four keys are accepted \u2014 unknown keys are rejected to prevent spurious provenance claims."
13808
14992
  ),
13809
- brand_id: z33.string().describe("Brand profile UUID this reflection belongs to."),
13810
- pipeline_id: z33.string().optional().describe("Optional: pipeline run UUID."),
13811
- post_id: z33.string().optional().describe("Optional: post UUID if reflection targets a post.")
14993
+ brand_id: z34.string().describe("Brand profile UUID this reflection belongs to."),
14994
+ pipeline_id: z34.string().optional().describe("Optional: pipeline run UUID."),
14995
+ post_id: z34.string().optional().describe("Optional: post UUID if reflection targets a post.")
13812
14996
  };
13813
14997
  var readReflectionSchema = {
13814
- brand_id: z33.string().describe("Brand profile UUID to read reflections for."),
13815
- generated_by_agent: z33.enum(ALLOWED_AGENTS).optional().describe(
14998
+ brand_id: z34.string().describe("Brand profile UUID to read reflections for."),
14999
+ generated_by_agent: z34.enum(ALLOWED_AGENTS).optional().describe(
13816
15000
  "Optional filter: only return reflections produced by this agent. One of: conductor, brand-brain, drafter, publisher, analyst, engager."
13817
15001
  ),
13818
- limit: z33.number().int().min(1).max(100).optional().describe(
15002
+ limit: z34.number().int().min(1).max(100).optional().describe(
13819
15003
  "Maximum number of reflections to return. Clamped to [1, 100]. Default: 50. Results are ordered by created_at DESC, then id ASC (deterministic tiebreak)."
13820
15004
  )
13821
15005
  };
13822
15006
  var recordOutcomeSchema = {
13823
- decision_event_id: z33.string().describe("UUID of the decision_events row to record an outcome for."),
13824
- horizon: z33.enum(["1h", "6h", "24h"]).describe(
15007
+ decision_event_id: z34.string().describe("UUID of the decision_events row to record an outcome for."),
15008
+ horizon: z34.enum(["1h", "6h", "24h"]).describe(
13825
15009
  "Observation horizon. Only horizon=24h with a non-null reward triggers a learning-loop update; 1h/6h are stored but inert for learning."
13826
15010
  ),
13827
- reward: z33.number().min(0).max(1).describe("Normalised reward signal in [0, 1]. Higher = better outcome."),
13828
- outcome_metrics: z33.record(z33.string(), z33.number()).optional().describe(
15011
+ reward: z34.number().min(0).max(1).describe("Normalised reward signal in [0, 1]. Higher = better outcome."),
15012
+ outcome_metrics: z34.record(z34.string(), z34.number()).optional().describe(
13829
15013
  'Optional map of raw metric name \u2192 value (e.g. {"likes": 42, "reach": 1200}). Stored verbatim; not used for learning directly.'
13830
15014
  )
13831
15015
  };
@@ -13921,7 +15105,7 @@ function registerHarnessTools(server, _ctx) {
13921
15105
  }
13922
15106
 
13923
15107
  // src/tools/hermes.ts
13924
- import { z as z34 } from "zod";
15108
+ import { z as z35 } from "zod";
13925
15109
  function asEnvelope25(data) {
13926
15110
  return {
13927
15111
  _meta: {
@@ -13931,7 +15115,7 @@ function asEnvelope25(data) {
13931
15115
  data
13932
15116
  };
13933
15117
  }
13934
- var PLATFORM = z34.enum([
15118
+ var PLATFORM = z35.enum([
13935
15119
  "instagram",
13936
15120
  "twitter",
13937
15121
  "linkedin",
@@ -13947,12 +15131,12 @@ function registerHermesTools(server) {
13947
15131
  "Save a draft post to the SN content library. Use when an autonomous agent wants to persist a draft for review before publishing. Lands in the content library with status='draft'. The draft can then be approved/edited in the SN UI.",
13948
15132
  {
13949
15133
  platform: PLATFORM.describe("Target platform for the draft."),
13950
- copy: z34.string().min(1).max(8e3).describe("The draft post body."),
13951
- project_id: z34.string().optional().describe("SN project UUID. Optional but recommended."),
13952
- media_url: z34.string().url().optional().describe("Optional cover/media URL (R2 signed URL)."),
13953
- hermes_run_id: z34.string().optional().describe("Agent run id, for traceability."),
13954
- source_intel_ids: z34.array(z34.string()).optional().describe("Optional list of intel_signals.id rows that informed this draft."),
13955
- response_format: z34.enum(["text", "json"]).optional()
15134
+ copy: z35.string().min(1).max(8e3).describe("The draft post body."),
15135
+ project_id: z35.string().optional().describe("SN project UUID. Optional but recommended."),
15136
+ media_url: z35.string().url().optional().describe("Optional cover/media URL (R2 signed URL)."),
15137
+ hermes_run_id: z35.string().optional().describe("Agent run id, for traceability."),
15138
+ source_intel_ids: z35.array(z35.string()).optional().describe("Optional list of intel_signals.id rows that informed this draft."),
15139
+ response_format: z35.enum(["text", "json"]).optional()
13956
15140
  },
13957
15141
  async ({ platform: platform2, copy, project_id, media_url, hermes_run_id, response_format }) => {
13958
15142
  const { data, error } = await callEdgeFunction(
@@ -13990,15 +15174,15 @@ function registerHermesTools(server) {
13990
15174
  "record_voice_lesson",
13991
15175
  'Persist a learned voice lesson to brand_profiles.platform_voice.voice_lessons. Use after weekly reflection identifies a hook/format/CTA pattern that beats median engagement by \u226530%. Appears in SN Brand > BrandBrainPreview > "Voice lessons (auto)".',
13992
15176
  {
13993
- project_id: z34.string().describe("SN project UUID."),
13994
- lesson: z34.string().min(1).max(500).describe('One-sentence rule (e.g. "lowercase IG hooks beat title-case").'),
13995
- evidence: z34.object({
13996
- engagement_lift_pct: z34.number().describe("Engagement lift vs baseline, in percentage points."),
13997
- sample_size: z34.number().int().min(1).describe("Number of posts behind this lesson."),
13998
- top_examples: z34.array(z34.string()).optional().describe("Up to 3 hook examples from the top quartile.")
15177
+ project_id: z35.string().describe("SN project UUID."),
15178
+ lesson: z35.string().min(1).max(500).describe('One-sentence rule (e.g. "lowercase IG hooks beat title-case").'),
15179
+ evidence: z35.object({
15180
+ engagement_lift_pct: z35.number().describe("Engagement lift vs baseline, in percentage points."),
15181
+ sample_size: z35.number().int().min(1).describe("Number of posts behind this lesson."),
15182
+ top_examples: z35.array(z35.string()).optional().describe("Up to 3 hook examples from the top quartile.")
13999
15183
  }).describe("Quantitative evidence backing the lesson."),
14000
- applies_to: z34.array(PLATFORM).min(1).describe("Platforms this lesson applies to."),
14001
- response_format: z34.enum(["text", "json"]).optional()
15184
+ applies_to: z35.array(PLATFORM).min(1).describe("Platforms this lesson applies to."),
15185
+ response_format: z35.enum(["text", "json"]).optional()
14002
15186
  },
14003
15187
  async ({ project_id, lesson, evidence, applies_to, response_format }) => {
14004
15188
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -14034,10 +15218,10 @@ function registerHermesTools(server) {
14034
15218
  "record_observation",
14035
15219
  'Record an agent observation (e.g. "topic X engagement up 23% this week"). Surfaces in the analytics Playbook. Use for weekly reflection digests and mid-campaign pulse summaries.',
14036
15220
  {
14037
- summary: z34.string().min(1).max(2e3).describe("One-paragraph summary, shown to the account owner."),
14038
- 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})."),
14039
- run_id: z34.string().optional().describe("Agent run id for traceability."),
14040
- response_format: z34.enum(["text", "json"]).optional()
15221
+ summary: z35.string().min(1).max(2e3).describe("One-paragraph summary, shown to the account owner."),
15222
+ deltas: z35.record(z35.string(), z35.union([z35.number(), z35.string(), z35.boolean()])).optional().describe("Optional structured key/value payload (e.g. {topic_x_er_pct: 23})."),
15223
+ run_id: z35.string().optional().describe("Agent run id for traceability."),
15224
+ response_format: z35.enum(["text", "json"]).optional()
14041
15225
  },
14042
15226
  async ({ summary, deltas, run_id, response_format }) => {
14043
15227
  const { data, error } = await callEdgeFunction("mcp-data", { action: "record-observation", summary, deltas, run_id });
@@ -14064,13 +15248,13 @@ function registerHermesTools(server) {
14064
15248
  "record_intel_signal",
14065
15249
  "Record a research/trend signal (news, HN post, competitor change, arxiv paper). Surfaces in SN Brand > Niche Intelligence. Dedupes by URL per user \u2014 safe to call multiple times for the same source.",
14066
15250
  {
14067
- source: z34.string().min(1).max(100).describe('Watcher name (e.g. "news-watch", "hackernews-watch").'),
14068
- url: z34.string().url().max(2e3).describe("Canonical URL of the source. Used for dedupe."),
14069
- topic: z34.string().max(200).optional().describe("Best-fit topic key."),
14070
- title: z34.string().max(500).optional(),
14071
- summary: z34.string().max(4e3).optional().describe("One-paragraph summary of the signal."),
14072
- score: z34.number().optional().describe("Relevance score from the watcher (0\u201310 typical)."),
14073
- response_format: z34.enum(["text", "json"]).optional()
15251
+ source: z35.string().min(1).max(100).describe('Watcher name (e.g. "news-watch", "hackernews-watch").'),
15252
+ url: z35.string().url().max(2e3).describe("Canonical URL of the source. Used for dedupe."),
15253
+ topic: z35.string().max(200).optional().describe("Best-fit topic key."),
15254
+ title: z35.string().max(500).optional(),
15255
+ summary: z35.string().max(4e3).optional().describe("One-paragraph summary of the signal."),
15256
+ score: z35.number().optional().describe("Relevance score from the watcher (0\u201310 typical)."),
15257
+ response_format: z35.enum(["text", "json"]).optional()
14074
15258
  },
14075
15259
  async ({ source, url, topic, title, summary, score, response_format }) => {
14076
15260
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -14108,16 +15292,16 @@ function registerHermesTools(server) {
14108
15292
  "record_campaign_spend",
14109
15293
  "Log a campaign cost line. Use when an autonomous agent incurs spend that should be attributed to a campaign \u2014 drafts, renders, paid amp. Read aggregate via get_active_campaigns.",
14110
15294
  {
14111
- campaign_id: z34.string().min(1).max(200).describe("Campaign identifier (slug)."),
14112
- category: z34.enum([
15295
+ campaign_id: z35.string().min(1).max(200).describe("Campaign identifier (slug)."),
15296
+ category: z35.enum([
14113
15297
  "hermes_drafts",
14114
15298
  "carousel_renders",
14115
15299
  "analytics_pulls",
14116
15300
  "paid_amplification",
14117
15301
  "other"
14118
15302
  ]).describe("Cost category."),
14119
- amount_usd: z34.number().min(0).describe("Amount in USD; supports 4 decimal places."),
14120
- response_format: z34.enum(["text", "json"]).optional()
15303
+ amount_usd: z35.number().min(0).describe("Amount in USD; supports 4 decimal places."),
15304
+ response_format: z35.enum(["text", "json"]).optional()
14121
15305
  },
14122
15306
  async ({ campaign_id, category, amount_usd, response_format }) => {
14123
15307
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -14152,7 +15336,7 @@ function registerHermesTools(server) {
14152
15336
  "get_active_campaigns",
14153
15337
  "List currently-running campaigns with thesis, budget, hero format, and current spend. Use to bias drafts toward active campaign themes.",
14154
15338
  {
14155
- response_format: z34.enum(["text", "json"]).optional()
15339
+ response_format: z35.enum(["text", "json"]).optional()
14156
15340
  },
14157
15341
  async ({ response_format }) => {
14158
15342
  const { data, error } = await callEdgeFunction("mcp-data", { action: "get-active-campaigns" });
@@ -14197,7 +15381,7 @@ ${"=".repeat(40)}
14197
15381
  }
14198
15382
 
14199
15383
  // src/tools/skills.ts
14200
- import { z as z35 } from "zod";
15384
+ import { z as z36 } from "zod";
14201
15385
 
14202
15386
  // src/lib/skills-manifest.ts
14203
15387
  var SKILLS_MANIFEST = [
@@ -14226,6 +15410,19 @@ function listSkills(opts) {
14226
15410
  }
14227
15411
 
14228
15412
  // src/tools/skills.ts
15413
+ function renderCatalogRow(row) {
15414
+ const lines = [];
15415
+ const platform2 = row.platform ? ` \xB7 ${row.platform}` : "";
15416
+ lines.push(`${row.slug} (${row.kind}${platform2})`);
15417
+ const desc = row.frontmatter && typeof row.frontmatter.description === "string" ? row.frontmatter.description : null;
15418
+ if (desc) lines.push(` ${desc}`);
15419
+ const lock = row.locked ? ` \u{1F512} (upgrade to unlock)` : "";
15420
+ lines.push(` Tier: ${row.tier_minimum}${lock}`);
15421
+ lines.push(
15422
+ ` Model: ${row.model_id ?? "n/a"} \xB7 ${row.body_chars} chars` + (row.updated_at ? ` \xB7 updated ${row.updated_at}` : "")
15423
+ );
15424
+ return lines.join("\n");
15425
+ }
14229
15426
  function asEnvelope26(data) {
14230
15427
  return {
14231
15428
  _meta: {
@@ -14235,7 +15432,14 @@ function asEnvelope26(data) {
14235
15432
  data
14236
15433
  };
14237
15434
  }
14238
- var STUDIO_VALUES = ["video", "avatar", "carousel", "voice", "caption", "edit"];
15435
+ var STUDIO_VALUES = [
15436
+ "video",
15437
+ "avatar",
15438
+ "carousel",
15439
+ "voice",
15440
+ "caption",
15441
+ "edit"
15442
+ ];
14239
15443
  function renderSkillSummary(skill) {
14240
15444
  const lines = [];
14241
15445
  lines.push(`${skill.name} (${skill.id})`);
@@ -14254,19 +15458,88 @@ function registerSkillsTools(server) {
14254
15458
  "list_skills",
14255
15459
  'List Social Neuron content workflow skills available to the authenticated user. A skill is a brand-locked multi-step pipeline (research \u2192 hook \u2192 script \u2192 visuals \u2192 voice \u2192 captions \u2192 assembly \u2192 quality gate \u2192 schedule) inspired by documented viral patterns (MrBeast 3-second hook, Hormozi pattern interrupt, etc.). Use this tool when the user asks "what can SN do", "what skills are available", "show me viral templates", or before calling run_skill so you can pick the right one.',
14256
15460
  {
14257
- studio: z35.enum(STUDIO_VALUES).optional().describe("Filter to one studio (video, avatar, carousel, voice, caption, edit)."),
14258
- featured_only: z35.boolean().optional().describe("Return only featured (recommended) skills. Defaults to false."),
14259
- response_format: z35.enum(["text", "json"]).optional().describe("Response format. Defaults to text \u2014 human-readable summary.")
15461
+ studio: z36.enum(STUDIO_VALUES).optional().describe(
15462
+ "Filter to one studio (video, avatar, carousel, voice, caption, edit)."
15463
+ ),
15464
+ featured_only: z36.boolean().optional().describe(
15465
+ "Return only featured (recommended) skills. Defaults to false."
15466
+ ),
15467
+ response_format: z36.enum(["text", "json"]).optional().describe(
15468
+ "Response format. Defaults to text \u2014 human-readable summary."
15469
+ )
14260
15470
  },
14261
15471
  async ({ studio, featured_only, response_format }) => {
14262
- const skills = listSkills({ studio, featuredOnly: featured_only });
14263
15472
  const format = response_format ?? "text";
15473
+ const { data: efData, error: efError } = await callEdgeFunction("mcp-data", { action: "get-skills" });
15474
+ const catalogRows = efData?.skills;
15475
+ if (!efError && Array.isArray(catalogRows) && catalogRows.length > 0) {
15476
+ const workflows = listSkills({ studio, featuredOnly: featured_only });
15477
+ const filteredGuideRows = catalogRows.filter(
15478
+ (row) => !studio && (!featured_only || row.frontmatter?.featured === true)
15479
+ );
15480
+ const guides = filteredGuideRows.map((row) => ({
15481
+ ...row,
15482
+ use_with: "get_skill"
15483
+ }));
15484
+ const total = guides.length + workflows.length;
15485
+ if (format === "json") {
15486
+ return {
15487
+ content: [
15488
+ {
15489
+ type: "text",
15490
+ text: JSON.stringify(
15491
+ asEnvelope26({
15492
+ count: total,
15493
+ guides,
15494
+ workflows: workflows.map((w) => ({
15495
+ ...w,
15496
+ use_with: "run_skill"
15497
+ }))
15498
+ }),
15499
+ null,
15500
+ 2
15501
+ )
15502
+ }
15503
+ ]
15504
+ };
15505
+ }
15506
+ const guideBlocks = filteredGuideRows.map(
15507
+ (row) => `${renderCatalogRow(row)}
15508
+ \u2192 Read it: get_skill(slug: "${row.slug}")`
15509
+ ).join("\n\n");
15510
+ const workflowBlocks = workflows.map(renderSkillSummary).join("\n\n");
15511
+ const header2 = `${total} skill${total === 1 ? "" : "s"} available (${guides.length} guide${guides.length === 1 ? "" : "s"} \xB7 ${workflows.length} workflow${workflows.length === 1 ? "" : "s"})
15512
+ ${"=".repeat(40)}`;
15513
+ const sections = [
15514
+ guides.length > 0 ? `GUIDES \u2014 living how-to documents. Fetch with get_skill(slug).
15515
+
15516
+ ${guideBlocks}` : "",
15517
+ workflows.length > 0 ? `WORKFLOWS \u2014 executable content pipelines. Launch with run_skill(skill_id).
15518
+
15519
+ ${workflowBlocks}` : ""
15520
+ ].filter(Boolean);
15521
+ return {
15522
+ content: [
15523
+ {
15524
+ type: "text",
15525
+ text: `${header2}
15526
+
15527
+ ${sections.join("\n\n")}`
15528
+ }
15529
+ ]
15530
+ };
15531
+ }
15532
+ const skills = listSkills({ studio, featuredOnly: featured_only });
14264
15533
  if (format === "json") {
14265
15534
  return {
14266
15535
  content: [
14267
15536
  {
14268
15537
  type: "text",
14269
- text: JSON.stringify(asEnvelope26({ count: skills.length, skills }), null, 2)
15538
+ text: JSON.stringify(
15539
+ asEnvelope26({ count: skills.length, skills }),
15540
+ null,
15541
+ 2
15542
+ )
14270
15543
  }
14271
15544
  ]
14272
15545
  };
@@ -14276,7 +15549,9 @@ function registerSkillsTools(server) {
14276
15549
  content: [
14277
15550
  {
14278
15551
  type: "text",
14279
- text: "No skills match the filter." + (studio ? ` Studio "${studio}" has no skills yet.` : "") + "\n\nAvailable studios with skills: " + Array.from(new Set(SKILLS_MANIFEST.map((s) => s.studio))).join(", ")
15552
+ text: "No skills match the filter." + (studio ? ` Studio "${studio}" has no skills yet.` : "") + "\n\nAvailable studios with skills: " + Array.from(new Set(SKILLS_MANIFEST.map((s) => s.studio))).join(
15553
+ ", "
15554
+ )
14280
15555
  }
14281
15556
  ]
14282
15557
  };
@@ -14295,18 +15570,22 @@ ${blocks}` }]
14295
15570
  "run_skill",
14296
15571
  "Run a Social Neuron workflow skill end-to-end (brand-locked content production). Returns a structured run preview with the exact step plan, credit cost, and a deep-link to launch the run in the SN dashboard. A future release executes in-process so you can stream step-by-step progress back to the user. Call list_skills first to discover available skill ids.",
14297
15572
  {
14298
- skill_id: z35.string().describe(
15573
+ skill_id: z36.string().describe(
14299
15574
  'The id of the skill to run (e.g. "skill-brand-locked-viral-hook-reel"). Use list_skills to discover available ids.'
14300
15575
  ),
14301
- topic: z35.string().min(1).describe('What the content is about (e.g. "why we built Social Neuron").'),
14302
- audience: z35.string().optional().describe(
15576
+ topic: z36.string().min(1).describe(
15577
+ 'What the content is about (e.g. "why we built Social Neuron").'
15578
+ ),
15579
+ audience: z36.string().optional().describe(
14303
15580
  'Who the content is for (e.g. "first-time founders"). Defaults to brand persona.'
14304
15581
  ),
14305
- hook: z35.string().optional().describe(
15582
+ hook: z36.string().optional().describe(
14306
15583
  "Optional explicit hook. If omitted, the skill drafts and scores 3-5 candidates."
14307
15584
  ),
14308
- cta: z35.string().optional().describe("Optional call-to-action override. Defaults to brand standard CTA."),
14309
- response_format: z35.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
15585
+ cta: z36.string().optional().describe(
15586
+ "Optional call-to-action override. Defaults to brand standard CTA."
15587
+ ),
15588
+ response_format: z36.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
14310
15589
  },
14311
15590
  async ({ skill_id, topic, audience, hook, cta, response_format }) => {
14312
15591
  const skill = getSkill(skill_id);
@@ -14374,10 +15653,78 @@ ${blocks}` }]
14374
15653
  };
14375
15654
  }
14376
15655
  );
15656
+ server.tool(
15657
+ "get_skill",
15658
+ `Fetch the full body of a single Social Neuron skill by slug \u2014 the hand-maintained strategy/specs plus the machine-maintained "what's working now" compiled section. Use this after list_skills when the user wants the actual playbook for a platform (e.g. "show me the TikTok skill"). Returns the skill body, compiled section, tier, and linked recipe slug (if any).`,
15659
+ {
15660
+ slug: z36.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9-]*$/).describe(
15661
+ 'The skill slug (e.g. "tiktok-content"). Use list_skills to discover slugs.'
15662
+ ),
15663
+ response_format: z36.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
15664
+ },
15665
+ async ({ slug, response_format }) => {
15666
+ const format = response_format ?? "text";
15667
+ const { data, error } = await callEdgeFunction("mcp-data", {
15668
+ action: "get-skill",
15669
+ slug
15670
+ });
15671
+ if (error) {
15672
+ return toolError(
15673
+ "upstream_error",
15674
+ "The skill catalogue could not be loaded. Please retry."
15675
+ );
15676
+ }
15677
+ const skill = data?.skill;
15678
+ if (!skill) {
15679
+ return toolError("not_found", `No skill found with slug "${slug}".`, {
15680
+ recover_with: ["Call list_skills to see available skills."]
15681
+ });
15682
+ }
15683
+ if (skill.locked) {
15684
+ return toolError(
15685
+ "permission_denied",
15686
+ "This skill requires a higher plan tier.",
15687
+ {
15688
+ details: { slug: skill.slug, tier_minimum: skill.tier_minimum },
15689
+ recover_with: [
15690
+ "Upgrade the account or choose an unlocked skill from list_skills."
15691
+ ]
15692
+ }
15693
+ );
15694
+ }
15695
+ if (format === "json") {
15696
+ return {
15697
+ content: [
15698
+ {
15699
+ type: "text",
15700
+ text: JSON.stringify(asEnvelope26(skill), null, 2)
15701
+ }
15702
+ ]
15703
+ };
15704
+ }
15705
+ const platform2 = skill.platform ? ` \xB7 ${skill.platform}` : "";
15706
+ const lock = skill.locked ? " \u{1F512} (upgrade to unlock)" : "";
15707
+ const header = [
15708
+ `${skill.slug} (${skill.kind}${platform2})`,
15709
+ "=".repeat(40),
15710
+ `Tier: ${skill.tier_minimum}${lock} \xB7 version ${skill.version}` + (skill.recipe_slug ? ` \xB7 recipe: ${skill.recipe_slug}` : ""),
15711
+ ""
15712
+ ].join("\n");
15713
+ const compiled = skill.compiled_section ? `
15714
+
15715
+ --- What's working now ---
15716
+ ${skill.compiled_section}` : "";
15717
+ return {
15718
+ content: [
15719
+ { type: "text", text: `${header}${skill.body}${compiled}` }
15720
+ ]
15721
+ };
15722
+ }
15723
+ );
14377
15724
  }
14378
15725
 
14379
15726
  // src/tools/loopPulse.ts
14380
- import { z as z36 } from "zod";
15727
+ import { z as z37 } from "zod";
14381
15728
  function asEnvelope27(data) {
14382
15729
  return {
14383
15730
  _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
@@ -14389,7 +15736,7 @@ function registerLoopPulseTools(server) {
14389
15736
  "get_loop_pulse",
14390
15737
  'Read the dynamic loop-health KPIs for the Social Neuron growth loop over the last 7 days. Returns reflection coverage, decision coverage, visual gate pass rate, learning-update application rate, per-platform learning uptake, autopilot lag, and pattern aggregation counts \u2014 each with a status ("ok" / "warn" / "bad") and a why-line explaining what the metric measures. Use this to decide whether the loop is closing or where it is stuck before recommending next moves.',
14391
15738
  {
14392
- response_format: z36.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
15739
+ response_format: z37.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
14393
15740
  },
14394
15741
  async ({ response_format }) => {
14395
15742
  const format = response_format ?? "text";
@@ -14432,7 +15779,7 @@ function registerLoopPulseTools(server) {
14432
15779
  }
14433
15780
 
14434
15781
  // src/tools/banditState.ts
14435
- import { z as z37 } from "zod";
15782
+ import { z as z38 } from "zod";
14436
15783
  init_supabase();
14437
15784
  function asEnvelope28(data) {
14438
15785
  return {
@@ -14445,8 +15792,8 @@ function registerBanditStateTools(server) {
14445
15792
  "get_bandit_state",
14446
15793
  "Read the current content learning state for a project. Returns top-K arms per (arm_type, platform) with expected performance and uncertainty. Use this to reason about which hook family / format / timing slot currently performs best on each platform before recommending next moves. Arm types: hook_family, length_bucket (xs/s/m/l/xl by platform), posting_time_bucket (morning/midday/evening/late), content_format (video/carousel/image/caption/text/avatar/storyboard).",
14447
15794
  {
14448
- project_id: z37.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
14449
- platform: z37.enum([
15795
+ project_id: z38.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
15796
+ platform: z38.enum([
14450
15797
  "instagram",
14451
15798
  "tiktok",
14452
15799
  "youtube",
@@ -14458,7 +15805,7 @@ function registerBanditStateTools(server) {
14458
15805
  ]).optional().describe(
14459
15806
  "Lowercase platform name. Omit to return both platform-scoped and legacy platform-agnostic arms."
14460
15807
  ),
14461
- arm_type: z37.enum([
15808
+ arm_type: z38.enum([
14462
15809
  "hook_family",
14463
15810
  "hook_type",
14464
15811
  "format",
@@ -14472,8 +15819,8 @@ function registerBanditStateTools(server) {
14472
15819
  "posting_time_bucket",
14473
15820
  "content_format"
14474
15821
  ]).optional().describe("Arm dimension. Omit to return all types grouped."),
14475
- top_k: z37.number().min(1).max(50).optional().describe("Max arms per (arm_type) group. Default 5."),
14476
- response_format: z37.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
15822
+ top_k: z38.number().min(1).max(50).optional().describe("Max arms per (arm_type) group. Default 5."),
15823
+ response_format: z38.enum(["text", "json"]).optional().describe("Output format. Defaults to text.")
14477
15824
  },
14478
15825
  async ({ project_id, platform: platform2, arm_type, top_k, response_format }) => {
14479
15826
  const format = response_format ?? "text";
@@ -14528,12 +15875,128 @@ function registerBanditStateTools(server) {
14528
15875
  );
14529
15876
  }
14530
15877
 
15878
+ // src/tools/lifecycle.ts
15879
+ import { z as z39 } from "zod";
15880
+ init_supabase();
15881
+ function envelope(data) {
15882
+ return {
15883
+ _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
15884
+ data
15885
+ };
15886
+ }
15887
+ function lifecycleFailure(error) {
15888
+ if (/not_found|not found/i.test(error)) {
15889
+ return toolError("not_found", "The requested object was not found in this project.");
15890
+ }
15891
+ if (/not_cancellable|publishing_in_progress|schedule_conflict|post_in_progress/i.test(error)) {
15892
+ return toolError("validation_error", "The object can no longer be cancelled in its current state.", {
15893
+ recover_with: ["Refresh its status before deciding the next action."]
15894
+ });
15895
+ }
15896
+ return toolError("upstream_error", "The lifecycle operation could not be completed. Please retry.");
15897
+ }
15898
+ async function projectContext(projectId) {
15899
+ return projectId ?? await getDefaultProjectId();
15900
+ }
15901
+ async function invokeLifecycle(action, projectId, identifiers) {
15902
+ const resolvedProjectId = await projectContext(projectId);
15903
+ if (!resolvedProjectId) {
15904
+ return toolError("validation_error", "A project_id is required because no default project is configured.");
15905
+ }
15906
+ const { data, error } = await callEdgeFunction("mcp-data", {
15907
+ action,
15908
+ projectId: resolvedProjectId,
15909
+ project_id: resolvedProjectId,
15910
+ ...identifiers
15911
+ });
15912
+ if (error) return lifecycleFailure(error);
15913
+ if (!data?.success) return toolError("upstream_error", "The lifecycle operation returned no result.");
15914
+ const result = envelope(data);
15915
+ return {
15916
+ structuredContent: result,
15917
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
15918
+ };
15919
+ }
15920
+ var PROJECT_ID = z39.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default.");
15921
+ var CONFIRM = z39.literal(true).describe("Must be true only after the user explicitly confirms this destructive action.");
15922
+ function registerLifecycleTools(server) {
15923
+ server.tool(
15924
+ "cancel_async_job",
15925
+ "Cancel an owned pending async generation/render job before a worker starts it. If credits were already debited, the backend attempts an idempotent refund and reports the result. Processing or terminal jobs are not cancellable.",
15926
+ {
15927
+ job_id: z39.string().uuid().describe("Owned async_jobs ID returned by a generation tool."),
15928
+ project_id: PROJECT_ID,
15929
+ confirm: CONFIRM
15930
+ },
15931
+ async ({ job_id, project_id }) => invokeLifecycle("cancel-async-job", project_id, { job_id })
15932
+ );
15933
+ server.tool(
15934
+ "cancel_scheduled_post",
15935
+ "Cancel an owned draft, pending, or scheduled post before publishing starts. This closes its pending schedule job first; it refuses once a worker has claimed the publication.",
15936
+ {
15937
+ post_id: z39.string().uuid().describe("Owned scheduled post ID."),
15938
+ project_id: PROJECT_ID,
15939
+ confirm: CONFIRM
15940
+ },
15941
+ async ({ post_id, project_id }) => invokeLifecycle("cancel-scheduled-post", project_id, { post_id })
15942
+ );
15943
+ server.tool(
15944
+ "delete_carousel",
15945
+ "Delete an owned carousel content-history record from one project. Stored media is retained until the normal retention cleanup; this does not delete already-published platform posts.",
15946
+ {
15947
+ content_id: z39.string().uuid().describe("Owned carousel content_history ID."),
15948
+ project_id: PROJECT_ID,
15949
+ confirm: CONFIRM
15950
+ },
15951
+ async ({ content_id, project_id }) => invokeLifecycle("delete-carousel", project_id, { content_id })
15952
+ );
15953
+ server.tool(
15954
+ "delete_content_plan",
15955
+ "Permanently delete an owned content plan in one project. This does not cancel posts that were already scheduled from the plan.",
15956
+ {
15957
+ plan_id: z39.string().uuid().describe("Owned content plan ID."),
15958
+ project_id: PROJECT_ID,
15959
+ confirm: CONFIRM
15960
+ },
15961
+ async ({ plan_id, project_id }) => invokeLifecycle("delete-content-plan", project_id, { plan_id })
15962
+ );
15963
+ server.tool(
15964
+ "delete_autopilot_config",
15965
+ "Permanently delete an owned autopilot configuration in one project. Historical runs and already-published posts are retained.",
15966
+ {
15967
+ config_id: z39.string().uuid().describe("Owned autopilot configuration ID."),
15968
+ project_id: PROJECT_ID,
15969
+ confirm: CONFIRM
15970
+ },
15971
+ async ({ config_id, project_id }) => invokeLifecycle("delete-autopilot-config", project_id, { config_id })
15972
+ );
15973
+ }
15974
+
14531
15975
  // src/lib/register-tools.ts
15976
+ var BASE64_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["file_data", "fileData"]);
15977
+ var DATA_URI_PREFIX2 = /^data:[\w.+-]+\/[\w.+-]+;base64,/;
15978
+ var STRICT_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
15979
+ function redactBase64Payloads(args) {
15980
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
15981
+ let changed = false;
15982
+ const out = { ...args };
15983
+ for (const key of BASE64_PAYLOAD_KEYS) {
15984
+ const value = out[key];
15985
+ if (typeof value !== "string" || value.length < 64) continue;
15986
+ const body = value.replace(DATA_URI_PREFIX2, "");
15987
+ if (STRICT_BASE64.test(body)) {
15988
+ out[key] = `[base64:${body.length} chars omitted from prose scan]`;
15989
+ changed = true;
15990
+ }
15991
+ }
15992
+ return changed ? out : args;
15993
+ }
14532
15994
  function wrapToolWithScanner(toolName, handler) {
14533
15995
  return async function scannerWrappedHandler(...handlerArgs) {
14534
15996
  const args = handlerArgs[0];
14535
15997
  const ctx = handlerArgs[1];
14536
- const inputText = args === void 0 ? "{}" : typeof args === "string" ? args : JSON.stringify(args);
15998
+ const scanArgs = redactBase64Payloads(args);
15999
+ const inputText = scanArgs === void 0 ? "{}" : typeof scanArgs === "string" ? scanArgs : JSON.stringify(scanArgs);
14537
16000
  const inputScan = scan(inputText, {
14538
16001
  mode: "block",
14539
16002
  source: "mcp_tool_input",
@@ -14561,6 +16024,16 @@ function wrapToolWithScanner(toolName, handler) {
14561
16024
  source: "mcp_tool_output",
14562
16025
  user_id: ctx?.userId
14563
16026
  });
16027
+ if (!outputScan.passed) {
16028
+ try {
16029
+ ctx?.logScan?.(toolName, "output", outputScan);
16030
+ } catch {
16031
+ }
16032
+ return toolError(
16033
+ "server_error",
16034
+ "The response exceeded the safe output limit and was not returned."
16035
+ );
16036
+ }
14564
16037
  if (outputScan.sanitized_text !== void 0) {
14565
16038
  try {
14566
16039
  ctx?.logScan?.(toolName, "output", outputScan);
@@ -14569,7 +16042,10 @@ function wrapToolWithScanner(toolName, handler) {
14569
16042
  try {
14570
16043
  return JSON.parse(outputScan.sanitized_text);
14571
16044
  } catch {
14572
- return result;
16045
+ return toolError(
16046
+ "server_error",
16047
+ "The response could not be returned safely. Please retry or contact support."
16048
+ );
14573
16049
  }
14574
16050
  }
14575
16051
  return result;
@@ -14619,8 +16095,7 @@ function applyScopeEnforcement(server, scopeResolver) {
14619
16095
  details: {
14620
16096
  source: "wrapper",
14621
16097
  // A thrown exception escaped the handler — an unclassified fault.
14622
- error_type: "server_error",
14623
- exception: err instanceof Error ? err.message.slice(0, 200) : "unknown"
16098
+ error_type: "server_error"
14624
16099
  }
14625
16100
  });
14626
16101
  throw err;
@@ -14753,24 +16228,26 @@ function registerAllTools(server, options) {
14753
16228
  registerSkillsTools(server);
14754
16229
  registerLoopPulseTools(server);
14755
16230
  registerBanditStateTools(server);
16231
+ registerLifecycleTools(server);
14756
16232
  if (!options?.skipApps) {
14757
16233
  registerContentCalendarApp(server);
16234
+ registerAnalyticsPulseApp(server);
14758
16235
  }
14759
16236
  applyAnnotations(server);
14760
16237
  }
14761
16238
 
14762
16239
  // src/prompts.ts
14763
- import { z as z38 } from "zod";
16240
+ import { z as z40 } from "zod";
14764
16241
  function registerPrompts(server) {
14765
16242
  server.prompt(
14766
16243
  "create_weekly_content_plan",
14767
16244
  "Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
14768
16245
  {
14769
- niche: z38.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
14770
- platforms: z38.string().optional().describe(
16246
+ niche: z40.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
16247
+ platforms: z40.string().optional().describe(
14771
16248
  'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
14772
16249
  ),
14773
- tone: z38.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
16250
+ tone: z40.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
14774
16251
  },
14775
16252
  ({ niche, platforms, tone }) => {
14776
16253
  const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
@@ -14812,8 +16289,8 @@ After building the plan, use \`save_content_plan\` to save it.`
14812
16289
  "analyze_top_content",
14813
16290
  "Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
14814
16291
  {
14815
- timeframe: z38.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
14816
- platform: z38.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
16292
+ timeframe: z40.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
16293
+ platform: z40.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
14817
16294
  },
14818
16295
  ({ timeframe, platform: platform2 }) => {
14819
16296
  const period = timeframe || "30 days";
@@ -14850,10 +16327,10 @@ Format as a clear, actionable performance report.`
14850
16327
  "repurpose_content",
14851
16328
  "Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
14852
16329
  {
14853
- source: z38.string().describe(
16330
+ source: z40.string().describe(
14854
16331
  "The source content to repurpose \u2014 a URL, transcript, or the content text itself"
14855
16332
  ),
14856
- target_platforms: z38.string().optional().describe(
16333
+ target_platforms: z40.string().optional().describe(
14857
16334
  'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
14858
16335
  )
14859
16336
  },
@@ -14895,9 +16372,9 @@ For each piece, include the platform, format, character count, and suggested pos
14895
16372
  "setup_brand_voice",
14896
16373
  "Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
14897
16374
  {
14898
- brand_name: z38.string().describe("Your brand or business name"),
14899
- industry: z38.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
14900
- website: z38.string().optional().describe("Your website URL for context")
16375
+ brand_name: z40.string().describe("Your brand or business name"),
16376
+ industry: z40.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
16377
+ website: z40.string().optional().describe("Your website URL for context")
14901
16378
  },
14902
16379
  ({ brand_name, industry, website }) => {
14903
16380
  const industryContext = industry ? ` in the ${industry} space` : "";
@@ -15260,11 +16737,7 @@ init_request_context();
15260
16737
 
15261
16738
  // src/lib/discovery-catalog.ts
15262
16739
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15263
- var PUBLIC_SCHEMA_OMIT_PROPERTIES = {
15264
- // Internal lineage fields are accepted by the runtime for Social Neuron's own
15265
- // automation, but should not be advertised in public MCP/OpenAPI discovery.
15266
- schedule_post: ["origin", "hermes_run_id"]
15267
- };
16740
+ var PUBLIC_SCHEMA_OMIT_PROPERTIES = {};
15268
16741
  var cached = /* @__PURE__ */ new Map();
15269
16742
  function buildDiscoveryCatalog(profile = "full") {
15270
16743
  const existing = cached.get(profile);
@@ -15391,7 +16864,9 @@ function buildOpenApiDocument() {
15391
16864
  async function computeOpenApiDocument() {
15392
16865
  const discovery = await buildDiscoveryCatalog();
15393
16866
  const schemaByName = new Map(discovery.map((t) => [t.name, t.inputSchema]));
15394
- const publicTools = TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal);
16867
+ const publicTools = TOOL_CATALOG.filter(
16868
+ (t) => !t.localOnly && !t.internal && !t.hiddenFromPublicCount
16869
+ );
15395
16870
  const paths = {};
15396
16871
  for (const tool of publicTools) {
15397
16872
  const scope = TOOL_SCOPES[tool.name];
@@ -15474,7 +16949,11 @@ function getCallHandler() {
15474
16949
  return cachedCallHandler;
15475
16950
  }
15476
16951
  function restToolNames() {
15477
- return new Set(TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal).map((t) => t.name));
16952
+ return new Set(
16953
+ TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal && !t.hiddenFromPublicCount).map(
16954
+ (t) => t.name
16955
+ )
16956
+ );
15478
16957
  }
15479
16958
  async function invokeToolRest(name, args) {
15480
16959
  const handler = getCallHandler();
@@ -15549,7 +17028,7 @@ function httpStatusForResult(result) {
15549
17028
  }
15550
17029
 
15551
17030
  // src/lib/token-verifier.ts
15552
- import { createHash as createHash3 } from "node:crypto";
17031
+ import { createHash as createHash2 } from "node:crypto";
15553
17032
  import * as jose from "jose";
15554
17033
  var jwks = null;
15555
17034
  function getJWKS(supabaseUrl) {
@@ -15560,10 +17039,21 @@ function getJWKS(supabaseUrl) {
15560
17039
  return jwks;
15561
17040
  }
15562
17041
  var tokenValidationCache = /* @__PURE__ */ new Map();
15563
- var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS = 3e5;
17042
+ var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS = 6e4;
15564
17043
  var DEFAULT_MCP_RESOURCE = "https://mcp.socialneuron.com";
17044
+ var VALID_MCP_SCOPES = new Set(getAllScopes());
17045
+ function validScopes(value) {
17046
+ if (!Array.isArray(value)) return [];
17047
+ return Array.from(
17048
+ new Set(
17049
+ value.filter(
17050
+ (scope) => typeof scope === "string" && VALID_MCP_SCOPES.has(scope)
17051
+ )
17052
+ )
17053
+ );
17054
+ }
15565
17055
  function cacheKey(token) {
15566
- return createHash3("sha256").update(token).digest("hex");
17056
+ return createHash2("sha256").update(token).digest("hex");
15567
17057
  }
15568
17058
  function evictFromCache(token) {
15569
17059
  tokenValidationCache.delete(cacheKey(token));
@@ -15598,6 +17088,7 @@ async function verifyCachedOpaqueToken(token, validate, ttlMs) {
15598
17088
  }
15599
17089
  function createTokenVerifier(options) {
15600
17090
  const { supabaseUrl, supabaseAnonKey } = options;
17091
+ const allowSupabaseSessionTokens = options.allowSupabaseSessionTokens ?? process.env.MCP_ALLOW_SUPABASE_SESSION_TOKENS === "true";
15601
17092
  const expectedResource = normalizeResource(
15602
17093
  options.resource ?? process.env.MCP_RESOURCE_URL ?? process.env.MCP_SERVER_URL
15603
17094
  ) ?? DEFAULT_MCP_RESOURCE;
@@ -15613,7 +17104,10 @@ function createTokenVerifier(options) {
15613
17104
  CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS
15614
17105
  );
15615
17106
  }
15616
- return verifySupabaseJwt(token, supabaseUrl);
17107
+ if (!allowSupabaseSessionTokens) {
17108
+ throw new Error("Unsupported access token");
17109
+ }
17110
+ return verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey);
15617
17111
  }
15618
17112
  };
15619
17113
  }
@@ -15621,7 +17115,10 @@ function normalizeResource(value) {
15621
17115
  if (!value) return void 0;
15622
17116
  try {
15623
17117
  const parsed = new URL(value);
15624
- return `${parsed.protocol}//${parsed.host}`;
17118
+ parsed.hash = "";
17119
+ parsed.search = "";
17120
+ if (parsed.pathname !== "/") parsed.pathname = parsed.pathname.replace(/\/+$/, "");
17121
+ return parsed.toString().replace(/\/$/, "");
15625
17122
  } catch {
15626
17123
  return value.replace(/\/$/, "");
15627
17124
  }
@@ -15635,7 +17132,7 @@ function audienceIncludesExpected(audience, expectedResource) {
15635
17132
  }
15636
17133
  return false;
15637
17134
  }
15638
- async function verifySupabaseJwt(token, supabaseUrl) {
17135
+ async function verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey) {
15639
17136
  const jwksKeySet = getJWKS(supabaseUrl);
15640
17137
  const { payload } = await jose.jwtVerify(token, jwksKeySet, {
15641
17138
  issuer: `${supabaseUrl}/auth/v1`,
@@ -15647,14 +17144,41 @@ async function verifySupabaseJwt(token, supabaseUrl) {
15647
17144
  if (!userId) {
15648
17145
  throw new Error("JWT missing sub claim");
15649
17146
  }
15650
- const appMetadata = payload.app_metadata ?? {};
15651
- const scopes = Array.isArray(appMetadata.mcp_scopes) ? appMetadata.mcp_scopes.map(String) : ["mcp:read"];
17147
+ const controller = new AbortController();
17148
+ const timer = setTimeout(() => controller.abort(), 1e4);
17149
+ let entitlements;
17150
+ try {
17151
+ const response = await fetch(
17152
+ `${supabaseUrl}/functions/v1/mcp-auth?action=validate-user-token`,
17153
+ {
17154
+ method: "POST",
17155
+ headers: {
17156
+ Authorization: `Bearer ${supabaseAnonKey}`,
17157
+ "Content-Type": "application/json"
17158
+ },
17159
+ body: JSON.stringify({ access_token: token }),
17160
+ signal: controller.signal
17161
+ }
17162
+ );
17163
+ if (!response.ok) throw new Error("JWT entitlement validation unavailable");
17164
+ entitlements = await response.json();
17165
+ } catch (error) {
17166
+ if (error instanceof Error && error.name === "AbortError") {
17167
+ throw new Error("JWT entitlement validation timed out");
17168
+ }
17169
+ throw error;
17170
+ } finally {
17171
+ clearTimeout(timer);
17172
+ }
17173
+ if (!entitlements.valid || entitlements.userId !== userId) {
17174
+ throw new Error("JWT is not authorized for MCP");
17175
+ }
15652
17176
  return {
15653
17177
  token,
15654
17178
  clientId: payload.client_id ?? "supabase-oauth",
15655
- scopes,
17179
+ scopes: validScopes(entitlements.scopes),
15656
17180
  expiresAt: payload.exp,
15657
- extra: { userId }
17181
+ extra: { userId, tier: entitlements.tier }
15658
17182
  };
15659
17183
  }
15660
17184
  async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
@@ -15686,12 +17210,12 @@ async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
15686
17210
  throw new Error("API key expired");
15687
17211
  }
15688
17212
  const projectId = data.projectId ?? data.project_id ?? null;
15689
- const extra = { userId: data.userId, email: data.email };
17213
+ const extra = { userId: data.userId };
15690
17214
  if (projectId) extra.projectId = projectId;
15691
17215
  return {
15692
17216
  token: apiKey,
15693
17217
  clientId: "api-key",
15694
- scopes: data.scopes ?? ["mcp:read"],
17218
+ scopes: validScopes(data.scopes),
15695
17219
  expiresAt,
15696
17220
  extra
15697
17221
  };
@@ -15737,9 +17261,9 @@ async function verifyConnectorToken(accessToken, supabaseUrl, supabaseAnonKey, e
15737
17261
  return {
15738
17262
  token: accessToken,
15739
17263
  clientId: data.clientId ?? "connector-oauth",
15740
- scopes: data.scopes ?? ["mcp:read"],
17264
+ scopes: validScopes(data.scopes),
15741
17265
  expiresAt,
15742
- extra: { userId: data.userId, email: data.email, resource: expectedResource }
17266
+ extra: { userId: data.userId, resource: expectedResource }
15743
17267
  };
15744
17268
  } catch (err) {
15745
17269
  if (err instanceof Error && err.name === "AbortError") {
@@ -15886,7 +17410,7 @@ function createClientsStore() {
15886
17410
  function markUnavailable(reason) {
15887
17411
  if (supabaseAvailable) {
15888
17412
  console.error(
15889
- `[oauth] persistent client store unavailable: ${reason}. Falling back to in-memory only for this process. Run the mcp_oauth_clients migration to enable persistence.`
17413
+ `[oauth] persistent client store unavailable: ${sanitizeError(reason)} Falling back to in-memory only for this process. Run the mcp_oauth_clients migration to enable persistence.`
15890
17414
  );
15891
17415
  supabaseAvailable = false;
15892
17416
  }
@@ -15915,7 +17439,7 @@ function createClientsStore() {
15915
17439
  const { error: deleteError } = await supabase.from("mcp_oauth_clients").delete().eq("client_id", clientId);
15916
17440
  if (deleteError) {
15917
17441
  console.error(
15918
- `[oauth] failed to remove client with disallowed redirect URI: ${deleteError.message}`
17442
+ `[oauth] failed to remove client with disallowed redirect URI: ${sanitizeError(deleteError)}`
15919
17443
  );
15920
17444
  }
15921
17445
  return void 0;
@@ -15976,7 +17500,11 @@ function createOAuthProvider(options) {
15976
17500
  const { supabaseUrl, supabaseAnonKey } = options;
15977
17501
  const appBaseUrl = options.appBaseUrl ?? "https://www.socialneuron.com";
15978
17502
  const clientsStore = createClientsStore();
15979
- const tokenVerifier2 = createTokenVerifier({ supabaseUrl, supabaseAnonKey });
17503
+ const tokenVerifier2 = createTokenVerifier({
17504
+ supabaseUrl,
17505
+ supabaseAnonKey,
17506
+ resource: options.resource
17507
+ });
15980
17508
  return {
15981
17509
  get clientsStore() {
15982
17510
  return clientsStore;
@@ -16049,12 +17577,11 @@ function createOAuthProvider(options) {
16049
17577
  }
16050
17578
  clearTimeout(timer);
16051
17579
  if (!response.ok) {
16052
- const err = await response.json().catch(() => ({ error: "Exchange failed" }));
16053
- throw new Error(err.error ?? `HTTP ${response.status}`);
17580
+ throw new Error(`Authorization code exchange failed (HTTP ${response.status})`);
16054
17581
  }
16055
17582
  const data = await response.json();
16056
17583
  if (!data.access_token) {
16057
- throw new Error(data.error ?? "No access token returned from exchange");
17584
+ throw new Error("No access token returned from exchange");
16058
17585
  }
16059
17586
  return {
16060
17587
  access_token: data.access_token,
@@ -16086,12 +17613,11 @@ function createOAuthProvider(options) {
16086
17613
  }
16087
17614
  );
16088
17615
  if (!response.ok) {
16089
- const err = await response.json().catch(() => ({ error: "Refresh failed" }));
16090
- throw new Error(err.error ?? `HTTP ${response.status}`);
17616
+ throw new Error(`Refresh token exchange failed (HTTP ${response.status})`);
16091
17617
  }
16092
17618
  const data = await response.json();
16093
17619
  if (!data.access_token) {
16094
- throw new Error(data.error ?? "No access token returned from refresh");
17620
+ throw new Error("No access token returned from refresh");
16095
17621
  }
16096
17622
  return {
16097
17623
  access_token: data.access_token,
@@ -16135,8 +17661,7 @@ function createOAuthProvider(options) {
16135
17661
  throw new Error(`Token revocation failed: HTTP ${response.status}`);
16136
17662
  }
16137
17663
  } catch (err) {
16138
- const msg = err instanceof Error ? err.message : "unknown";
16139
- console.error(`[oauth] Token revocation call failed: ${msg}`);
17664
+ console.error(`[oauth] Token revocation call failed: ${sanitizeError(err)}`);
16140
17665
  } finally {
16141
17666
  clearTimeout(timer);
16142
17667
  }
@@ -16148,6 +17673,10 @@ function createOAuthProvider(options) {
16148
17673
  init_posthog();
16149
17674
 
16150
17675
  // src/lib/protected-resource-metadata.ts
17676
+ var PROTECTED_RESOURCE_METADATA_PATHS = [
17677
+ "/.well-known/oauth-protected-resource",
17678
+ "/.well-known/oauth-protected-resource/mcp"
17679
+ ];
16151
17680
  function buildProtectedResourceMetadata(options) {
16152
17681
  return {
16153
17682
  resource: options.resourceUrl,
@@ -16195,18 +17724,16 @@ function parseAllowedOrigins(raw) {
16195
17724
  }
16196
17725
  function buildOriginPolicy(input) {
16197
17726
  const envOrigins = parseAllowedOrigins(input.allowedOriginsEnv);
16198
- if (envOrigins.size > 0) {
16199
- return { allowedOrigins: envOrigins, source: "env" };
16200
- }
16201
- const fallback = new Set(PRODUCTION_FALLBACK_ORIGINS);
17727
+ const source = envOrigins.size > 0 ? "env" : "fallback";
17728
+ const allowedOrigins = source === "env" ? envOrigins : new Set(PRODUCTION_FALLBACK_ORIGINS);
16202
17729
  for (const configuredUrl of input.configuredUrls ?? []) {
16203
17730
  const normalized = normalizeOrigin(configuredUrl);
16204
- if (normalized) fallback.add(normalized);
17731
+ if (normalized) allowedOrigins.add(normalized);
16205
17732
  }
16206
- if (input.nodeEnv !== "production") {
16207
- for (const origin of DEVELOPMENT_FALLBACK_ORIGINS) fallback.add(origin);
17733
+ if (source === "fallback" && input.nodeEnv !== "production") {
17734
+ for (const origin of DEVELOPMENT_FALLBACK_ORIGINS) allowedOrigins.add(origin);
16208
17735
  }
16209
- return { allowedOrigins: fallback, source: "fallback" };
17736
+ return { allowedOrigins, source };
16210
17737
  }
16211
17738
  function validateBrowserOrigin(originHeader, policy) {
16212
17739
  if (originHeader === void 0) return { allowed: true, origin: null };
@@ -16218,6 +17745,20 @@ function validateBrowserOrigin(originHeader, policy) {
16218
17745
  return { allowed: true, origin: normalized };
16219
17746
  }
16220
17747
 
17748
+ // src/lib/session-lru.ts
17749
+ function findOldestIdleSessionId(sessions2, userId) {
17750
+ let candidateId = null;
17751
+ let candidateActivity = Number.POSITIVE_INFINITY;
17752
+ for (const [sessionId, entry] of sessions2) {
17753
+ if (userId !== void 0 && entry.userId !== userId) continue;
17754
+ if (entry.activeRequests > 0) continue;
17755
+ if (entry.lastActivity >= candidateActivity) continue;
17756
+ candidateId = sessionId;
17757
+ candidateActivity = entry.lastActivity;
17758
+ }
17759
+ return candidateId;
17760
+ }
17761
+
16221
17762
  // src/http.ts
16222
17763
  process.env.MCP_TRANSPORT = "http";
16223
17764
  var PORT = parseInt(process.env.PORT ?? "8080", 10);
@@ -16227,9 +17768,19 @@ var MCP_SERVER_URL = process.env.MCP_SERVER_URL ?? `http://localhost:${PORT}/mcp
16227
17768
  var APP_BASE_URL = process.env.APP_BASE_URL ?? "https://www.socialneuron.com";
16228
17769
  var NODE_ENV = process.env.NODE_ENV ?? "development";
16229
17770
  var TOOL_PROFILE = resolveToolProfile(process.env.MCP_TOOL_PROFILE);
17771
+ var TRUSTED_BROWSER_MCP_CLIENTS = [
17772
+ "https://claude.ai",
17773
+ "https://claude.com",
17774
+ "https://chatgpt.com",
17775
+ "https://chat.openai.com"
17776
+ ];
16230
17777
  var ORIGIN_POLICY = buildOriginPolicy({
16231
17778
  allowedOriginsEnv: process.env.ALLOWED_ORIGINS,
16232
- configuredUrls: [APP_BASE_URL, MCP_SERVER_URL],
17779
+ configuredUrls: [
17780
+ APP_BASE_URL,
17781
+ MCP_SERVER_URL,
17782
+ ...TRUSTED_BROWSER_MCP_CLIENTS
17783
+ ],
16233
17784
  nodeEnv: NODE_ENV
16234
17785
  });
16235
17786
  function deriveOAuthIssuerUrl() {
@@ -16254,6 +17805,18 @@ function deriveOAuthIssuerUrl() {
16254
17805
  return "https://mcp.socialneuron.com";
16255
17806
  }
16256
17807
  var OAUTH_ISSUER_URL = deriveOAuthIssuerUrl();
17808
+ function logOperationalError(label, error) {
17809
+ const category = error === void 0 ? "An internal error occurred." : sanitizeError(error);
17810
+ console.error(`[MCP HTTP] ${label}: ${category}`);
17811
+ }
17812
+ function safeEndpointForLog(value) {
17813
+ try {
17814
+ const url = new URL(value);
17815
+ return `${url.origin}${url.pathname}`;
17816
+ } catch {
17817
+ return "[invalid endpoint configuration]";
17818
+ }
17819
+ }
16257
17820
  if (!SUPABASE_URL2 || !SUPABASE_ANON_KEY) {
16258
17821
  console.error("[MCP HTTP] Missing SUPABASE_URL or SUPABASE_ANON_KEY");
16259
17822
  process.exit(1);
@@ -16265,17 +17828,19 @@ if (SUPABASE_SERVICE_ROLE_KEY && SUPABASE_SERVICE_ROLE_KEY.length < 100) {
16265
17828
  );
16266
17829
  }
16267
17830
  process.on("uncaughtException", (err) => {
16268
- console.error(`[MCP HTTP] Uncaught exception: ${err.message}`);
17831
+ logOperationalError("Uncaught exception", err);
16269
17832
  process.exit(1);
16270
17833
  });
16271
17834
  process.on("unhandledRejection", (reason) => {
16272
- const message = reason instanceof Error ? reason.message : String(reason);
16273
- console.error(`[MCP HTTP] Unhandled rejection: ${message}`);
17835
+ logOperationalError("Unhandled rejection", reason);
16274
17836
  process.exit(1);
16275
17837
  });
16276
17838
  var tokenVerifier = createTokenVerifier({
16277
17839
  supabaseUrl: SUPABASE_URL2,
16278
- supabaseAnonKey: SUPABASE_ANON_KEY
17840
+ supabaseAnonKey: SUPABASE_ANON_KEY,
17841
+ // Match RFC 9728 metadata exactly. Opaque connector tokens minted for the
17842
+ // issuer origin must not be replayable at the more-specific /mcp resource.
17843
+ resource: MCP_SERVER_URL
16279
17844
  });
16280
17845
  initPostHog();
16281
17846
  var MAX_SESSIONS = 500;
@@ -16289,15 +17854,46 @@ function countUserSessions(userId) {
16289
17854
  }
16290
17855
  return count;
16291
17856
  }
17857
+ async function closeSessionEntry(sessionId, entry) {
17858
+ sessions.delete(sessionId);
17859
+ for (const close of [
17860
+ () => entry.transport.close(),
17861
+ () => entry.server.close()
17862
+ ]) {
17863
+ try {
17864
+ await close();
17865
+ } catch {
17866
+ }
17867
+ }
17868
+ }
17869
+ async function reclaimOldestIdleSession(userId) {
17870
+ const sessionId = findOldestIdleSessionId(sessions, userId);
17871
+ if (!sessionId) return false;
17872
+ const entry = sessions.get(sessionId);
17873
+ if (!entry) return false;
17874
+ await closeSessionEntry(sessionId, entry);
17875
+ console.log(
17876
+ `[MCP HTTP] Reclaimed one idle ${userId ? "user" : "global"} session.`
17877
+ );
17878
+ return true;
17879
+ }
17880
+ async function runInSession(entry, callback) {
17881
+ entry.activeRequests += 1;
17882
+ entry.lastActivity = Date.now();
17883
+ try {
17884
+ return await callback();
17885
+ } finally {
17886
+ entry.activeRequests = Math.max(0, entry.activeRequests - 1);
17887
+ entry.lastActivity = Date.now();
17888
+ }
17889
+ }
16292
17890
  var cleanupInterval = setInterval(
16293
17891
  () => {
16294
17892
  const now = Date.now();
16295
17893
  for (const [sessionId, entry] of sessions) {
16296
- if (now - entry.lastActivity > SESSION_TIMEOUT_MS) {
16297
- entry.transport.close();
16298
- entry.server.close();
16299
- sessions.delete(sessionId);
16300
- console.log(`[MCP HTTP] Cleaned up stale session: ${sessionId}`);
17894
+ if (now - entry.lastActivity > SESSION_TIMEOUT_MS && entry.activeRequests === 0) {
17895
+ void closeSessionEntry(sessionId, entry);
17896
+ console.log("[MCP HTTP] Cleaned up one stale session.");
16301
17897
  }
16302
17898
  }
16303
17899
  },
@@ -16320,7 +17916,7 @@ setInterval(() => {
16320
17916
  }
16321
17917
  }, IP_RATE_CLEANUP_INTERVAL).unref();
16322
17918
  app.use((req, res, next) => {
16323
- if (req.path === "/health" || req.path === "/.well-known/oauth-protected-resource" || req.path === "/.well-known/oauth-authorization-server" || req.path === "/config")
17919
+ if (req.path === "/health" || PROTECTED_RESOURCE_METADATA_PATHS.includes(req.path) || req.path === "/.well-known/oauth-authorization-server" || req.path === "/config")
16324
17920
  return next();
16325
17921
  const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
16326
17922
  const now = Date.now();
@@ -16391,7 +17987,8 @@ app.use((req, res, next) => {
16391
17987
  var oauthProvider = createOAuthProvider({
16392
17988
  supabaseUrl: SUPABASE_URL2,
16393
17989
  supabaseAnonKey: SUPABASE_ANON_KEY,
16394
- appBaseUrl: APP_BASE_URL
17990
+ appBaseUrl: APP_BASE_URL,
17991
+ resource: MCP_SERVER_URL
16395
17992
  });
16396
17993
  var SCOPES_SUPPORTED = [
16397
17994
  "mcp:full",
@@ -16402,7 +17999,7 @@ var SCOPES_SUPPORTED = [
16402
17999
  "mcp:comments",
16403
18000
  "mcp:autopilot"
16404
18001
  ];
16405
- app.get("/.well-known/oauth-protected-resource", (_req, res) => {
18002
+ app.get(PROTECTED_RESOURCE_METADATA_PATHS, (_req, res) => {
16406
18003
  res.json(
16407
18004
  buildProtectedResourceMetadata({
16408
18005
  resourceUrl: MCP_SERVER_URL,
@@ -16458,7 +18055,7 @@ app.use((req, _res, next) => {
16458
18055
  app.use((req, res, next) => {
16459
18056
  authRouter(req, res, (err) => {
16460
18057
  if (err) {
16461
- console.error("[MCP HTTP] Auth router error:", err);
18058
+ logOperationalError("Auth router error", err);
16462
18059
  }
16463
18060
  next(err);
16464
18061
  });
@@ -16491,8 +18088,8 @@ async function authenticateRequest(req, res, next) {
16491
18088
  projectId: authInfo.extra?.projectId ?? null
16492
18089
  };
16493
18090
  next();
16494
- } catch (err) {
16495
- const message = err instanceof Error ? err.message : "Token verification failed";
18091
+ } catch {
18092
+ const message = "The access token is invalid, expired, or not authorized for MCP.";
16496
18093
  res.setHeader(
16497
18094
  "WWW-Authenticate",
16498
18095
  buildWwwAuthenticateHeader({
@@ -16600,11 +18197,7 @@ app.get(
16600
18197
  status: "ok",
16601
18198
  version: MCP_VERSION,
16602
18199
  transport: "streamable-http",
16603
- sessions: sessions.size,
16604
- sessionCap: MAX_SESSIONS,
16605
- uptime: Math.floor(process.uptime()),
16606
- memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
16607
- env: NODE_ENV
18200
+ uptime: Math.floor(process.uptime())
16608
18201
  });
16609
18202
  }
16610
18203
  );
@@ -16656,7 +18249,8 @@ app.post(
16656
18249
  });
16657
18250
  return;
16658
18251
  }
16659
- const rl = checkRateLimit("read", `rest:${auth.userId}`);
18252
+ const category = rateLimitCategoryForTool(name);
18253
+ const rl = checkRateLimit(category, `rest:${auth.userId}`);
16660
18254
  if (!rl.allowed) {
16661
18255
  res.setHeader("Retry-After", String(rl.retryAfter));
16662
18256
  res.status(429).json({
@@ -16723,7 +18317,9 @@ app.post(
16723
18317
  app.post("/mcp", async (req, res) => {
16724
18318
  const auth = req.auth;
16725
18319
  const existingSessionId = req.headers["mcp-session-id"];
16726
- const rl = checkRateLimit("read", auth.userId);
18320
+ const requestedTool = req.body?.method === "tools/call" && typeof req.body?.params?.name === "string" ? req.body.params.name : void 0;
18321
+ const category = rateLimitCategoryForTool(requestedTool);
18322
+ const rl = checkRateLimit(category, `mcp:${auth.userId}`);
16727
18323
  if (!rl.allowed) {
16728
18324
  res.setHeader("Retry-After", String(rl.retryAfter));
16729
18325
  res.status(429).json({
@@ -16743,31 +18339,33 @@ app.post("/mcp", async (req, res) => {
16743
18339
  });
16744
18340
  return;
16745
18341
  }
16746
- entry.lastActivity = Date.now();
16747
- await requestContext.run(
16748
- {
16749
- userId: auth.userId,
16750
- scopes: auth.scopes,
16751
- token: auth.token,
16752
- creditsUsed: 0,
16753
- assetsGenerated: 0,
16754
- projectId: auth.projectId
16755
- },
16756
- () => entry.transport.handleRequest(req, res, req.body)
18342
+ await runInSession(
18343
+ entry,
18344
+ () => requestContext.run(
18345
+ {
18346
+ userId: auth.userId,
18347
+ scopes: auth.scopes,
18348
+ token: auth.token,
18349
+ creditsUsed: 0,
18350
+ assetsGenerated: 0,
18351
+ projectId: auth.projectId
18352
+ },
18353
+ () => entry.transport.handleRequest(req, res, req.body)
18354
+ )
16757
18355
  );
16758
18356
  return;
16759
18357
  }
16760
- if (sessions.size >= MAX_SESSIONS) {
18358
+ if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER && !await reclaimOldestIdleSession(auth.userId)) {
16761
18359
  res.status(429).json({
16762
18360
  error: "too_many_sessions",
16763
- error_description: `Server session limit reached (${MAX_SESSIONS}). Try again later.`
18361
+ error_description: `Per-user session limit reached (${MAX_SESSIONS_PER_USER}); all sessions are active. Close an existing client and retry.`
16764
18362
  });
16765
18363
  return;
16766
18364
  }
16767
- if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER) {
18365
+ if (sessions.size >= MAX_SESSIONS && !await reclaimOldestIdleSession()) {
16768
18366
  res.status(429).json({
16769
18367
  error: "too_many_sessions",
16770
- error_description: `Per-user session limit reached (${MAX_SESSIONS_PER_USER}). Close existing sessions or wait for timeout.`
18368
+ error_description: `Server session limit reached (${MAX_SESSIONS}); all sessions are active. Try again later.`
16771
18369
  });
16772
18370
  return;
16773
18371
  }
@@ -16782,14 +18380,17 @@ app.post("/mcp", async (req, res) => {
16782
18380
  });
16783
18381
  registerPrompts(server);
16784
18382
  registerResources(server);
18383
+ let initializedSessionId = null;
16785
18384
  const transport = new StreamableHTTPServerTransport({
16786
18385
  sessionIdGenerator: () => randomUUID4(),
16787
18386
  onsessioninitialized: (sessionId) => {
18387
+ initializedSessionId = sessionId;
16788
18388
  sessions.set(sessionId, {
16789
18389
  transport,
16790
18390
  server,
16791
18391
  lastActivity: Date.now(),
16792
- userId: auth.userId
18392
+ userId: auth.userId,
18393
+ activeRequests: 1
16793
18394
  });
16794
18395
  }
16795
18396
  });
@@ -16799,20 +18400,29 @@ app.post("/mcp", async (req, res) => {
16799
18400
  }
16800
18401
  };
16801
18402
  await server.connect(transport);
16802
- await requestContext.run(
16803
- {
16804
- userId: auth.userId,
16805
- scopes: auth.scopes,
16806
- token: auth.token,
16807
- creditsUsed: 0,
16808
- assetsGenerated: 0,
16809
- projectId: auth.projectId
16810
- },
16811
- () => transport.handleRequest(req, res, req.body)
16812
- );
18403
+ try {
18404
+ await requestContext.run(
18405
+ {
18406
+ userId: auth.userId,
18407
+ scopes: auth.scopes,
18408
+ token: auth.token,
18409
+ creditsUsed: 0,
18410
+ assetsGenerated: 0,
18411
+ projectId: auth.projectId
18412
+ },
18413
+ () => transport.handleRequest(req, res, req.body)
18414
+ );
18415
+ } finally {
18416
+ if (initializedSessionId) {
18417
+ const entry = sessions.get(initializedSessionId);
18418
+ if (entry) {
18419
+ entry.activeRequests = Math.max(0, entry.activeRequests - 1);
18420
+ entry.lastActivity = Date.now();
18421
+ }
18422
+ }
18423
+ }
16813
18424
  } catch (err) {
16814
- const rawMessage = err instanceof Error ? err.message : "Internal server error";
16815
- console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
18425
+ logOperationalError("POST /mcp error", err);
16816
18426
  if (!res.headersSent) {
16817
18427
  res.status(500).json({
16818
18428
  jsonrpc: "2.0",
@@ -16832,19 +18442,21 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
16832
18442
  res.status(403).json({ error: "Session belongs to another user" });
16833
18443
  return;
16834
18444
  }
16835
- entry.lastActivity = Date.now();
16836
18445
  res.setHeader("X-Accel-Buffering", "no");
16837
18446
  res.setHeader("Cache-Control", "no-cache");
16838
- await requestContext.run(
16839
- {
16840
- userId: req.auth.userId,
16841
- scopes: req.auth.scopes,
16842
- token: req.auth.token,
16843
- creditsUsed: 0,
16844
- assetsGenerated: 0,
16845
- projectId: req.auth.projectId
16846
- },
16847
- () => entry.transport.handleRequest(req, res)
18447
+ await runInSession(
18448
+ entry,
18449
+ () => requestContext.run(
18450
+ {
18451
+ userId: req.auth.userId,
18452
+ scopes: req.auth.scopes,
18453
+ token: req.auth.token,
18454
+ creditsUsed: 0,
18455
+ assetsGenerated: 0,
18456
+ projectId: req.auth.projectId
18457
+ },
18458
+ () => entry.transport.handleRequest(req, res)
18459
+ )
16848
18460
  );
16849
18461
  });
16850
18462
  app.delete(
@@ -16861,18 +18473,13 @@ app.delete(
16861
18473
  res.status(403).json({ error: "Session belongs to another user" });
16862
18474
  return;
16863
18475
  }
16864
- await entry.transport.close();
16865
- await entry.server.close();
16866
- sessions.delete(sessionId);
18476
+ await closeSessionEntry(sessionId, entry);
16867
18477
  res.status(200).json({ status: "session_closed" });
16868
18478
  }
16869
18479
  );
16870
18480
  app.use(
16871
18481
  (err, _req, res, _next) => {
16872
- console.error(
16873
- "[MCP HTTP] Unhandled Express error:",
16874
- err.stack || err.message || err
16875
- );
18482
+ logOperationalError("Unhandled Express error", err);
16876
18483
  if (res.headersSent) return;
16877
18484
  const e = err;
16878
18485
  const status = e.status ?? e.statusCode;
@@ -16891,9 +18498,8 @@ var httpServer = app.listen(PORT, "0.0.0.0", () => {
16891
18498
  `[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`
16892
18499
  );
16893
18500
  console.log(`[MCP HTTP] Health: http://localhost:${PORT}/health`);
16894
- console.log(`[MCP HTTP] MCP endpoint: ${MCP_SERVER_URL}`);
18501
+ console.log(`[MCP HTTP] MCP endpoint: ${safeEndpointForLog(MCP_SERVER_URL)}`);
16895
18502
  console.log(`[MCP HTTP] Tool profile: ${TOOL_PROFILE}`);
16896
- console.log(`[MCP HTTP] Environment: ${NODE_ENV}`);
16897
18503
  });
16898
18504
  async function shutdown(signal) {
16899
18505
  console.log(`[MCP HTTP] ${signal} received, shutting down...`);