@socialneuron/mcp-server 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1";
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: [
@@ -2760,7 +3100,7 @@ function registerContentTools(server) {
2760
3100
  isError: true
2761
3101
  };
2762
3102
  }
2763
- const rateLimit = checkRateLimit("posting", `generate_image:${userId}`);
3103
+ const rateLimit = checkRateLimit("generation", `generate_image:${userId}`);
2764
3104
  if (!rateLimit.allowed) {
2765
3105
  return {
2766
3106
  content: [
@@ -2900,20 +3240,28 @@ function registerContentTools(server) {
2900
3240
  }
2901
3241
  );
2902
3242
  if (liveStatus) {
3243
+ const livePayload = buildCheckStatusPayload(job, liveStatus);
2903
3244
  const lines2 = [
2904
3245
  `Job: ${job.id}`,
2905
3246
  `Type: ${job.job_type}`,
2906
3247
  `Model: ${job.model}`,
2907
- `Status: ${liveStatus.status}`,
2908
- `Progress: ${liveStatus.progress}%`
3248
+ `Status: ${livePayload.status}`,
3249
+ `Progress: ${livePayload.progress}%`
2909
3250
  ];
2910
- if (liveStatus.resultUrl) {
2911
- lines2.push(`Result URL: ${liveStatus.resultUrl}`);
3251
+ if (livePayload.result_url) {
3252
+ lines2.push(`Result URL: ${livePayload.result_url}`);
2912
3253
  }
2913
- if (liveStatus.error) {
2914
- lines2.push(`Error: ${liveStatus.error}`);
3254
+ if (livePayload.error) {
3255
+ lines2.push(`Error: ${livePayload.error}`);
2915
3256
  }
3257
+ const fallbackDisclosure2 = buildFallbackDisclosureLine(job.result_metadata);
3258
+ if (fallbackDisclosure2) lines2.push(fallbackDisclosure2);
2916
3259
  lines2.push(`Credits: ${job.credits_cost}`);
3260
+ if (job.billing_status) {
3261
+ lines2.push(
3262
+ `Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
3263
+ );
3264
+ }
2917
3265
  lines2.push(`Created: ${job.created_at}`);
2918
3266
  if (format === "json") {
2919
3267
  return {
@@ -2931,7 +3279,7 @@ function registerContentTools(server) {
2931
3279
  // `job` + `liveStatus`; do not duplicate them here.
2932
3280
  asEnvelope2({
2933
3281
  ...liveStatus,
2934
- ...buildCheckStatusPayload(job, liveStatus)
3282
+ ...livePayload
2935
3283
  }),
2936
3284
  null,
2937
3285
  2
@@ -2974,7 +3322,14 @@ function registerContentTools(server) {
2974
3322
  if (job.error_message) {
2975
3323
  lines.push(`Error: ${job.error_message}`);
2976
3324
  }
3325
+ const fallbackDisclosure = buildFallbackDisclosureLine(job.result_metadata);
3326
+ if (fallbackDisclosure) lines.push(fallbackDisclosure);
2977
3327
  lines.push(`Credits: ${job.credits_cost}`);
3328
+ if (job.billing_status) {
3329
+ lines.push(
3330
+ `Billing: ${job.billing_status} (charged ${job.credits_charged ?? 0}, refunded ${job.credits_refunded ?? 0})`
3331
+ );
3332
+ }
2978
3333
  lines.push(`Created: ${job.created_at}`);
2979
3334
  if (job.completed_at) {
2980
3335
  lines.push(`Completed: ${job.completed_at}`);
@@ -3204,7 +3559,7 @@ Return ONLY valid JSON in this exact format:
3204
3559
  };
3205
3560
  }
3206
3561
  const rateLimit = checkRateLimit(
3207
- "posting",
3562
+ "generation",
3208
3563
  `generate_voiceover:${userId}`
3209
3564
  );
3210
3565
  if (!rateLimit.allowed) {
@@ -3371,7 +3726,7 @@ Return ONLY valid JSON in this exact format:
3371
3726
  }
3372
3727
  const userId = await getDefaultUserId();
3373
3728
  const rateLimit = checkRateLimit(
3374
- "posting",
3729
+ "generation",
3375
3730
  `generate_carousel:${userId}`
3376
3731
  );
3377
3732
  if (!rateLimit.allowed) {
@@ -3473,7 +3828,7 @@ Return ONLY valid JSON in this exact format:
3473
3828
 
3474
3829
  // src/tools/distribution.ts
3475
3830
  import { z as z3 } from "zod";
3476
- import { createHash as createHash2 } from "node:crypto";
3831
+ import { createHash } from "node:crypto";
3477
3832
 
3478
3833
  // src/lib/sanitize-error.ts
3479
3834
  var ERROR_PATTERNS = [
@@ -3512,9 +3867,6 @@ var ERROR_PATTERNS = [
3512
3867
  ];
3513
3868
  function sanitizeError(error) {
3514
3869
  const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
3515
- if (process.env.NODE_ENV !== "production") {
3516
- console.error("[Error]", msg);
3517
- }
3518
3870
  for (const [pattern, userMessage] of ERROR_PATTERNS) {
3519
3871
  if (pattern.test(msg)) {
3520
3872
  return userMessage;
@@ -3685,6 +4037,7 @@ function evaluateQuality(input) {
3685
4037
  const firstLine = caption.split("\n")[0]?.trim() ?? "";
3686
4038
  const hashtags = countHashtags(caption);
3687
4039
  const threshold = Math.min(35, Math.max(0, input.threshold ?? 26));
4040
+ const isShortFormX = platforms.length > 0 && platforms.every((p) => p === "twitter") && caption.length <= 280;
3688
4041
  const blockedTerms = [
3689
4042
  ...(input.brandAvoidPatterns ?? []).map((t) => t.trim()).filter(Boolean),
3690
4043
  ...(input.customBannedTerms ?? []).map((t) => t.trim()).filter(Boolean)
@@ -3694,11 +4047,12 @@ function evaluateQuality(input) {
3694
4047
  if (firstLine.length >= 20 && firstLine.length <= 120) hookScore += 1;
3695
4048
  if (/[!?]/.test(firstLine) || /\b\d+(\.\d+)?\b/.test(firstLine)) hookScore += 1;
3696
4049
  if (/\b(how|why|stop|avoid|build|launch|scale|grow|mistake)\b/i.test(firstLine)) hookScore += 1;
4050
+ if (isShortFormX) hookScore = Math.max(hookScore, 3);
3697
4051
  categories.push({
3698
4052
  name: "Hook Strength",
3699
4053
  score: Math.min(5, hookScore),
3700
4054
  maxScore: 5,
3701
- detail: "First line should create curiosity/value within 120 chars."
4055
+ detail: isShortFormX ? "Short-form X: keyword-hook heuristics neutralized; substance judge arbitrates." : "First line should create curiosity/value within 120 chars."
3702
4056
  });
3703
4057
  let clarityScore = 2;
3704
4058
  if (caption.length >= 80 && caption.length <= 1200) clarityScore += 2;
@@ -3724,7 +4078,8 @@ function evaluateQuality(input) {
3724
4078
  detail: "Length, title, and hashtag usage should match target platforms."
3725
4079
  });
3726
4080
  let brandScore = 3;
3727
- const 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;
3728
4083
  if (brandKeyword && new RegExp("\\b" + brandKeyword + "\\b", "i").test(title + " " + caption))
3729
4084
  brandScore += 1;
3730
4085
  if (!/\b(you|your|customer|audience)\b/i.test(caption)) brandScore -= 1;
@@ -3735,11 +4090,12 @@ function evaluateQuality(input) {
3735
4090
  brandScore -= Math.min(2, matched.length);
3736
4091
  }
3737
4092
  }
4093
+ if (isShortFormX) brandScore = Math.max(brandScore, 3);
3738
4094
  categories.push({
3739
4095
  name: "Brand Alignment",
3740
4096
  score: Math.max(0, Math.min(5, brandScore)),
3741
4097
  maxScore: 5,
3742
- detail: "Voice should match brand context and audience focus."
4098
+ detail: isShortFormX ? "Short-form X: second-person-address heuristic neutralized; blocked terms still enforced." : "Voice should match brand context and audience focus."
3743
4099
  });
3744
4100
  let noveltyScore = 2;
3745
4101
  if (/\b(case study|framework|workflow|playbook|breakdown|behind the scenes)\b/i.test(caption))
@@ -3750,21 +4106,23 @@ function evaluateQuality(input) {
3750
4106
  const matched = blockedTerms.filter((term) => lowerCombined.includes(term.toLowerCase()));
3751
4107
  if (matched.length > 0) noveltyScore -= Math.min(2, matched.length);
3752
4108
  }
4109
+ if (isShortFormX) noveltyScore = Math.max(noveltyScore, 3);
3753
4110
  categories.push({
3754
4111
  name: "Novelty",
3755
4112
  score: Math.max(0, Math.min(5, noveltyScore)),
3756
4113
  maxScore: 5,
3757
- detail: "Avoid generic phrasing; include distinct angle."
4114
+ detail: isShortFormX ? "Short-form X: keyword-novelty neutralized; substance judge arbitrates." : "Avoid generic phrasing; include distinct angle."
3758
4115
  });
3759
4116
  let ctaScore = 2;
3760
4117
  if (/\b(comment|reply|share|save|follow|subscribe|click|try|book|download)\b/i.test(caption))
3761
4118
  ctaScore += 2;
3762
4119
  if (/\?$/.test(firstLine)) ctaScore += 1;
4120
+ if (isShortFormX) ctaScore = Math.max(ctaScore, 3);
3763
4121
  categories.push({
3764
4122
  name: "CTA Strength",
3765
4123
  score: Math.min(5, ctaScore),
3766
4124
  maxScore: 5,
3767
- detail: "Should include a clear next action."
4125
+ detail: isShortFormX ? "Short-form X: explicit CTA optional; native posts may close without one." : "Should include a clear next action."
3768
4126
  });
3769
4127
  let safetyScore = 5;
3770
4128
  if (/\b(guarantee|guaranteed|no risk|risk-free|always works|100%)\b/i.test(caption))
@@ -3822,7 +4180,9 @@ var PLATFORM_CASE_MAP = {
3822
4180
  threads: "Threads",
3823
4181
  bluesky: "Bluesky"
3824
4182
  };
3825
- var TIKTOK_AUDIT_APPROVED = false;
4183
+ var TIKTOK_AUDIT_APPROVED = !["false", "0", "no"].includes(
4184
+ (process.env.TIKTOK_AUDIT_APPROVED ?? "").toLowerCase()
4185
+ );
3826
4186
  var MCP_NOT_LIVE_FOR_POSTING = /* @__PURE__ */ new Set(["LinkedIn", "Pinterest", "Reddit"]);
3827
4187
  function asEnvelope3(data) {
3828
4188
  return {
@@ -3833,6 +4193,106 @@ function asEnvelope3(data) {
3833
4193
  data
3834
4194
  };
3835
4195
  }
4196
+ function publicConnectedAccount(value) {
4197
+ if (!value || typeof value !== "object") return null;
4198
+ const row = value;
4199
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
4200
+ return null;
4201
+ }
4202
+ return {
4203
+ id: row.id,
4204
+ platform: row.platform,
4205
+ status: row.status,
4206
+ ...typeof row.effective_status === "string" ? { effective_status: row.effective_status } : {},
4207
+ username: typeof row.username === "string" ? row.username : null,
4208
+ created_at: row.created_at,
4209
+ ...typeof row.updated_at === "string" || row.updated_at === null ? { updated_at: row.updated_at } : {},
4210
+ ...typeof row.expires_at === "string" || row.expires_at === null ? { expires_at: row.expires_at } : {},
4211
+ ...typeof row.has_refresh_token === "boolean" ? { has_refresh_token: row.has_refresh_token } : {},
4212
+ ...typeof row.project_id === "string" || row.project_id === null ? { project_id: row.project_id } : {}
4213
+ };
4214
+ }
4215
+ function publicPostRecord(value) {
4216
+ if (!value || typeof value !== "object") return null;
4217
+ const row = value;
4218
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
4219
+ return null;
4220
+ }
4221
+ const nullableString = (field) => typeof row[field] === "string" ? row[field] : null;
4222
+ return {
4223
+ id: row.id,
4224
+ platform: row.platform,
4225
+ status: row.status,
4226
+ title: nullableString("title"),
4227
+ external_post_id: nullableString("external_post_id"),
4228
+ published_at: nullableString("published_at"),
4229
+ scheduled_at: nullableString("scheduled_at"),
4230
+ created_at: row.created_at
4231
+ };
4232
+ }
4233
+ var VIDEO_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4234
+ "mp4",
4235
+ "mov",
4236
+ "m4v",
4237
+ "webm",
4238
+ "avi",
4239
+ "mkv",
4240
+ "mpeg",
4241
+ "mpg"
4242
+ ]);
4243
+ var IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4244
+ "jpg",
4245
+ "jpeg",
4246
+ "png",
4247
+ "webp",
4248
+ "gif",
4249
+ "avif",
4250
+ "heic"
4251
+ ]);
4252
+ function mediaTypeFromPath(value) {
4253
+ try {
4254
+ const pathname = value.startsWith("http") ? new URL(value).pathname : value;
4255
+ const extension = pathname.split(".").pop()?.toLowerCase();
4256
+ if (!extension) return null;
4257
+ if (VIDEO_FILE_EXTENSIONS.has(extension)) return "VIDEO";
4258
+ if (IMAGE_FILE_EXTENSIONS.has(extension)) return "IMAGE";
4259
+ } catch {
4260
+ return null;
4261
+ }
4262
+ return null;
4263
+ }
4264
+ function inferScheduleMediaType(explicit, singleCandidates, collectionCandidates) {
4265
+ if (explicit) return explicit;
4266
+ const populatedCollection = collectionCandidates.find(
4267
+ (values) => Array.isArray(values) && values.length > 0
4268
+ );
4269
+ if (populatedCollection) {
4270
+ if (populatedCollection.length > 1) return "CAROUSEL_ALBUM";
4271
+ for (const values of collectionCandidates) {
4272
+ if (!values || values.length !== 1) continue;
4273
+ const inferred = mediaTypeFromPath(values[0]);
4274
+ if (inferred) return inferred;
4275
+ }
4276
+ return null;
4277
+ }
4278
+ for (const value of singleCandidates) {
4279
+ if (typeof value !== "string" || value.length === 0) continue;
4280
+ const inferred = mediaTypeFromPath(value);
4281
+ if (inferred) return inferred;
4282
+ }
4283
+ return null;
4284
+ }
4285
+ async function validatePublishMediaUrl(url) {
4286
+ try {
4287
+ if (new URL(url).protocol !== "https:") {
4288
+ return "Media URLs must use HTTPS.";
4289
+ }
4290
+ } catch {
4291
+ return "Media URL is invalid.";
4292
+ }
4293
+ const check = await validateUrlForSSRF(url);
4294
+ return check.isValid ? null : check.error || "Media URL failed safety validation.";
4295
+ }
3836
4296
  function accountEffectiveStatus(account) {
3837
4297
  return account.effective_status || account.status;
3838
4298
  }
@@ -3851,22 +4311,11 @@ function requestedAccountIdForPlatform(accountId, accountIds, platform2) {
3851
4311
  if (!accountIds) return void 0;
3852
4312
  return accountIds[platform2] || accountIds[platform2.toLowerCase()];
3853
4313
  }
3854
- function isAlreadyR2Signed(url) {
3855
- try {
3856
- const u = new URL(url);
3857
- return u.searchParams.has("X-Amz-Signature");
3858
- } catch {
3859
- return false;
3860
- }
3861
- }
3862
4314
  async function rehostExternalUrl(mediaUrl, projectId) {
3863
4315
  const ssrf = await validateUrlForSSRF(mediaUrl);
3864
4316
  if (!ssrf.isValid) {
3865
4317
  return { error: ssrf.error ?? "URL rejected by SSRF check" };
3866
4318
  }
3867
- if (isAlreadyR2Signed(mediaUrl)) {
3868
- return { signedUrl: mediaUrl, r2Key: "" };
3869
- }
3870
4319
  const { data, error } = await callEdgeFunction(
3871
4320
  "upload-to-r2",
3872
4321
  { url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
@@ -3885,19 +4334,19 @@ function registerDistributionTools(server) {
3885
4334
  media_url: z3.string().optional().describe(
3886
4335
  "URL of the media file to post. Any public HTTPS URL works \u2014 including ephemeral generator URLs (Replicate, OpenAI, DALL-E). The server persists non-R2 URLs into R2 before posting so scheduled posts and byte-upload platforms (X, LinkedIn, YouTube, Bluesky) do not 404 when the source URL expires. Set auto_rehost=false to skip. Not needed if media_urls, r2_key, or job_id is provided."
3887
4336
  ),
3888
- media_urls: z3.array(z3.string()).optional().describe(
4337
+ media_urls: z3.array(z3.string().url()).min(2).max(10).optional().describe(
3889
4338
  "Array of 2-10 image URLs for carousel posts. Same rehosting rules as media_url \u2014 ephemeral URLs are persisted automatically. Use with media_type=CAROUSEL_ALBUM."
3890
4339
  ),
3891
4340
  r2_key: z3.string().optional().describe(
3892
4341
  "R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
3893
4342
  ),
3894
- r2_keys: z3.array(z3.string()).optional().describe(
4343
+ r2_keys: z3.array(z3.string()).min(2).max(10).optional().describe(
3895
4344
  "Array of R2 object keys for carousel posts. Each is signed on demand. Alternative to media_urls."
3896
4345
  ),
3897
4346
  job_id: z3.string().optional().describe(
3898
4347
  "Async job ID from generate_image/generate_video. Resolves the completed job's R2 key and signs it. Alternative to media_url/r2_key."
3899
4348
  ),
3900
- job_ids: z3.array(z3.string()).optional().describe(
4349
+ job_ids: z3.array(z3.string()).min(2).max(10).optional().describe(
3901
4350
  "Array of async job IDs for carousel posts. Each resolved to its R2 key. Alternative to media_urls/r2_keys."
3902
4351
  ),
3903
4352
  platform_metadata: z3.object({
@@ -3927,7 +4376,10 @@ function registerDistributionTools(server) {
3927
4376
  category_id: z3.string().optional(),
3928
4377
  tags: z3.array(z3.string()).optional(),
3929
4378
  made_for_kids: z3.boolean().optional(),
3930
- notify_subscribers: z3.boolean().optional()
4379
+ notify_subscribers: z3.boolean().optional(),
4380
+ contains_synthetic_media: z3.boolean().optional().describe(
4381
+ "YouTube altered-or-synthetic-content disclosure. Defaults to true for MCP posts; set false explicitly for verified non-AI media."
4382
+ )
3931
4383
  }).optional(),
3932
4384
  facebook: z3.object({
3933
4385
  page_id: z3.string().optional().describe("Facebook Page ID to post to."),
@@ -4010,14 +4462,8 @@ function registerDistributionTools(server) {
4010
4462
  auto_rehost: z3.boolean().optional().describe(
4011
4463
  "Whether to persist non-R2 media_url/media_urls into R2 before posting. Default: true. Set to false only if you know the source URL will outlive the scheduling window and every target platform supports URL ingest."
4012
4464
  ),
4013
- visual_gate_result: z3.object({ passed: z3.boolean() }).passthrough().optional().describe(
4014
- "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."
4015
- ),
4016
- origin: z3.enum(["human", "hermes", "user"]).optional().describe(
4017
- "Originator lineage for the post. Marks who scheduled it; use the agent value when calling from an autonomous workflow, otherwise default 'human'."
4018
- ),
4019
- hermes_run_id: z3.string().optional().describe(
4020
- "Optional agent run identifier for traceability between a draft and the run that produced it."
4465
+ idempotency_key: z3.string().regex(/^[a-zA-Z0-9_-]{8,128}$/).optional().describe(
4466
+ "Stable 8-128 character retry key (letters, numbers, underscore, hyphen). Reuse the same key when retrying the same publish request to prevent duplicate posts."
4021
4467
  )
4022
4468
  },
4023
4469
  async ({
@@ -4040,9 +4486,7 @@ function registerDistributionTools(server) {
4040
4486
  platform_metadata,
4041
4487
  account_id,
4042
4488
  account_ids,
4043
- visual_gate_result,
4044
- origin,
4045
- hermes_run_id
4489
+ idempotency_key
4046
4490
  }) => {
4047
4491
  const format = response_format ?? "text";
4048
4492
  if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
@@ -4071,6 +4515,8 @@ function registerDistributionTools(server) {
4071
4515
  }
4072
4516
  let resolvedMediaUrl = media_url;
4073
4517
  let resolvedMediaUrls = media_urls;
4518
+ let resolvedMediaUrlIsTrustedR2 = false;
4519
+ let resolvedMediaUrlsAreTrustedR2 = (media_urls ?? []).map(() => false);
4074
4520
  const signR2Key = async (key) => {
4075
4521
  const cleanKey = key.startsWith("r2://") ? key.slice(5) : key;
4076
4522
  const { data: signData } = await callEdgeFunction(
@@ -4088,8 +4534,11 @@ function registerDistributionTools(server) {
4088
4534
  );
4089
4535
  const resultUrl = jobData?.job?.result_url;
4090
4536
  if (!resultUrl) return null;
4091
- if (!resultUrl.startsWith("http")) return signR2Key(resultUrl);
4092
- 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 };
4093
4542
  };
4094
4543
  try {
4095
4544
  if (r2_key && !resolvedMediaUrl) {
@@ -4106,6 +4555,7 @@ function registerDistributionTools(server) {
4106
4555
  };
4107
4556
  }
4108
4557
  resolvedMediaUrl = signed;
4558
+ resolvedMediaUrlIsTrustedR2 = true;
4109
4559
  } else if (job_id && !resolvedMediaUrl && !r2_key) {
4110
4560
  const resolved = await resolveJobId(job_id);
4111
4561
  if (!resolved) {
@@ -4119,7 +4569,8 @@ function registerDistributionTools(server) {
4119
4569
  isError: true
4120
4570
  };
4121
4571
  }
4122
- resolvedMediaUrl = resolved;
4572
+ resolvedMediaUrl = resolved.url;
4573
+ resolvedMediaUrlIsTrustedR2 = resolved.trustedR2;
4123
4574
  }
4124
4575
  if (r2_keys && r2_keys.length > 0 && !resolvedMediaUrls) {
4125
4576
  const signed = await Promise.all(r2_keys.map(signR2Key));
@@ -4136,6 +4587,7 @@ function registerDistributionTools(server) {
4136
4587
  };
4137
4588
  }
4138
4589
  resolvedMediaUrls = signed;
4590
+ resolvedMediaUrlsAreTrustedR2 = signed.map(() => true);
4139
4591
  } else if (job_ids && job_ids.length > 0 && !resolvedMediaUrls && !r2_keys) {
4140
4592
  const resolved = await Promise.all(job_ids.map(resolveJobId));
4141
4593
  const failIdx = resolved.findIndex((r) => !r);
@@ -4150,10 +4602,12 @@ function registerDistributionTools(server) {
4150
4602
  isError: true
4151
4603
  };
4152
4604
  }
4153
- resolvedMediaUrls = resolved;
4605
+ const resolvedJobs = resolved;
4606
+ resolvedMediaUrls = resolvedJobs.map((item) => item.url);
4607
+ resolvedMediaUrlsAreTrustedR2 = resolvedJobs.map((item) => item.trustedR2);
4154
4608
  }
4155
4609
  const shouldRehost = auto_rehost !== false;
4156
- if (shouldRehost && resolvedMediaUrl) {
4610
+ if (shouldRehost && resolvedMediaUrl && !resolvedMediaUrlIsTrustedR2) {
4157
4611
  const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
4158
4612
  if ("error" in rehost) {
4159
4613
  return {
@@ -4167,10 +4621,13 @@ function registerDistributionTools(server) {
4167
4621
  };
4168
4622
  }
4169
4623
  resolvedMediaUrl = rehost.signedUrl;
4624
+ resolvedMediaUrlIsTrustedR2 = true;
4170
4625
  }
4171
4626
  if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
4172
4627
  const rehosted = await Promise.all(
4173
- resolvedMediaUrls.map((u) => rehostExternalUrl(u, project_id))
4628
+ resolvedMediaUrls.map(
4629
+ (u, index) => resolvedMediaUrlsAreTrustedR2[index] ? Promise.resolve({ signedUrl: u, r2Key: "" }) : rehostExternalUrl(u, project_id)
4630
+ )
4174
4631
  );
4175
4632
  const failIdx = rehosted.findIndex((r) => "error" in r);
4176
4633
  if (failIdx !== -1) {
@@ -4186,6 +4643,7 @@ function registerDistributionTools(server) {
4186
4643
  };
4187
4644
  }
4188
4645
  resolvedMediaUrls = rehosted.map((r) => r.signedUrl);
4646
+ resolvedMediaUrlsAreTrustedR2 = resolvedMediaUrls.map(() => true);
4189
4647
  }
4190
4648
  } catch (resolveErr) {
4191
4649
  return {
@@ -4198,6 +4656,42 @@ function registerDistributionTools(server) {
4198
4656
  isError: true
4199
4657
  };
4200
4658
  }
4659
+ const hasResolvedMedia = Boolean(
4660
+ resolvedMediaUrl || resolvedMediaUrls && resolvedMediaUrls.length > 0
4661
+ );
4662
+ const resolvedMediaType = inferScheduleMediaType(
4663
+ media_type,
4664
+ [resolvedMediaUrl, r2_key],
4665
+ [resolvedMediaUrls, r2_keys]
4666
+ );
4667
+ if (hasResolvedMedia && !resolvedMediaType) {
4668
+ return {
4669
+ content: [
4670
+ {
4671
+ type: "text",
4672
+ text: "media_type is required when the media format cannot be inferred from a file extension. Set IMAGE, VIDEO, or CAROUSEL_ALBUM explicitly."
4673
+ }
4674
+ ],
4675
+ isError: true
4676
+ };
4677
+ }
4678
+ const finalUrls = [resolvedMediaUrl, ...resolvedMediaUrls ?? []].filter(
4679
+ (value) => typeof value === "string" && value.length > 0
4680
+ );
4681
+ for (const url of finalUrls) {
4682
+ const validationError = await validatePublishMediaUrl(url);
4683
+ if (validationError) {
4684
+ return {
4685
+ content: [
4686
+ {
4687
+ type: "text",
4688
+ text: `Media URL blocked: ${validationError}`
4689
+ }
4690
+ ],
4691
+ isError: true
4692
+ };
4693
+ }
4694
+ }
4201
4695
  const normalizedPlatforms = platforms.map(
4202
4696
  (p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p
4203
4697
  );
@@ -4323,12 +4817,32 @@ Created with Social Neuron`;
4323
4817
  };
4324
4818
  return true;
4325
4819
  })();
4820
+ const defaultAiDisclosure = (platformKey, field) => {
4821
+ const existing = normalizedPlatformMetadata?.[platformKey];
4822
+ if (existing && existing[field] !== void 0) return;
4823
+ normalizedPlatformMetadata = {
4824
+ ...normalizedPlatformMetadata ?? {},
4825
+ [platformKey]: {
4826
+ ...existing ?? {},
4827
+ [field]: true
4828
+ }
4829
+ };
4830
+ };
4831
+ if (normalizedPlatforms.includes("TikTok")) {
4832
+ defaultAiDisclosure("tiktok", "is_ai_generated");
4833
+ }
4834
+ if (normalizedPlatforms.includes("Instagram")) {
4835
+ defaultAiDisclosure("instagram", "is_ai_generated");
4836
+ }
4837
+ if (normalizedPlatforms.includes("YouTube")) {
4838
+ defaultAiDisclosure("youtube", "contains_synthetic_media");
4839
+ }
4326
4840
  const { data, error } = await callEdgeFunction(
4327
4841
  "schedule-post",
4328
4842
  {
4329
4843
  mediaUrl: resolvedMediaUrl,
4330
4844
  mediaUrls: resolvedMediaUrls,
4331
- mediaType: media_type,
4845
+ mediaType: resolvedMediaType ?? void 0,
4332
4846
  caption: finalCaption,
4333
4847
  platforms: normalizedPlatforms,
4334
4848
  title,
@@ -4341,14 +4855,10 @@ Created with Social Neuron`;
4341
4855
  normalizedPlatformMetadata
4342
4856
  )
4343
4857
  } : {},
4344
- // Forward visual gate verdict + source so the EF can enforce on media posts.
4345
- ...visual_gate_result ? { visualGateResult: visual_gate_result } : {},
4346
- visualGateSource: "mcp",
4347
- // Originator lineage (Hermes integration, 2026-05-22). EF validates origin
4348
- // against the posts.origin CHECK constraint and persists hermesRunId when
4349
- // origin === 'hermes'.
4350
- ...origin ? { origin } : {},
4351
- ...hermes_run_id ? { hermesRunId: hermes_run_id } : {}
4858
+ ...idempotency_key ? { idempotencyKey: idempotency_key } : {}
4859
+ // Attribution is assigned by the authenticated gateway. Visual QA
4860
+ // attestations are server-produced evidence and are deliberately not
4861
+ // accepted from an MCP caller.
4352
4862
  },
4353
4863
  { timeoutMs: 3e4 }
4354
4864
  );
@@ -4414,6 +4924,99 @@ Created with Social Neuron`;
4414
4924
  };
4415
4925
  }
4416
4926
  );
4927
+ server.tool(
4928
+ "reschedule_post",
4929
+ "Move an existing pending or scheduled post to a new future time without creating a duplicate. Pass project_id for the post's brand. expected_scheduled_at is recommended: it prevents overwriting a change made in another client after the calendar was loaded.",
4930
+ {
4931
+ post_id: z3.string().uuid().describe("Post ID returned by list_recent_posts."),
4932
+ project_id: z3.string().uuid().optional().describe(
4933
+ "Brand/project ID that owns the post. Defaults to the authenticated key's project or the account default."
4934
+ ),
4935
+ scheduled_at: z3.string().datetime({ offset: true }).describe("New future publish time as an ISO 8601 datetime with timezone."),
4936
+ expected_scheduled_at: z3.string().datetime({ offset: true }).optional().describe(
4937
+ "Optional current schedule timestamp. If it changed since you read it, the update is rejected instead of silently overwriting it."
4938
+ ),
4939
+ response_format: z3.enum(["text", "json"]).default("text")
4940
+ },
4941
+ async ({
4942
+ post_id,
4943
+ project_id,
4944
+ scheduled_at,
4945
+ expected_scheduled_at,
4946
+ response_format
4947
+ }) => {
4948
+ const next = new Date(scheduled_at);
4949
+ if (!Number.isFinite(next.getTime()) || next.getTime() <= Date.now()) {
4950
+ return {
4951
+ content: [
4952
+ {
4953
+ type: "text",
4954
+ text: "scheduled_at must be a valid future ISO datetime with timezone."
4955
+ }
4956
+ ],
4957
+ isError: true
4958
+ };
4959
+ }
4960
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
4961
+ if (!resolvedProjectId) {
4962
+ return {
4963
+ content: [
4964
+ {
4965
+ type: "text",
4966
+ text: "No project_id was provided and no default project is configured."
4967
+ }
4968
+ ],
4969
+ isError: true
4970
+ };
4971
+ }
4972
+ const userId = await getDefaultUserId();
4973
+ const rateLimit = checkRateLimit("posting", `reschedule_post:${userId}`);
4974
+ if (!rateLimit.allowed) {
4975
+ return {
4976
+ content: [
4977
+ {
4978
+ type: "text",
4979
+ text: `Rate limit exceeded. Retry in ~${rateLimit.retryAfter}s.`
4980
+ }
4981
+ ],
4982
+ isError: true
4983
+ };
4984
+ }
4985
+ const { data: result, error } = await callEdgeFunction("mcp-data", {
4986
+ action: "reschedule-scheduled-post",
4987
+ post_id,
4988
+ projectId: resolvedProjectId,
4989
+ project_id: resolvedProjectId,
4990
+ scheduled_at: next.toISOString(),
4991
+ ...expected_scheduled_at ? { expected_scheduled_at: new Date(expected_scheduled_at).toISOString() } : {}
4992
+ });
4993
+ if (error || !result?.success) {
4994
+ const code = result?.error ?? error ?? "reschedule_failed";
4995
+ const recovery = code === "publishing_in_progress" ? "The worker has already started publishing this post." : code === "schedule_conflict" ? `The schedule changed in another client${result?.current_scheduled_at ? ` to ${result.current_scheduled_at}` : ""}; refresh the calendar before retrying.` : code === "not_found" ? "The post was not found in this project." : code === "not_reschedulable" ? `This post can no longer be rescheduled${result?.status ? ` (status: ${result.status})` : ""}.` : "The post could not be rescheduled.";
4996
+ return {
4997
+ content: [{ type: "text", text: recovery }],
4998
+ isError: true
4999
+ };
5000
+ }
5001
+ const publicResult = {
5002
+ success: true,
5003
+ post_id: result.post_id ?? post_id,
5004
+ project_id: result.project_id ?? resolvedProjectId,
5005
+ previous_scheduled_at: result.previous_scheduled_at ?? null,
5006
+ scheduled_at: result.scheduled_at ?? next.toISOString()
5007
+ };
5008
+ const structuredContent = asEnvelope3(publicResult);
5009
+ return {
5010
+ structuredContent,
5011
+ content: [
5012
+ {
5013
+ type: "text",
5014
+ text: response_format === "json" ? JSON.stringify(structuredContent, null, 2) : `Post ${publicResult.post_id} rescheduled to ${publicResult.scheduled_at}.`
5015
+ }
5016
+ ]
5017
+ };
5018
+ }
5019
+ );
4417
5020
  server.tool(
4418
5021
  "list_connected_accounts",
4419
5022
  "Check which social platforms have active OAuth connections for posting. Call this before schedule_post to verify credentials. Pass project_id to list the accounts for a specific brand/project, then pass the returned account id as account_id/account_ids when posting. If a platform is missing or expired, the user needs to reconnect at socialneuron.com/settings/connections.",
@@ -4442,14 +5045,16 @@ Created with Social Neuron`;
4442
5045
  isError: true
4443
5046
  };
4444
5047
  }
4445
- const accounts = result.accounts ?? [];
5048
+ const accounts = (result.accounts ?? []).map(publicConnectedAccount).filter((account) => account !== null);
4446
5049
  if (accounts.length === 0) {
4447
5050
  if (format === "json") {
5051
+ const structuredContent = asEnvelope3({ accounts: [] });
4448
5052
  return {
5053
+ structuredContent,
4449
5054
  content: [
4450
5055
  {
4451
5056
  type: "text",
4452
- text: JSON.stringify(asEnvelope3({ accounts: [] }), null, 2)
5057
+ text: JSON.stringify(structuredContent, null, 2)
4453
5058
  }
4454
5059
  ]
4455
5060
  };
@@ -4477,11 +5082,13 @@ Created with Social Neuron`;
4477
5082
  );
4478
5083
  }
4479
5084
  if (format === "json") {
5085
+ const structuredContent = asEnvelope3({ accounts });
4480
5086
  return {
5087
+ structuredContent,
4481
5088
  content: [
4482
5089
  {
4483
5090
  type: "text",
4484
- text: JSON.stringify(asEnvelope3({ accounts }), null, 2)
5091
+ text: JSON.stringify(structuredContent, null, 2)
4485
5092
  }
4486
5093
  ]
4487
5094
  };
@@ -4495,6 +5102,9 @@ Created with Social Neuron`;
4495
5102
  "list_recent_posts",
4496
5103
  "List recent published and scheduled posts with status, platform, title, and timestamps. Use to check what has been posted before planning new content, or to find post IDs for fetch_analytics. Filter by platform or status to narrow results.",
4497
5104
  {
5105
+ project_id: z3.string().uuid().optional().describe(
5106
+ "Brand/project ID to scope posts. Defaults to the authenticated key's project or the account default."
5107
+ ),
4498
5108
  platform: z3.enum([
4499
5109
  "youtube",
4500
5110
  "tiktok",
@@ -4510,13 +5120,27 @@ Created with Social Neuron`;
4510
5120
  limit: z3.number().min(1).max(50).optional().describe("Maximum number of posts to return. Defaults to 20."),
4511
5121
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
4512
5122
  },
4513
- async ({ platform: platform2, status, days, limit, response_format }) => {
5123
+ async ({ project_id, platform: platform2, status, days, limit, response_format }) => {
4514
5124
  const format = response_format ?? "text";
4515
5125
  const lookbackDays = days ?? 7;
4516
- const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
4517
- action: "recent-posts",
4518
- days: lookbackDays,
4519
- limit: limit ?? 20,
5126
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
5127
+ if (!resolvedProjectId) {
5128
+ return {
5129
+ content: [
5130
+ {
5131
+ type: "text",
5132
+ text: "No project_id was provided and no default project is configured."
5133
+ }
5134
+ ],
5135
+ isError: true
5136
+ };
5137
+ }
5138
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
5139
+ action: "recent-posts",
5140
+ days: lookbackDays,
5141
+ limit: limit ?? 20,
5142
+ projectId: resolvedProjectId,
5143
+ project_id: resolvedProjectId,
4520
5144
  ...platform2 ? { platform: platform2 } : {},
4521
5145
  ...status ? { status } : {}
4522
5146
  });
@@ -4531,7 +5155,7 @@ Created with Social Neuron`;
4531
5155
  isError: true
4532
5156
  };
4533
5157
  }
4534
- const rows = result.posts ?? [];
5158
+ const rows = (result.posts ?? []).map(publicPostRecord).filter((post) => post !== null);
4535
5159
  if (rows.length === 0) {
4536
5160
  if (format === "json") {
4537
5161
  const structuredContent2 = asEnvelope3({ posts: [] });
@@ -4606,8 +5230,11 @@ Created with Social Neuron`;
4606
5230
  };
4607
5231
  server.tool(
4608
5232
  "find_next_slots",
4609
- "Find optimal posting time slots based on 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.",
4610
5234
  {
5235
+ project_id: z3.string().uuid().optional().describe(
5236
+ "Brand/project ID used for conflict detection. Defaults to the authenticated key's project or the account default."
5237
+ ),
4611
5238
  platforms: z3.array(
4612
5239
  z3.enum([
4613
5240
  "youtube",
@@ -4626,6 +5253,7 @@ Created with Social Neuron`;
4626
5253
  response_format: z3.enum(["text", "json"]).default("text")
4627
5254
  },
4628
5255
  async ({
5256
+ project_id,
4629
5257
  platforms,
4630
5258
  count,
4631
5259
  start_after,
@@ -4633,13 +5261,35 @@ Created with Social Neuron`;
4633
5261
  response_format
4634
5262
  }) => {
4635
5263
  try {
5264
+ const resolvedProjectId = project_id ?? await getDefaultProjectId() ?? void 0;
5265
+ if (!resolvedProjectId) {
5266
+ return {
5267
+ content: [
5268
+ {
5269
+ type: "text",
5270
+ text: "No project_id was provided and no default project is configured."
5271
+ }
5272
+ ],
5273
+ isError: true
5274
+ };
5275
+ }
4636
5276
  const startDate = start_after ? new Date(start_after) : /* @__PURE__ */ new Date();
5277
+ if (!Number.isFinite(startDate.getTime())) {
5278
+ return {
5279
+ content: [
5280
+ { type: "text", text: "start_after must be a valid ISO datetime." }
5281
+ ],
5282
+ isError: true
5283
+ };
5284
+ }
4637
5285
  const endDate = new Date(startDate.getTime() + 7 * 24 * 60 * 60 * 1e3);
4638
5286
  const { data: postsResult, error: postsError } = await callEdgeFunction("mcp-data", {
4639
5287
  action: "scheduled-posts",
4640
5288
  start_date: startDate.toISOString(),
4641
5289
  end_date: endDate.toISOString(),
4642
- statuses: ["scheduled", "draft"]
5290
+ statuses: ["pending", "scheduled", "draft"],
5291
+ projectId: resolvedProjectId,
5292
+ project_id: resolvedProjectId
4643
5293
  });
4644
5294
  const existingPosts = postsError ? [] : postsResult?.posts ?? [];
4645
5295
  const gapMs = min_gap_hours * 60 * 60 * 1e3;
@@ -4679,19 +5329,17 @@ Created with Social Neuron`;
4679
5329
  const slots = candidates.filter((s) => !s.conflict).sort((a, b) => b.engagement_score - a.engagement_score).slice(0, count);
4680
5330
  const conflictsAvoided = candidates.filter((s) => s.conflict).length;
4681
5331
  if (response_format === "json") {
5332
+ const structuredContent = asEnvelope3({
5333
+ slots,
5334
+ total_candidates: candidates.length,
5335
+ conflicts_avoided: conflictsAvoided
5336
+ });
4682
5337
  return {
5338
+ structuredContent,
4683
5339
  content: [
4684
5340
  {
4685
5341
  type: "text",
4686
- text: JSON.stringify(
4687
- asEnvelope3({
4688
- slots,
4689
- total_candidates: candidates.length,
4690
- conflicts_avoided: conflictsAvoided
4691
- }),
4692
- null,
4693
- 2
4694
- )
5342
+ text: JSON.stringify(structuredContent, null, 2)
4695
5343
  }
4696
5344
  ],
4697
5345
  isError: false
@@ -5056,7 +5704,7 @@ Created with Social Neuron`;
5056
5704
  const results = [];
5057
5705
  const buildIdempotencyKey = (post) => {
5058
5706
  const planId = effectivePlanId ?? (typeof workingPlan.plan_id === "string" ? String(workingPlan.plan_id) : "inline");
5059
- const captionHash = createHash2("sha256").update(post.caption).digest("hex").slice(0, 16);
5707
+ const captionHash = createHash("sha256").update(post.caption).digest("hex").slice(0, 16);
5060
5708
  const raw = [
5061
5709
  "schedule_content_plan",
5062
5710
  planId,
@@ -5066,7 +5714,7 @@ Created with Social Neuron`;
5066
5714
  captionHash,
5067
5715
  idempotency_seed ?? ""
5068
5716
  ].join(":");
5069
- return `plan-${createHash2("sha256").update(raw).digest("hex").slice(0, 48)}`;
5717
+ return `plan-${createHash("sha256").update(raw).digest("hex").slice(0, 48)}`;
5070
5718
  };
5071
5719
  const scheduleOne = async (post) => {
5072
5720
  if (!post.schedule_at) {
@@ -5545,7 +6193,10 @@ function registerMediaTools(server) {
5545
6193
  content: [
5546
6194
  {
5547
6195
  type: "text",
5548
- 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.`
5549
6200
  }
5550
6201
  ],
5551
6202
  isError: true
@@ -5764,7 +6415,11 @@ function registerAnalyticsTools(server) {
5764
6415
  action: "analytics",
5765
6416
  platform: platform2,
5766
6417
  days: lookbackDays,
5767
- limit: maxPosts,
6418
+ // Fetch extra snapshots, then deduplicate to the requested post count.
6419
+ // The backend understands latestOnly after the paired application
6420
+ // deployment; older deployments safely ignore the hint.
6421
+ limit: Math.min(maxPosts * 5, 100),
6422
+ latestOnly: true,
5768
6423
  contentId: content_id,
5769
6424
  ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
5770
6425
  });
@@ -5774,7 +6429,14 @@ function registerAnalyticsTools(server) {
5774
6429
  isError: true
5775
6430
  };
5776
6431
  }
5777
- const 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);
5778
6440
  if (rows.length === 0) {
5779
6441
  if (format === "json") {
5780
6442
  const structuredContent = asEnvelope4({
@@ -5947,6 +6609,80 @@ function formatAnalytics(summary, days, format) {
5947
6609
  // src/tools/brand.ts
5948
6610
  import { z as z6 } from "zod";
5949
6611
  init_supabase();
6612
+
6613
+ // src/lib/brandUrlInput.ts
6614
+ var PLATFORM_ALIASES = {
6615
+ instagram: "instagram",
6616
+ ig: "instagram",
6617
+ insta: "instagram",
6618
+ tiktok: "tiktok",
6619
+ tt: "tiktok",
6620
+ twitter: "twitter",
6621
+ x: "twitter",
6622
+ linkedin: "linkedin",
6623
+ li: "linkedin",
6624
+ youtube: "youtube",
6625
+ yt: "youtube"
6626
+ };
6627
+ var PROFILE_URL_BUILDERS = {
6628
+ instagram: (handle) => `https://instagram.com/${encodeURIComponent(handle)}`,
6629
+ tiktok: (handle) => `https://tiktok.com/@${encodeURIComponent(handle)}`,
6630
+ twitter: (handle) => `https://x.com/${encodeURIComponent(handle)}`,
6631
+ linkedin: (handle) => `https://linkedin.com/company/${encodeURIComponent(handle)}`,
6632
+ youtube: (handle) => `https://youtube.com/@${encodeURIComponent(handle)}`
6633
+ };
6634
+ var UNICODE_DOT_VARIANTS = /[。.。]/g;
6635
+ function canonicalizeHost(hostname) {
6636
+ return hostname.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, "");
6637
+ }
6638
+ function hostLooksLikeDomain(hostname) {
6639
+ return /\S+\.\S+/.test(canonicalizeHost(hostname));
6640
+ }
6641
+ function resolveHandle(handle, platform2) {
6642
+ const normalized = handle.replace(/^@/, "").trim();
6643
+ if (!normalized) return { url: null, handle: null, platform: null, ambiguous: false };
6644
+ if (!platform2) return { url: null, handle: normalized, platform: null, ambiguous: true };
6645
+ return {
6646
+ url: PROFILE_URL_BUILDERS[platform2](normalized),
6647
+ handle: normalized,
6648
+ platform: platform2,
6649
+ ambiguous: false
6650
+ };
6651
+ }
6652
+ function normalizeBrandUrlInput(raw, explicitPlatform) {
6653
+ const input = (raw ?? "").trim();
6654
+ if (!input) return { url: null, handle: null, platform: null, ambiguous: false };
6655
+ if (/^https?:\/\//i.test(input)) {
6656
+ let parsed;
6657
+ try {
6658
+ parsed = new URL(input);
6659
+ } catch {
6660
+ return { url: null, handle: null, platform: null, ambiguous: false, invalidUrl: true };
6661
+ }
6662
+ if (hostLooksLikeDomain(parsed.hostname)) {
6663
+ return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
6664
+ }
6665
+ return resolveHandle(canonicalizeHost(parsed.hostname), explicitPlatform);
6666
+ }
6667
+ const qualified = input.match(/^([a-z]{1,10}):\s*@?(.+)$/i);
6668
+ if (qualified) {
6669
+ const platform2 = PLATFORM_ALIASES[qualified[1].toLowerCase()];
6670
+ if (platform2) return resolveHandle(qualified[2], platform2);
6671
+ }
6672
+ if (input.startsWith("@")) return resolveHandle(input, explicitPlatform);
6673
+ if (!/\s/.test(input)) {
6674
+ try {
6675
+ const parsed = new URL(`https://${input}`);
6676
+ if (hostLooksLikeDomain(parsed.hostname)) {
6677
+ return { url: parsed.toString(), handle: null, platform: null, ambiguous: false };
6678
+ }
6679
+ } catch {
6680
+ }
6681
+ }
6682
+ return resolveHandle(input.replace(UNICODE_DOT_VARIANTS, ".").replace(/\.+$/, ""), explicitPlatform);
6683
+ }
6684
+
6685
+ // src/tools/brand.ts
5950
6686
  function asEnvelope5(data) {
5951
6687
  return {
5952
6688
  _meta: {
@@ -5961,12 +6697,31 @@ function registerBrandTools(server) {
5961
6697
  "extract_brand",
5962
6698
  "Analyze a website URL and extract brand identity data including brand name, colors, voice/tone, target audience, and logo. Uses AI-powered analysis of the page HTML. Useful for understanding a brand before generating content for it.",
5963
6699
  {
5964
- url: z6.string().url().describe(
5965
- 'The website URL to analyze for brand identity (e.g. "https://example.com").'
6700
+ url: z6.string().min(1).max(2048).describe(
6701
+ 'The website URL to analyze for brand identity (e.g. "https://example.com"). Bare handles are rejected; supply a full profile URL or use "platform:handle" shorthand (e.g. "instagram:acmefoods").'
5966
6702
  ),
5967
6703
  response_format: z6.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5968
6704
  },
5969
- async ({ url, response_format }) => {
6705
+ async ({ url: rawUrl, response_format }) => {
6706
+ const normalized = normalizeBrandUrlInput(rawUrl);
6707
+ if (normalized.invalidUrl) {
6708
+ return {
6709
+ content: [{ type: "text", text: `URL blocked: "${rawUrl}" is not a valid URL.` }],
6710
+ isError: true
6711
+ };
6712
+ }
6713
+ if (!normalized.url) {
6714
+ return {
6715
+ content: [
6716
+ {
6717
+ type: "text",
6718
+ text: `"${rawUrl}" looks like a handle, not a full URL. Provide a profile URL or use "platform:handle" shorthand (for example "instagram:acmefoods").`
6719
+ }
6720
+ ],
6721
+ isError: true
6722
+ };
6723
+ }
6724
+ const url = normalized.url;
5970
6725
  const ssrfCheck = await validateUrlForSSRF(url);
5971
6726
  if (!ssrfCheck.isValid) {
5972
6727
  return {
@@ -6750,7 +7505,7 @@ function registerRemotionTools(server) {
6750
7505
  },
6751
7506
  async ({ composition_id, output_format, props }) => {
6752
7507
  const userId = await getDefaultUserId();
6753
- const rateLimit = checkRateLimit("screenshot", `render_demo_video:${userId}`);
7508
+ const rateLimit = checkRateLimit("generation", `render_demo_video:${userId}`);
6754
7509
  if (!rateLimit.allowed) {
6755
7510
  return {
6756
7511
  content: [
@@ -10399,7 +11154,9 @@ function toolKnowledgeDocument(tool) {
10399
11154
  function getKnowledgeDocuments() {
10400
11155
  return [
10401
11156
  ...STATIC_KNOWLEDGE_DOCUMENTS,
10402
- ...TOOL_CATALOG.filter((t) => !t.internal).map(toolKnowledgeDocument)
11157
+ ...TOOL_CATALOG.filter((t) => !t.internal && !t.hiddenFromPublicCount).map(
11158
+ toolKnowledgeDocument
11159
+ )
10403
11160
  ];
10404
11161
  }
10405
11162
  function tokenize(input) {
@@ -10516,7 +11273,7 @@ function registerDiscoveryTools(server) {
10516
11273
  if (query) {
10517
11274
  results = searchTools(query);
10518
11275
  }
10519
- results = results.filter((t) => !t.internal);
11276
+ results = results.filter((t) => !t.internal && !t.hiddenFromPublicCount);
10520
11277
  if (module) {
10521
11278
  const moduleTools = getToolsByModule(module);
10522
11279
  results = results.filter((t) => moduleTools.some((mt) => mt.name === t.name));
@@ -12338,11 +13095,11 @@ function hexToLab(hex) {
12338
13095
  const lb = srgbToLinear(b);
12339
13096
  const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
12340
13097
  const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
12341
- const z39 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
13098
+ const z41 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
12342
13099
  const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
12343
13100
  const fx = f(x / 0.95047);
12344
13101
  const fy = f(y / 1);
12345
- const fz = f(z39 / 1.08883);
13102
+ const fz = f(z41 / 1.08883);
12346
13103
  return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
12347
13104
  }
12348
13105
  function deltaE2000(lab1, lab2) {
@@ -12575,10 +13332,10 @@ function registerBrandRuntimeTools(server) {
12575
13332
  pagesScraped: meta.pagesScraped || 0
12576
13333
  }
12577
13334
  };
12578
- const envelope = asEnvelope23(runtime);
13335
+ const envelope2 = asEnvelope23(runtime);
12579
13336
  return {
12580
- structuredContent: envelope,
12581
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13337
+ structuredContent: envelope2,
13338
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12582
13339
  };
12583
13340
  }
12584
13341
  );
@@ -12718,9 +13475,9 @@ function registerBrandRuntimeTools(server) {
12718
13475
  }
12719
13476
  const profile = row.profile_data;
12720
13477
  const checkResult = computeBrandConsistency(content, profile);
12721
- const envelope = asEnvelope23(checkResult);
13478
+ const envelope2 = asEnvelope23(checkResult);
12722
13479
  return {
12723
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13480
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12724
13481
  };
12725
13482
  }
12726
13483
  );
@@ -12752,9 +13509,9 @@ function registerBrandRuntimeTools(server) {
12752
13509
  content_colors,
12753
13510
  threshold ?? 10
12754
13511
  );
12755
- const envelope = asEnvelope23(auditResult);
13512
+ const envelope2 = asEnvelope23(auditResult);
12756
13513
  return {
12757
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13514
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12758
13515
  };
12759
13516
  }
12760
13517
  );
@@ -12787,9 +13544,9 @@ function registerBrandRuntimeTools(server) {
12787
13544
  row.profile_data.typography,
12788
13545
  format
12789
13546
  );
12790
- const envelope = asEnvelope23({ format, tokens: output });
13547
+ const envelope2 = asEnvelope23({ format, tokens: output });
12791
13548
  return {
12792
- content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
13549
+ content: [{ type: "text", text: JSON.stringify(envelope2, null, 2) }]
12793
13550
  };
12794
13551
  }
12795
13552
  );
@@ -12945,7 +13702,7 @@ function registerCarouselTools(server) {
12945
13702
  };
12946
13703
  }
12947
13704
  const userId = await getDefaultUserId();
12948
- const rateLimit = checkRateLimit("posting", `create_carousel:${userId}`);
13705
+ const rateLimit = checkRateLimit("generation", `create_carousel:${userId}`);
12949
13706
  if (!rateLimit.allowed) {
12950
13707
  return {
12951
13708
  content: [
@@ -13017,7 +13774,12 @@ function registerCarouselTools(server) {
13017
13774
  slideNumber: slide.slideNumber,
13018
13775
  jobId: null,
13019
13776
  model: image_model,
13020
- error: error ?? "No job ID returned"
13777
+ error: error ?? "No job ID returned",
13778
+ creditsReserved: data?.credits_reserved ?? null,
13779
+ creditsCharged: data?.credits_charged ?? null,
13780
+ creditsRefunded: data?.credits_refunded ?? null,
13781
+ billingStatus: data?.billing_status ?? "unknown",
13782
+ failureReason: data?.failure_reason ?? null
13021
13783
  };
13022
13784
  }
13023
13785
  const jobId = data.asyncJobId ?? data.taskId ?? null;
@@ -13029,14 +13791,24 @@ function registerCarouselTools(server) {
13029
13791
  slideNumber: slide.slideNumber,
13030
13792
  jobId,
13031
13793
  model: image_model,
13032
- error: null
13794
+ error: null,
13795
+ creditsReserved: 0,
13796
+ creditsCharged: data.creditsDeducted ?? perImageCost,
13797
+ creditsRefunded: 0,
13798
+ billingStatus: "charged",
13799
+ failureReason: null
13033
13800
  };
13034
13801
  } catch (err) {
13035
13802
  return {
13036
13803
  slideNumber: slide.slideNumber,
13037
13804
  jobId: null,
13038
13805
  model: image_model,
13039
- error: sanitizeError(err)
13806
+ error: sanitizeError(err),
13807
+ creditsReserved: null,
13808
+ creditsCharged: null,
13809
+ creditsRefunded: null,
13810
+ billingStatus: "unknown",
13811
+ failureReason: null
13040
13812
  };
13041
13813
  }
13042
13814
  })
@@ -13061,7 +13833,8 @@ function registerCarouselTools(server) {
13061
13833
  return {
13062
13834
  ...s,
13063
13835
  imageJobId: job?.jobId ?? null,
13064
- imageError: job?.error ?? null
13836
+ imageError: job?.error ?? null,
13837
+ imageBillingStatus: job?.billingStatus ?? "unknown"
13065
13838
  };
13066
13839
  }),
13067
13840
  imageModel: image_model,
@@ -13073,11 +13846,19 @@ function registerCarouselTools(server) {
13073
13846
  jobIds: successfulJobs.map((j) => j.jobId),
13074
13847
  failedSlides: failedJobs.map((j) => ({
13075
13848
  slideNumber: j.slideNumber,
13076
- error: j.error
13849
+ error: j.error,
13850
+ credits_reserved: j.creditsReserved,
13851
+ credits_charged: j.creditsCharged,
13852
+ credits_refunded: j.creditsRefunded,
13853
+ billing_status: j.billingStatus,
13854
+ failure_reason: j.failureReason
13077
13855
  })),
13078
13856
  credits: {
13079
13857
  textGeneration: textCredits,
13080
13858
  imagesEstimated: successfulJobs.length * perImageCost,
13859
+ imagesCharged: imageJobs.reduce((sum, job) => sum + (job.creditsCharged ?? 0), 0),
13860
+ imagesRefunded: imageJobs.reduce((sum, job) => sum + (job.creditsRefunded ?? 0), 0),
13861
+ billingUnknownSlides: imageJobs.filter((job) => job.billingStatus === "unknown").length,
13081
13862
  totalEstimated: textCredits + successfulJobs.length * perImageCost
13082
13863
  }
13083
13864
  }
@@ -13348,7 +14129,7 @@ function registerHyperframesTools(server) {
13348
14129
  );
13349
14130
  server.tool(
13350
14131
  "render_hyperframes",
13351
- "Render an HTML video composition (Hyperframes) to MP4. 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. Pass project_id to keep the render with the correct brand/project. Returns a job ID \u2014 poll with check_status.",
14132
+ "Render an HTML video composition (Hyperframes) to MP4 \u2014 frame-accurate, no React build step. The page MUST expose window.__hf = { duration: <seconds>, seek: (t) => void }; the renderer calls seek(t) per frame (GSAP timelines work when driven from seek). Missing window.__hf causes a terminal timeout; poll check_status and verify its reported billing/refund outcome. Use list_hyperframes_blocks for the pre-built block catalog. Pass project_id to keep the render with the correct brand/project. Returns a job ID \u2014 poll with check_status.",
13352
14133
  {
13353
14134
  composition_html: z30.string().max(5e5).optional().describe(
13354
14135
  "Inline HTML composition (full <html>...</html>). Max 500KB. Use composition_url for larger."
@@ -13361,7 +14142,8 @@ function registerHyperframesTools(server) {
13361
14142
  duration_sec: z30.number().min(0.1).max(600).describe("Output duration in seconds. Required. Max 600 (10 min)."),
13362
14143
  fps: z30.union([z30.literal(24), z30.literal(30), z30.literal(60)]).optional().describe("Frames per second. Default 30."),
13363
14144
  quality: z30.enum(["draft", "standard", "high"]).optional().describe("draft: fast iteration; standard: production; high: final master."),
13364
- project_id: z30.string().optional().describe("Project ID to associate the Hyperframes render with.")
14145
+ project_id: z30.string().optional().describe("Project ID to associate the Hyperframes render with."),
14146
+ response_format: z30.enum(["text", "json"]).optional().describe("Response format. Use json for a stable job_id handoff.")
13365
14147
  },
13366
14148
  async ({
13367
14149
  composition_html,
@@ -13371,7 +14153,8 @@ function registerHyperframesTools(server) {
13371
14153
  duration_sec,
13372
14154
  fps,
13373
14155
  quality,
13374
- project_id
14156
+ project_id,
14157
+ response_format
13375
14158
  }) => {
13376
14159
  const userId = await getDefaultUserId();
13377
14160
  const rateLimit = checkRateLimit("generation", `render_hyperframes:${userId}`);
@@ -13422,11 +14205,23 @@ function registerHyperframesTools(server) {
13422
14205
  if (error || !data?.jobId) {
13423
14206
  throw new Error(error || data?.error || "Failed to create Hyperframes render job");
13424
14207
  }
14208
+ const payload = {
14209
+ job_id: data.jobId,
14210
+ jobId: data.jobId,
14211
+ status: data.status,
14212
+ credits_cost: data.creditsCost,
14213
+ credits: data.creditsCost,
14214
+ duration_sec,
14215
+ fps: fps || 30,
14216
+ aspect_ratio: aspect_ratio || "9:16",
14217
+ quality: quality || "standard",
14218
+ project_id: project_id || null
14219
+ };
13425
14220
  return {
13426
14221
  content: [
13427
14222
  {
13428
14223
  type: "text",
13429
- text: [
14224
+ text: response_format === "json" ? JSON.stringify({ data: payload }) : [
13430
14225
  `Hyperframes render job queued.`,
13431
14226
  ` Job ID: ${data.jobId}`,
13432
14227
  ` Credits: ${data.creditsCost}`,
@@ -13443,7 +14238,7 @@ function registerHyperframesTools(server) {
13443
14238
  content: [
13444
14239
  {
13445
14240
  type: "text",
13446
- text: `Failed to queue Hyperframes render: ${err instanceof Error ? err.message : "Unknown error"}`
14241
+ text: `Failed to queue Hyperframes render: ${sanitizeError(err)}`
13447
14242
  }
13448
14243
  ],
13449
14244
  isError: true
@@ -13462,7 +14257,17 @@ import {
13462
14257
  import { z as z31 } from "zod";
13463
14258
  import fs from "node:fs/promises";
13464
14259
  import path from "node:path";
13465
- 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
+ };
13466
14271
  var RecentPostOutputSchema = z31.object({
13467
14272
  id: z31.string(),
13468
14273
  platform: z31.string(),
@@ -13475,47 +14280,119 @@ var RecentPostOutputSchema = z31.object({
13475
14280
  });
13476
14281
  function startOfCurrentWeekMonday() {
13477
14282
  const now = /* @__PURE__ */ new Date();
13478
- const day = now.getDay();
14283
+ const day = now.getUTCDay();
13479
14284
  const monday = new Date(now);
13480
- monday.setUTCDate(now.getUTCDate() - day + (day === 0 ? -6 : 1));
14285
+ monday.setUTCDate(now.getUTCDate() - (day + 6) % 7);
14286
+ monday.setUTCHours(0, 0, 0, 0);
13481
14287
  return monday.toISOString().split("T")[0];
13482
14288
  }
14289
+ function endOfWeek(startDate) {
14290
+ const end = /* @__PURE__ */ new Date(`${startDate}T00:00:00.000Z`);
14291
+ end.setUTCDate(end.getUTCDate() + 7);
14292
+ end.setUTCMilliseconds(end.getUTCMilliseconds() - 1);
14293
+ return end.toISOString();
14294
+ }
14295
+ function isStrictIsoDate(value) {
14296
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
14297
+ const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
14298
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
14299
+ }
14300
+ function publicRecentPost(value) {
14301
+ if (!value || typeof value !== "object") return null;
14302
+ const row = value;
14303
+ if (typeof row.id !== "string" || typeof row.platform !== "string" || typeof row.status !== "string" || typeof row.created_at !== "string") {
14304
+ return null;
14305
+ }
14306
+ const optional = (name) => typeof row[name] === "string" ? row[name] : null;
14307
+ return {
14308
+ id: row.id,
14309
+ platform: row.platform,
14310
+ status: row.status,
14311
+ title: optional("title"),
14312
+ external_post_id: optional("external_post_id"),
14313
+ published_at: optional("published_at"),
14314
+ scheduled_at: optional("scheduled_at"),
14315
+ created_at: row.created_at
14316
+ };
14317
+ }
14318
+ function calendarHtmlCandidates() {
14319
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
14320
+ return [
14321
+ // Bundled HTTP entry: import.meta.url is dist/http.js.
14322
+ path.join(moduleDir, "apps/content-calendar/mcp-app.html"),
14323
+ // Source/tsx/vitest: import.meta.url is src/apps/content-calendar.ts.
14324
+ path.resolve(moduleDir, "../../dist/apps/content-calendar/mcp-app.html"),
14325
+ // Local development fallback; never the sole package path.
14326
+ path.resolve(process.cwd(), "dist/apps/content-calendar/mcp-app.html")
14327
+ ];
14328
+ }
14329
+ async function readCalendarHtml() {
14330
+ for (const candidate of calendarHtmlCandidates()) {
14331
+ try {
14332
+ return await fs.readFile(candidate, "utf-8");
14333
+ } catch (error) {
14334
+ if (error.code !== "ENOENT") throw error;
14335
+ }
14336
+ }
14337
+ throw new Error("calendar_bundle_missing");
14338
+ }
13483
14339
  function registerContentCalendarApp(server) {
13484
14340
  registerAppTool(
13485
14341
  server,
13486
14342
  "open_content_calendar",
13487
14343
  {
13488
14344
  title: "Content Calendar",
13489
- description: "Open 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.",
13490
14346
  inputSchema: {
13491
- 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(
13492
14351
  "ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday."
13493
14352
  )
13494
14353
  },
13495
14354
  outputSchema: {
13496
14355
  start_date: z31.string(),
14356
+ project_id: z31.string(),
13497
14357
  posts: z31.array(RecentPostOutputSchema),
13498
14358
  scopes: z31.array(z31.string())
13499
14359
  },
13500
14360
  _meta: {
13501
14361
  ui: {
13502
- resourceUri: CALENDAR_URI,
13503
- csp: {
13504
- "img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
13505
- "connect-src": ["'self'"]
13506
- }
14362
+ resourceUri: CALENDAR_URI
13507
14363
  }
13508
14364
  }
13509
14365
  },
13510
- async ({ start_date }, extra) => {
13511
- 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
+ }
13512
14380
  const fromDate = start_date ?? startOfCurrentWeekMonday();
14381
+ if (!isStrictIsoDate(fromDate)) {
14382
+ return {
14383
+ content: [{ type: "text", text: "start_date must be a valid YYYY-MM-DD date." }],
14384
+ isError: true
14385
+ };
14386
+ }
13513
14387
  const { data: result, error } = await callEdgeFunction(
13514
14388
  "mcp-data",
13515
14389
  {
13516
- action: "recent-posts",
13517
- days: 14,
13518
- 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
13519
14396
  },
13520
14397
  { timeoutMs: 15e3 }
13521
14398
  );
@@ -13524,19 +14401,16 @@ function registerContentCalendarApp(server) {
13524
14401
  content: [
13525
14402
  {
13526
14403
  type: "text",
13527
- text: `Failed to load posts: ${error || result?.error || "Unknown error"}`
14404
+ text: "The content calendar could not load posts. Please retry."
13528
14405
  }
13529
14406
  ],
13530
14407
  isError: true
13531
14408
  };
13532
14409
  }
13533
- const posts = (result.posts ?? []).filter((p) => {
13534
- const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
13535
- if (!ts) return false;
13536
- return ts.split("T")[0] >= fromDate;
13537
- });
14410
+ const posts = (result.posts ?? []).map(publicRecentPost).filter((post) => post !== null);
13538
14411
  const structuredContent = {
13539
14412
  start_date: fromDate,
14413
+ project_id: resolvedProjectId,
13540
14414
  posts,
13541
14415
  scopes: userScopes
13542
14416
  };
@@ -13555,34 +14429,290 @@ function registerContentCalendarApp(server) {
13555
14429
  server,
13556
14430
  CALENDAR_URI,
13557
14431
  CALENDAR_URI,
13558
- { 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
+ },
13559
14437
  async () => {
13560
- const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
13561
14438
  try {
13562
- const html = await fs.readFile(htmlPath, "utf-8");
14439
+ const html = await readCalendarHtml();
13563
14440
  return {
13564
- 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
+ ]
13565
14449
  };
13566
- } catch (err) {
14450
+ } catch {
13567
14451
  const errorHtml = `<!DOCTYPE html>
13568
14452
  <html><head><title>Content Calendar \u2014 unavailable</title></head>
13569
14453
  <body style="font-family:sans-serif;padding:24px;color:#444;">
13570
14454
  <h2>Content Calendar app bundle missing</h2>
13571
- <p>The server registered <code>open_content_calendar</code> but
13572
- <code>apps/content-calendar/dist/mcp-app.html</code> is not built.
13573
- Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
13574
- <p style="color:#999;font-size:12px;">${err.message}</p>
14455
+ <p>The interactive bundle is unavailable on this deployment. Please contact Social Neuron support.</p>
13575
14456
  </body></html>`;
13576
14457
  return {
13577
- contents: [{ 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
+ ]
13578
14466
  };
13579
14467
  }
13580
14468
  }
13581
14469
  );
13582
14470
  }
13583
14471
 
13584
- // 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";
13585
14478
  import { z as z32 } from "zod";
14479
+ import fs2 from "node:fs/promises";
14480
+ import path2 from "node:path";
14481
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
14482
+ init_supabase();
14483
+ var ANALYTICS_URI = "ui://analytics-pulse/v1/mcp-app.html";
14484
+ var ANALYTICS_CSP = {
14485
+ connectDomains: [],
14486
+ resourceDomains: [],
14487
+ frameDomains: []
14488
+ };
14489
+ var PLATFORM_VALUES = [
14490
+ "youtube",
14491
+ "tiktok",
14492
+ "instagram",
14493
+ "twitter",
14494
+ "linkedin",
14495
+ "facebook",
14496
+ "threads",
14497
+ "bluesky"
14498
+ ];
14499
+ var AnalyticsPostOutputSchema = z32.object({
14500
+ platform: z32.string(),
14501
+ title: z32.string().nullable(),
14502
+ views: z32.number(),
14503
+ likes: z32.number(),
14504
+ comments: z32.number(),
14505
+ shares: z32.number(),
14506
+ engagement_rate: z32.number(),
14507
+ captured_at: z32.string(),
14508
+ published_at: z32.string().nullable()
14509
+ });
14510
+ function finiteMetric(value) {
14511
+ const parsed = Number(value ?? 0);
14512
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
14513
+ }
14514
+ function publicAnalyticsPost(value) {
14515
+ if (!value || typeof value !== "object") return null;
14516
+ const row = value;
14517
+ const post = row.posts && typeof row.posts === "object" ? row.posts : {};
14518
+ const platform2 = typeof row.platform === "string" ? row.platform : typeof post.platform === "string" ? post.platform : null;
14519
+ const capturedAt = typeof row.captured_at === "string" ? row.captured_at : null;
14520
+ if (!platform2 || !capturedAt) return null;
14521
+ const views = finiteMetric(row.views);
14522
+ const likes = finiteMetric(row.likes);
14523
+ const comments = finiteMetric(row.comments);
14524
+ const shares = finiteMetric(row.shares);
14525
+ const engagement = likes + comments + shares;
14526
+ return {
14527
+ platform: platform2,
14528
+ title: typeof post.title === "string" ? post.title : null,
14529
+ views,
14530
+ likes,
14531
+ comments,
14532
+ shares,
14533
+ engagement_rate: views > 0 ? Number((engagement / views * 100).toFixed(2)) : 0,
14534
+ captured_at: capturedAt,
14535
+ published_at: typeof post.published_at === "string" ? post.published_at : null
14536
+ };
14537
+ }
14538
+ function latestAnalyticsRows(values) {
14539
+ const rows = values.filter((value) => Boolean(value) && typeof value === "object").sort((a, b) => String(b.captured_at ?? "").localeCompare(String(a.captured_at ?? "")));
14540
+ const seen = /* @__PURE__ */ new Set();
14541
+ const latest = [];
14542
+ for (const row of rows) {
14543
+ if (typeof row.post_id !== "string" || typeof row.platform !== "string") continue;
14544
+ const key = `${row.post_id}:${row.platform.toLowerCase()}`;
14545
+ if (seen.has(key)) continue;
14546
+ seen.add(key);
14547
+ latest.push(row);
14548
+ }
14549
+ return latest;
14550
+ }
14551
+ function analyticsHtmlCandidates() {
14552
+ const moduleDir = path2.dirname(fileURLToPath2(import.meta.url));
14553
+ return [
14554
+ path2.join(moduleDir, "apps/analytics-pulse/mcp-app.html"),
14555
+ path2.resolve(moduleDir, "../../dist/apps/analytics-pulse/mcp-app.html"),
14556
+ path2.resolve(process.cwd(), "dist/apps/analytics-pulse/mcp-app.html")
14557
+ ];
14558
+ }
14559
+ async function readAnalyticsHtml() {
14560
+ for (const candidate of analyticsHtmlCandidates()) {
14561
+ try {
14562
+ return await fs2.readFile(candidate, "utf-8");
14563
+ } catch (error) {
14564
+ if (error.code !== "ENOENT") throw error;
14565
+ }
14566
+ }
14567
+ throw new Error("analytics_bundle_missing");
14568
+ }
14569
+ function registerAnalyticsPulseApp(server) {
14570
+ registerAppTool2(
14571
+ server,
14572
+ "open_analytics_pulse",
14573
+ {
14574
+ title: "Analytics Pulse",
14575
+ description: "Open a project-scoped performance dashboard with views, engagement, platform mix, and top-post metrics. Use it to review results visually before planning the next content cycle.",
14576
+ inputSchema: {
14577
+ project_id: z32.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default."),
14578
+ platform: z32.enum(PLATFORM_VALUES).optional().describe("Optional platform filter."),
14579
+ days: z32.number().int().min(1).max(365).optional().describe("Lookback window. Defaults to 30 days.")
14580
+ },
14581
+ outputSchema: {
14582
+ project_id: z32.string(),
14583
+ platform: z32.string().nullable(),
14584
+ days: z32.number(),
14585
+ summary: z32.object({
14586
+ views: z32.number(),
14587
+ engagement: z32.number(),
14588
+ engagement_rate: z32.number(),
14589
+ posts: z32.number()
14590
+ }),
14591
+ platform_totals: z32.array(
14592
+ z32.object({
14593
+ platform: z32.string(),
14594
+ views: z32.number(),
14595
+ engagement: z32.number(),
14596
+ posts: z32.number()
14597
+ })
14598
+ ),
14599
+ posts: z32.array(AnalyticsPostOutputSchema)
14600
+ },
14601
+ _meta: { ui: { resourceUri: ANALYTICS_URI } }
14602
+ },
14603
+ async ({ project_id, platform: platform2, days }) => {
14604
+ const resolvedProjectId = project_id ?? await getDefaultProjectId();
14605
+ if (!resolvedProjectId) {
14606
+ return {
14607
+ content: [{ type: "text", text: "No project_id was provided and no default project is configured." }],
14608
+ isError: true
14609
+ };
14610
+ }
14611
+ const lookbackDays = days ?? 30;
14612
+ const { data: result, error } = await callEdgeFunction(
14613
+ "mcp-data",
14614
+ {
14615
+ action: "analytics",
14616
+ projectId: resolvedProjectId,
14617
+ project_id: resolvedProjectId,
14618
+ days: lookbackDays,
14619
+ // The table stores cumulative snapshots. Request a wider window and
14620
+ // deduplicate below so refresh frequency cannot inflate the totals.
14621
+ limit: 100,
14622
+ latestOnly: true,
14623
+ ...platform2 ? { platform: platform2 } : {}
14624
+ },
14625
+ { timeoutMs: 15e3 }
14626
+ );
14627
+ if (error || !result?.success) {
14628
+ return {
14629
+ content: [{ type: "text", text: "The analytics dashboard could not load data. Please retry." }],
14630
+ isError: true
14631
+ };
14632
+ }
14633
+ const posts = latestAnalyticsRows(result.rows ?? []).map(publicAnalyticsPost).filter((post) => post !== null).sort((a, b) => b.views - a.views);
14634
+ const totals = /* @__PURE__ */ new Map();
14635
+ let totalViews = 0;
14636
+ let totalEngagement = 0;
14637
+ for (const post of posts) {
14638
+ const engagement = post.likes + post.comments + post.shares;
14639
+ totalViews += post.views;
14640
+ totalEngagement += engagement;
14641
+ const aggregate = totals.get(post.platform) ?? {
14642
+ platform: post.platform,
14643
+ views: 0,
14644
+ engagement: 0,
14645
+ posts: 0
14646
+ };
14647
+ aggregate.views += post.views;
14648
+ aggregate.engagement += engagement;
14649
+ aggregate.posts += 1;
14650
+ totals.set(post.platform, aggregate);
14651
+ }
14652
+ const structuredContent = {
14653
+ project_id: resolvedProjectId,
14654
+ platform: platform2 ?? null,
14655
+ days: lookbackDays,
14656
+ summary: {
14657
+ views: totalViews,
14658
+ engagement: totalEngagement,
14659
+ engagement_rate: totalViews > 0 ? Number((totalEngagement / totalViews * 100).toFixed(2)) : 0,
14660
+ posts: posts.length
14661
+ },
14662
+ platform_totals: [...totals.values()].sort((a, b) => b.views - a.views),
14663
+ posts
14664
+ };
14665
+ return {
14666
+ structuredContent,
14667
+ content: [
14668
+ {
14669
+ type: "text",
14670
+ text: `Loaded ${posts.length} analytics record${posts.length === 1 ? "" : "s"} for the last ${lookbackDays} days.`
14671
+ }
14672
+ ]
14673
+ };
14674
+ }
14675
+ );
14676
+ registerAppResource2(
14677
+ server,
14678
+ ANALYTICS_URI,
14679
+ ANALYTICS_URI,
14680
+ {
14681
+ mimeType: RESOURCE_MIME_TYPE2,
14682
+ description: "Self-contained Social Neuron project analytics dashboard.",
14683
+ _meta: { ui: { csp: ANALYTICS_CSP } }
14684
+ },
14685
+ async () => {
14686
+ try {
14687
+ const html = await readAnalyticsHtml();
14688
+ return {
14689
+ contents: [
14690
+ {
14691
+ uri: ANALYTICS_URI,
14692
+ mimeType: RESOURCE_MIME_TYPE2,
14693
+ text: html,
14694
+ _meta: { ui: { csp: ANALYTICS_CSP } }
14695
+ }
14696
+ ]
14697
+ };
14698
+ } catch {
14699
+ return {
14700
+ contents: [
14701
+ {
14702
+ uri: ANALYTICS_URI,
14703
+ mimeType: RESOURCE_MIME_TYPE2,
14704
+ text: "<!doctype html><html><body><h2>Analytics Pulse unavailable</h2><p>The interactive bundle is unavailable on this deployment. Please contact Social Neuron support.</p></body></html>",
14705
+ _meta: { ui: { csp: ANALYTICS_CSP } }
14706
+ }
14707
+ ]
14708
+ };
14709
+ }
14710
+ }
14711
+ );
14712
+ }
14713
+
14714
+ // src/tools/connections.ts
14715
+ import { z as z33 } from "zod";
13586
14716
  init_supabase();
13587
14717
  var PLATFORM_ENUM4 = [
13588
14718
  "youtube",
@@ -13610,16 +14740,16 @@ function registerConnectionTools(server) {
13610
14740
  "start_platform_connection",
13611
14741
  "Begin connecting a social platform (Instagram, TikTok, YouTube, etc.). Returns a single-use deep link the user opens in a browser to complete the one-time OAuth handshake on socialneuron.com. This is NOT another OAuth in Claude \u2014 platform connections require a browser session because the social platforms (Meta, Google, TikTok) only accept callbacks on socialneuron.com. After the user clicks the link and approves on the platform, call `wait_for_connection` to detect completion. Link expires in 2 minutes; mint a new one if needed. Use `list_connected_accounts` first to check whether the platform is already connected before calling this.",
13612
14742
  {
13613
- platform: z32.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
13614
- 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(
13615
14745
  "Brand/project ID to bind the new social account to. Use this when connecting a brand-specific account."
13616
14746
  ),
13617
- response_format: z32.enum(["text", "json"]).optional().describe("Response format. Default: text.")
14747
+ response_format: z33.enum(["text", "json"]).optional().describe("Response format. Default: text.")
13618
14748
  },
13619
14749
  async ({ platform: platform2, project_id, response_format }) => {
13620
14750
  const format = response_format ?? "text";
13621
14751
  const userId = await getDefaultUserId();
13622
- const rl = checkRateLimit("read", `start_platform_connection:${userId}`);
14752
+ const rl = checkRateLimit("posting", `start_platform_connection:${userId}`);
13623
14753
  if (!rl.allowed) {
13624
14754
  return {
13625
14755
  content: [
@@ -13699,13 +14829,13 @@ function registerConnectionTools(server) {
13699
14829
  "wait_for_connection",
13700
14830
  "Poll until a platform connection becomes active. Use after `start_platform_connection` while the user completes the browser OAuth flow. Returns when the account row appears with status=active, or when the timeout elapses. Default timeout 120s, max 600s.",
13701
14831
  {
13702
- platform: z32.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
13703
- project_id: z32.string().optional().describe(
14832
+ platform: z33.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
14833
+ project_id: z33.string().optional().describe(
13704
14834
  "Brand/project ID to scope the connection poll. Use the same project_id passed to start_platform_connection."
13705
14835
  ),
13706
- timeout_s: z32.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
13707
- poll_interval_s: z32.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
13708
- 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.")
13709
14839
  },
13710
14840
  async ({ platform: platform2, project_id, timeout_s, poll_interval_s, response_format }) => {
13711
14841
  const format = response_format ?? "text";
@@ -13838,7 +14968,7 @@ function registerConnectionTools(server) {
13838
14968
  }
13839
14969
 
13840
14970
  // src/tools/harness.ts
13841
- import { z as z33 } from "zod";
14971
+ import { z as z34 } from "zod";
13842
14972
  var ALLOWED_AGENTS = [
13843
14973
  "conductor",
13844
14974
  "brand-brain",
@@ -13848,38 +14978,38 @@ var ALLOWED_AGENTS = [
13848
14978
  "engager"
13849
14979
  ];
13850
14980
  var writeReflectionSchema = {
13851
- reflection_text: z33.string().min(1).max(4e3).describe("The verbal reflection text produced by the agent. 1\u20134000 characters."),
13852
- 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(
13853
14983
  "Which agent produced this reflection. Must be one of: conductor, brand-brain, drafter, publisher, analyst, engager."
13854
14984
  ),
13855
- provenance: z33.object({
13856
- content_history_id: z33.string().optional().describe("Related content_history row UUID."),
13857
- outcome_event_id: z33.string().optional().describe("Related outcome event UUID."),
13858
- prm_score_ids: z33.array(z33.string()).optional().describe("PRM score IDs that informed this reflection."),
13859
- 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.")
13860
14990
  }).strict().describe(
13861
14991
  "Source evidence for this reflection. Only these four keys are accepted \u2014 unknown keys are rejected to prevent spurious provenance claims."
13862
14992
  ),
13863
- brand_id: z33.string().describe("Brand profile UUID this reflection belongs to."),
13864
- pipeline_id: z33.string().optional().describe("Optional: pipeline run UUID."),
13865
- 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.")
13866
14996
  };
13867
14997
  var readReflectionSchema = {
13868
- brand_id: z33.string().describe("Brand profile UUID to read reflections for."),
13869
- 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(
13870
15000
  "Optional filter: only return reflections produced by this agent. One of: conductor, brand-brain, drafter, publisher, analyst, engager."
13871
15001
  ),
13872
- limit: z33.number().int().min(1).max(100).optional().describe(
15002
+ limit: z34.number().int().min(1).max(100).optional().describe(
13873
15003
  "Maximum number of reflections to return. Clamped to [1, 100]. Default: 50. Results are ordered by created_at DESC, then id ASC (deterministic tiebreak)."
13874
15004
  )
13875
15005
  };
13876
15006
  var recordOutcomeSchema = {
13877
- decision_event_id: z33.string().describe("UUID of the decision_events row to record an outcome for."),
13878
- 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(
13879
15009
  "Observation horizon. Only horizon=24h with a non-null reward triggers a learning-loop update; 1h/6h are stored but inert for learning."
13880
15010
  ),
13881
- reward: z33.number().min(0).max(1).describe("Normalised reward signal in [0, 1]. Higher = better outcome."),
13882
- 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(
13883
15013
  'Optional map of raw metric name \u2192 value (e.g. {"likes": 42, "reach": 1200}). Stored verbatim; not used for learning directly.'
13884
15014
  )
13885
15015
  };
@@ -13975,7 +15105,7 @@ function registerHarnessTools(server, _ctx) {
13975
15105
  }
13976
15106
 
13977
15107
  // src/tools/hermes.ts
13978
- import { z as z34 } from "zod";
15108
+ import { z as z35 } from "zod";
13979
15109
  function asEnvelope25(data) {
13980
15110
  return {
13981
15111
  _meta: {
@@ -13985,7 +15115,7 @@ function asEnvelope25(data) {
13985
15115
  data
13986
15116
  };
13987
15117
  }
13988
- var PLATFORM = z34.enum([
15118
+ var PLATFORM = z35.enum([
13989
15119
  "instagram",
13990
15120
  "twitter",
13991
15121
  "linkedin",
@@ -14001,12 +15131,12 @@ function registerHermesTools(server) {
14001
15131
  "Save a draft post to the SN content library. Use when an autonomous agent wants to persist a draft for review before publishing. Lands in the content library with status='draft'. The draft can then be approved/edited in the SN UI.",
14002
15132
  {
14003
15133
  platform: PLATFORM.describe("Target platform for the draft."),
14004
- copy: z34.string().min(1).max(8e3).describe("The draft post body."),
14005
- project_id: z34.string().optional().describe("SN project UUID. Optional but recommended."),
14006
- media_url: z34.string().url().optional().describe("Optional cover/media URL (R2 signed URL)."),
14007
- hermes_run_id: z34.string().optional().describe("Agent run id, for traceability."),
14008
- source_intel_ids: z34.array(z34.string()).optional().describe("Optional list of intel_signals.id rows that informed this draft."),
14009
- 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()
14010
15140
  },
14011
15141
  async ({ platform: platform2, copy, project_id, media_url, hermes_run_id, response_format }) => {
14012
15142
  const { data, error } = await callEdgeFunction(
@@ -14044,15 +15174,15 @@ function registerHermesTools(server) {
14044
15174
  "record_voice_lesson",
14045
15175
  'Persist a learned voice lesson to brand_profiles.platform_voice.voice_lessons. Use after weekly reflection identifies a hook/format/CTA pattern that beats median engagement by \u226530%. Appears in SN Brand > BrandBrainPreview > "Voice lessons (auto)".',
14046
15176
  {
14047
- project_id: z34.string().describe("SN project UUID."),
14048
- lesson: z34.string().min(1).max(500).describe('One-sentence rule (e.g. "lowercase IG hooks beat title-case").'),
14049
- evidence: z34.object({
14050
- engagement_lift_pct: z34.number().describe("Engagement lift vs baseline, in percentage points."),
14051
- sample_size: z34.number().int().min(1).describe("Number of posts behind this lesson."),
14052
- 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.")
14053
15183
  }).describe("Quantitative evidence backing the lesson."),
14054
- applies_to: z34.array(PLATFORM).min(1).describe("Platforms this lesson applies to."),
14055
- 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()
14056
15186
  },
14057
15187
  async ({ project_id, lesson, evidence, applies_to, response_format }) => {
14058
15188
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -14088,10 +15218,10 @@ function registerHermesTools(server) {
14088
15218
  "record_observation",
14089
15219
  'Record an agent observation (e.g. "topic X engagement up 23% this week"). Surfaces in the analytics Playbook. Use for weekly reflection digests and mid-campaign pulse summaries.',
14090
15220
  {
14091
- summary: z34.string().min(1).max(2e3).describe("One-paragraph summary, shown to the account owner."),
14092
- 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})."),
14093
- run_id: z34.string().optional().describe("Agent run id for traceability."),
14094
- 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()
14095
15225
  },
14096
15226
  async ({ summary, deltas, run_id, response_format }) => {
14097
15227
  const { data, error } = await callEdgeFunction("mcp-data", { action: "record-observation", summary, deltas, run_id });
@@ -14118,13 +15248,13 @@ function registerHermesTools(server) {
14118
15248
  "record_intel_signal",
14119
15249
  "Record a research/trend signal (news, HN post, competitor change, arxiv paper). Surfaces in SN Brand > Niche Intelligence. Dedupes by URL per user \u2014 safe to call multiple times for the same source.",
14120
15250
  {
14121
- source: z34.string().min(1).max(100).describe('Watcher name (e.g. "news-watch", "hackernews-watch").'),
14122
- url: z34.string().url().max(2e3).describe("Canonical URL of the source. Used for dedupe."),
14123
- topic: z34.string().max(200).optional().describe("Best-fit topic key."),
14124
- title: z34.string().max(500).optional(),
14125
- summary: z34.string().max(4e3).optional().describe("One-paragraph summary of the signal."),
14126
- score: z34.number().optional().describe("Relevance score from the watcher (0\u201310 typical)."),
14127
- 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()
14128
15258
  },
14129
15259
  async ({ source, url, topic, title, summary, score, response_format }) => {
14130
15260
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -14162,16 +15292,16 @@ function registerHermesTools(server) {
14162
15292
  "record_campaign_spend",
14163
15293
  "Log a campaign cost line. Use when an autonomous agent incurs spend that should be attributed to a campaign \u2014 drafts, renders, paid amp. Read aggregate via get_active_campaigns.",
14164
15294
  {
14165
- campaign_id: z34.string().min(1).max(200).describe("Campaign identifier (slug)."),
14166
- category: z34.enum([
15295
+ campaign_id: z35.string().min(1).max(200).describe("Campaign identifier (slug)."),
15296
+ category: z35.enum([
14167
15297
  "hermes_drafts",
14168
15298
  "carousel_renders",
14169
15299
  "analytics_pulls",
14170
15300
  "paid_amplification",
14171
15301
  "other"
14172
15302
  ]).describe("Cost category."),
14173
- amount_usd: z34.number().min(0).describe("Amount in USD; supports 4 decimal places."),
14174
- 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()
14175
15305
  },
14176
15306
  async ({ campaign_id, category, amount_usd, response_format }) => {
14177
15307
  const { data, error } = await callEdgeFunction("mcp-data", {
@@ -14206,7 +15336,7 @@ function registerHermesTools(server) {
14206
15336
  "get_active_campaigns",
14207
15337
  "List currently-running campaigns with thesis, budget, hero format, and current spend. Use to bias drafts toward active campaign themes.",
14208
15338
  {
14209
- response_format: z34.enum(["text", "json"]).optional()
15339
+ response_format: z35.enum(["text", "json"]).optional()
14210
15340
  },
14211
15341
  async ({ response_format }) => {
14212
15342
  const { data, error } = await callEdgeFunction("mcp-data", { action: "get-active-campaigns" });
@@ -14251,7 +15381,7 @@ ${"=".repeat(40)}
14251
15381
  }
14252
15382
 
14253
15383
  // src/tools/skills.ts
14254
- import { z as z35 } from "zod";
15384
+ import { z as z36 } from "zod";
14255
15385
 
14256
15386
  // src/lib/skills-manifest.ts
14257
15387
  var SKILLS_MANIFEST = [
@@ -14280,6 +15410,19 @@ function listSkills(opts) {
14280
15410
  }
14281
15411
 
14282
15412
  // src/tools/skills.ts
15413
+ function renderCatalogRow(row) {
15414
+ const lines = [];
15415
+ const platform2 = row.platform ? ` \xB7 ${row.platform}` : "";
15416
+ lines.push(`${row.slug} (${row.kind}${platform2})`);
15417
+ const desc = row.frontmatter && typeof row.frontmatter.description === "string" ? row.frontmatter.description : null;
15418
+ if (desc) lines.push(` ${desc}`);
15419
+ const lock = row.locked ? ` \u{1F512} (upgrade to unlock)` : "";
15420
+ lines.push(` Tier: ${row.tier_minimum}${lock}`);
15421
+ lines.push(
15422
+ ` Model: ${row.model_id ?? "n/a"} \xB7 ${row.body_chars} chars` + (row.updated_at ? ` \xB7 updated ${row.updated_at}` : "")
15423
+ );
15424
+ return lines.join("\n");
15425
+ }
14283
15426
  function asEnvelope26(data) {
14284
15427
  return {
14285
15428
  _meta: {
@@ -14289,7 +15432,14 @@ function asEnvelope26(data) {
14289
15432
  data
14290
15433
  };
14291
15434
  }
14292
- var STUDIO_VALUES = ["video", "avatar", "carousel", "voice", "caption", "edit"];
15435
+ var STUDIO_VALUES = [
15436
+ "video",
15437
+ "avatar",
15438
+ "carousel",
15439
+ "voice",
15440
+ "caption",
15441
+ "edit"
15442
+ ];
14293
15443
  function renderSkillSummary(skill) {
14294
15444
  const lines = [];
14295
15445
  lines.push(`${skill.name} (${skill.id})`);
@@ -14308,19 +15458,88 @@ function registerSkillsTools(server) {
14308
15458
  "list_skills",
14309
15459
  'List Social Neuron content workflow skills available to the authenticated user. A skill is a brand-locked multi-step pipeline (research \u2192 hook \u2192 script \u2192 visuals \u2192 voice \u2192 captions \u2192 assembly \u2192 quality gate \u2192 schedule) inspired by documented viral patterns (MrBeast 3-second hook, Hormozi pattern interrupt, etc.). Use this tool when the user asks "what can SN do", "what skills are available", "show me viral templates", or before calling run_skill so you can pick the right one.',
14310
15460
  {
14311
- studio: z35.enum(STUDIO_VALUES).optional().describe("Filter to one studio (video, avatar, carousel, voice, caption, edit)."),
14312
- featured_only: z35.boolean().optional().describe("Return only featured (recommended) skills. Defaults to false."),
14313
- 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
+ )
14314
15470
  },
14315
15471
  async ({ studio, featured_only, response_format }) => {
14316
- const skills = listSkills({ studio, featuredOnly: featured_only });
14317
15472
  const format = response_format ?? "text";
15473
+ const { data: efData, error: efError } = await callEdgeFunction("mcp-data", { action: "get-skills" });
15474
+ const catalogRows = efData?.skills;
15475
+ if (!efError && Array.isArray(catalogRows) && catalogRows.length > 0) {
15476
+ const workflows = listSkills({ studio, featuredOnly: featured_only });
15477
+ const filteredGuideRows = catalogRows.filter(
15478
+ (row) => !studio && (!featured_only || row.frontmatter?.featured === true)
15479
+ );
15480
+ const guides = filteredGuideRows.map((row) => ({
15481
+ ...row,
15482
+ use_with: "get_skill"
15483
+ }));
15484
+ const total = guides.length + workflows.length;
15485
+ if (format === "json") {
15486
+ return {
15487
+ content: [
15488
+ {
15489
+ type: "text",
15490
+ text: JSON.stringify(
15491
+ asEnvelope26({
15492
+ count: total,
15493
+ guides,
15494
+ workflows: workflows.map((w) => ({
15495
+ ...w,
15496
+ use_with: "run_skill"
15497
+ }))
15498
+ }),
15499
+ null,
15500
+ 2
15501
+ )
15502
+ }
15503
+ ]
15504
+ };
15505
+ }
15506
+ const guideBlocks = filteredGuideRows.map(
15507
+ (row) => `${renderCatalogRow(row)}
15508
+ \u2192 Read it: get_skill(slug: "${row.slug}")`
15509
+ ).join("\n\n");
15510
+ const workflowBlocks = workflows.map(renderSkillSummary).join("\n\n");
15511
+ const header2 = `${total} skill${total === 1 ? "" : "s"} available (${guides.length} guide${guides.length === 1 ? "" : "s"} \xB7 ${workflows.length} workflow${workflows.length === 1 ? "" : "s"})
15512
+ ${"=".repeat(40)}`;
15513
+ const sections = [
15514
+ guides.length > 0 ? `GUIDES \u2014 living how-to documents. Fetch with get_skill(slug).
15515
+
15516
+ ${guideBlocks}` : "",
15517
+ workflows.length > 0 ? `WORKFLOWS \u2014 executable content pipelines. Launch with run_skill(skill_id).
15518
+
15519
+ ${workflowBlocks}` : ""
15520
+ ].filter(Boolean);
15521
+ return {
15522
+ content: [
15523
+ {
15524
+ type: "text",
15525
+ text: `${header2}
15526
+
15527
+ ${sections.join("\n\n")}`
15528
+ }
15529
+ ]
15530
+ };
15531
+ }
15532
+ const skills = listSkills({ studio, featuredOnly: featured_only });
14318
15533
  if (format === "json") {
14319
15534
  return {
14320
15535
  content: [
14321
15536
  {
14322
15537
  type: "text",
14323
- text: JSON.stringify(asEnvelope26({ count: skills.length, skills }), null, 2)
15538
+ text: JSON.stringify(
15539
+ asEnvelope26({ count: skills.length, skills }),
15540
+ null,
15541
+ 2
15542
+ )
14324
15543
  }
14325
15544
  ]
14326
15545
  };
@@ -14330,7 +15549,9 @@ function registerSkillsTools(server) {
14330
15549
  content: [
14331
15550
  {
14332
15551
  type: "text",
14333
- text: "No skills match the filter." + (studio ? ` Studio "${studio}" has no skills yet.` : "") + "\n\nAvailable studios with skills: " + Array.from(new Set(SKILLS_MANIFEST.map((s) => s.studio))).join(", ")
15552
+ text: "No skills match the filter." + (studio ? ` Studio "${studio}" has no skills yet.` : "") + "\n\nAvailable studios with skills: " + Array.from(new Set(SKILLS_MANIFEST.map((s) => s.studio))).join(
15553
+ ", "
15554
+ )
14334
15555
  }
14335
15556
  ]
14336
15557
  };
@@ -14349,18 +15570,22 @@ ${blocks}` }]
14349
15570
  "run_skill",
14350
15571
  "Run a Social Neuron workflow skill end-to-end (brand-locked content production). Returns a structured run preview with the exact step plan, credit cost, and a deep-link to launch the run in the SN dashboard. A future release executes in-process so you can stream step-by-step progress back to the user. Call list_skills first to discover available skill ids.",
14351
15572
  {
14352
- skill_id: z35.string().describe(
15573
+ skill_id: z36.string().describe(
14353
15574
  'The id of the skill to run (e.g. "skill-brand-locked-viral-hook-reel"). Use list_skills to discover available ids.'
14354
15575
  ),
14355
- topic: z35.string().min(1).describe('What the content is about (e.g. "why we built Social Neuron").'),
14356
- 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(
14357
15580
  'Who the content is for (e.g. "first-time founders"). Defaults to brand persona.'
14358
15581
  ),
14359
- hook: z35.string().optional().describe(
15582
+ hook: z36.string().optional().describe(
14360
15583
  "Optional explicit hook. If omitted, the skill drafts and scores 3-5 candidates."
14361
15584
  ),
14362
- cta: z35.string().optional().describe("Optional call-to-action override. Defaults to brand standard CTA."),
14363
- 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.")
14364
15589
  },
14365
15590
  async ({ skill_id, topic, audience, hook, cta, response_format }) => {
14366
15591
  const skill = getSkill(skill_id);
@@ -14428,10 +15653,78 @@ ${blocks}` }]
14428
15653
  };
14429
15654
  }
14430
15655
  );
15656
+ server.tool(
15657
+ "get_skill",
15658
+ `Fetch the full body of a single Social Neuron skill by slug \u2014 the hand-maintained strategy/specs plus the machine-maintained "what's working now" compiled section. Use this after list_skills when the user wants the actual playbook for a platform (e.g. "show me the TikTok skill"). Returns the skill body, compiled section, tier, and linked recipe slug (if any).`,
15659
+ {
15660
+ slug: z36.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9-]*$/).describe(
15661
+ 'The skill slug (e.g. "tiktok-content"). Use list_skills to discover slugs.'
15662
+ ),
15663
+ response_format: z36.enum(["text", "json"]).optional().describe("Response format. Defaults to text.")
15664
+ },
15665
+ async ({ slug, response_format }) => {
15666
+ const format = response_format ?? "text";
15667
+ const { data, error } = await callEdgeFunction("mcp-data", {
15668
+ action: "get-skill",
15669
+ slug
15670
+ });
15671
+ if (error) {
15672
+ return toolError(
15673
+ "upstream_error",
15674
+ "The skill catalogue could not be loaded. Please retry."
15675
+ );
15676
+ }
15677
+ const skill = data?.skill;
15678
+ if (!skill) {
15679
+ return toolError("not_found", `No skill found with slug "${slug}".`, {
15680
+ recover_with: ["Call list_skills to see available skills."]
15681
+ });
15682
+ }
15683
+ if (skill.locked) {
15684
+ return toolError(
15685
+ "permission_denied",
15686
+ "This skill requires a higher plan tier.",
15687
+ {
15688
+ details: { slug: skill.slug, tier_minimum: skill.tier_minimum },
15689
+ recover_with: [
15690
+ "Upgrade the account or choose an unlocked skill from list_skills."
15691
+ ]
15692
+ }
15693
+ );
15694
+ }
15695
+ if (format === "json") {
15696
+ return {
15697
+ content: [
15698
+ {
15699
+ type: "text",
15700
+ text: JSON.stringify(asEnvelope26(skill), null, 2)
15701
+ }
15702
+ ]
15703
+ };
15704
+ }
15705
+ const platform2 = skill.platform ? ` \xB7 ${skill.platform}` : "";
15706
+ const lock = skill.locked ? " \u{1F512} (upgrade to unlock)" : "";
15707
+ const header = [
15708
+ `${skill.slug} (${skill.kind}${platform2})`,
15709
+ "=".repeat(40),
15710
+ `Tier: ${skill.tier_minimum}${lock} \xB7 version ${skill.version}` + (skill.recipe_slug ? ` \xB7 recipe: ${skill.recipe_slug}` : ""),
15711
+ ""
15712
+ ].join("\n");
15713
+ const compiled = skill.compiled_section ? `
15714
+
15715
+ --- What's working now ---
15716
+ ${skill.compiled_section}` : "";
15717
+ return {
15718
+ content: [
15719
+ { type: "text", text: `${header}${skill.body}${compiled}` }
15720
+ ]
15721
+ };
15722
+ }
15723
+ );
14431
15724
  }
14432
15725
 
14433
15726
  // src/tools/loopPulse.ts
14434
- import { z as z36 } from "zod";
15727
+ import { z as z37 } from "zod";
14435
15728
  function asEnvelope27(data) {
14436
15729
  return {
14437
15730
  _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
@@ -14443,7 +15736,7 @@ function registerLoopPulseTools(server) {
14443
15736
  "get_loop_pulse",
14444
15737
  'Read the dynamic loop-health KPIs for the Social Neuron growth loop over the last 7 days. Returns reflection coverage, decision coverage, visual gate pass rate, learning-update application rate, per-platform learning uptake, autopilot lag, and pattern aggregation counts \u2014 each with a status ("ok" / "warn" / "bad") and a why-line explaining what the metric measures. Use this to decide whether the loop is closing or where it is stuck before recommending next moves.',
14445
15738
  {
14446
- response_format: 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.")
14447
15740
  },
14448
15741
  async ({ response_format }) => {
14449
15742
  const format = response_format ?? "text";
@@ -14486,7 +15779,7 @@ function registerLoopPulseTools(server) {
14486
15779
  }
14487
15780
 
14488
15781
  // src/tools/banditState.ts
14489
- import { z as z37 } from "zod";
15782
+ import { z as z38 } from "zod";
14490
15783
  init_supabase();
14491
15784
  function asEnvelope28(data) {
14492
15785
  return {
@@ -14499,8 +15792,8 @@ function registerBanditStateTools(server) {
14499
15792
  "get_bandit_state",
14500
15793
  "Read the current content learning state for a project. Returns top-K arms per (arm_type, platform) with expected performance and uncertainty. Use this to reason about which hook family / format / timing slot currently performs best on each platform before recommending next moves. Arm types: hook_family, length_bucket (xs/s/m/l/xl by platform), posting_time_bucket (morning/midday/evening/late), content_format (video/carousel/image/caption/text/avatar/storyboard).",
14501
15794
  {
14502
- project_id: z37.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
14503
- platform: z37.enum([
15795
+ project_id: z38.string().uuid().optional().describe("Project UUID. Defaults to the authenticated user default project."),
15796
+ platform: z38.enum([
14504
15797
  "instagram",
14505
15798
  "tiktok",
14506
15799
  "youtube",
@@ -14512,7 +15805,7 @@ function registerBanditStateTools(server) {
14512
15805
  ]).optional().describe(
14513
15806
  "Lowercase platform name. Omit to return both platform-scoped and legacy platform-agnostic arms."
14514
15807
  ),
14515
- arm_type: z37.enum([
15808
+ arm_type: z38.enum([
14516
15809
  "hook_family",
14517
15810
  "hook_type",
14518
15811
  "format",
@@ -14526,8 +15819,8 @@ function registerBanditStateTools(server) {
14526
15819
  "posting_time_bucket",
14527
15820
  "content_format"
14528
15821
  ]).optional().describe("Arm dimension. Omit to return all types grouped."),
14529
- top_k: z37.number().min(1).max(50).optional().describe("Max arms per (arm_type) group. Default 5."),
14530
- 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.")
14531
15824
  },
14532
15825
  async ({ project_id, platform: platform2, arm_type, top_k, response_format }) => {
14533
15826
  const format = response_format ?? "text";
@@ -14582,12 +15875,128 @@ function registerBanditStateTools(server) {
14582
15875
  );
14583
15876
  }
14584
15877
 
15878
+ // src/tools/lifecycle.ts
15879
+ import { z as z39 } from "zod";
15880
+ init_supabase();
15881
+ function envelope(data) {
15882
+ return {
15883
+ _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
15884
+ data
15885
+ };
15886
+ }
15887
+ function lifecycleFailure(error) {
15888
+ if (/not_found|not found/i.test(error)) {
15889
+ return toolError("not_found", "The requested object was not found in this project.");
15890
+ }
15891
+ if (/not_cancellable|publishing_in_progress|schedule_conflict|post_in_progress/i.test(error)) {
15892
+ return toolError("validation_error", "The object can no longer be cancelled in its current state.", {
15893
+ recover_with: ["Refresh its status before deciding the next action."]
15894
+ });
15895
+ }
15896
+ return toolError("upstream_error", "The lifecycle operation could not be completed. Please retry.");
15897
+ }
15898
+ async function projectContext(projectId) {
15899
+ return projectId ?? await getDefaultProjectId();
15900
+ }
15901
+ async function invokeLifecycle(action, projectId, identifiers) {
15902
+ const resolvedProjectId = await projectContext(projectId);
15903
+ if (!resolvedProjectId) {
15904
+ return toolError("validation_error", "A project_id is required because no default project is configured.");
15905
+ }
15906
+ const { data, error } = await callEdgeFunction("mcp-data", {
15907
+ action,
15908
+ projectId: resolvedProjectId,
15909
+ project_id: resolvedProjectId,
15910
+ ...identifiers
15911
+ });
15912
+ if (error) return lifecycleFailure(error);
15913
+ if (!data?.success) return toolError("upstream_error", "The lifecycle operation returned no result.");
15914
+ const result = envelope(data);
15915
+ return {
15916
+ structuredContent: result,
15917
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
15918
+ };
15919
+ }
15920
+ var PROJECT_ID = z39.string().uuid().optional().describe("Brand/project ID. Defaults to the authenticated key's project or account default.");
15921
+ var CONFIRM = z39.literal(true).describe("Must be true only after the user explicitly confirms this destructive action.");
15922
+ function registerLifecycleTools(server) {
15923
+ server.tool(
15924
+ "cancel_async_job",
15925
+ "Cancel an owned pending async generation/render job before a worker starts it. If credits were already debited, the backend attempts an idempotent refund and reports the result. Processing or terminal jobs are not cancellable.",
15926
+ {
15927
+ job_id: z39.string().uuid().describe("Owned async_jobs ID returned by a generation tool."),
15928
+ project_id: PROJECT_ID,
15929
+ confirm: CONFIRM
15930
+ },
15931
+ async ({ job_id, project_id }) => invokeLifecycle("cancel-async-job", project_id, { job_id })
15932
+ );
15933
+ server.tool(
15934
+ "cancel_scheduled_post",
15935
+ "Cancel an owned draft, pending, or scheduled post before publishing starts. This closes its pending schedule job first; it refuses once a worker has claimed the publication.",
15936
+ {
15937
+ post_id: z39.string().uuid().describe("Owned scheduled post ID."),
15938
+ project_id: PROJECT_ID,
15939
+ confirm: CONFIRM
15940
+ },
15941
+ async ({ post_id, project_id }) => invokeLifecycle("cancel-scheduled-post", project_id, { post_id })
15942
+ );
15943
+ server.tool(
15944
+ "delete_carousel",
15945
+ "Delete an owned carousel content-history record from one project. Stored media is retained until the normal retention cleanup; this does not delete already-published platform posts.",
15946
+ {
15947
+ content_id: z39.string().uuid().describe("Owned carousel content_history ID."),
15948
+ project_id: PROJECT_ID,
15949
+ confirm: CONFIRM
15950
+ },
15951
+ async ({ content_id, project_id }) => invokeLifecycle("delete-carousel", project_id, { content_id })
15952
+ );
15953
+ server.tool(
15954
+ "delete_content_plan",
15955
+ "Permanently delete an owned content plan in one project. This does not cancel posts that were already scheduled from the plan.",
15956
+ {
15957
+ plan_id: z39.string().uuid().describe("Owned content plan ID."),
15958
+ project_id: PROJECT_ID,
15959
+ confirm: CONFIRM
15960
+ },
15961
+ async ({ plan_id, project_id }) => invokeLifecycle("delete-content-plan", project_id, { plan_id })
15962
+ );
15963
+ server.tool(
15964
+ "delete_autopilot_config",
15965
+ "Permanently delete an owned autopilot configuration in one project. Historical runs and already-published posts are retained.",
15966
+ {
15967
+ config_id: z39.string().uuid().describe("Owned autopilot configuration ID."),
15968
+ project_id: PROJECT_ID,
15969
+ confirm: CONFIRM
15970
+ },
15971
+ async ({ config_id, project_id }) => invokeLifecycle("delete-autopilot-config", project_id, { config_id })
15972
+ );
15973
+ }
15974
+
14585
15975
  // src/lib/register-tools.ts
15976
+ var BASE64_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["file_data", "fileData"]);
15977
+ var DATA_URI_PREFIX2 = /^data:[\w.+-]+\/[\w.+-]+;base64,/;
15978
+ var STRICT_BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
15979
+ function redactBase64Payloads(args) {
15980
+ if (typeof args !== "object" || args === null || Array.isArray(args)) return args;
15981
+ let changed = false;
15982
+ const out = { ...args };
15983
+ for (const key of BASE64_PAYLOAD_KEYS) {
15984
+ const value = out[key];
15985
+ if (typeof value !== "string" || value.length < 64) continue;
15986
+ const body = value.replace(DATA_URI_PREFIX2, "");
15987
+ if (STRICT_BASE64.test(body)) {
15988
+ out[key] = `[base64:${body.length} chars omitted from prose scan]`;
15989
+ changed = true;
15990
+ }
15991
+ }
15992
+ return changed ? out : args;
15993
+ }
14586
15994
  function wrapToolWithScanner(toolName, handler) {
14587
15995
  return async function scannerWrappedHandler(...handlerArgs) {
14588
15996
  const args = handlerArgs[0];
14589
15997
  const ctx = handlerArgs[1];
14590
- const 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);
14591
16000
  const inputScan = scan(inputText, {
14592
16001
  mode: "block",
14593
16002
  source: "mcp_tool_input",
@@ -14615,6 +16024,16 @@ function wrapToolWithScanner(toolName, handler) {
14615
16024
  source: "mcp_tool_output",
14616
16025
  user_id: ctx?.userId
14617
16026
  });
16027
+ if (!outputScan.passed) {
16028
+ try {
16029
+ ctx?.logScan?.(toolName, "output", outputScan);
16030
+ } catch {
16031
+ }
16032
+ return toolError(
16033
+ "server_error",
16034
+ "The response exceeded the safe output limit and was not returned."
16035
+ );
16036
+ }
14618
16037
  if (outputScan.sanitized_text !== void 0) {
14619
16038
  try {
14620
16039
  ctx?.logScan?.(toolName, "output", outputScan);
@@ -14623,7 +16042,10 @@ function wrapToolWithScanner(toolName, handler) {
14623
16042
  try {
14624
16043
  return JSON.parse(outputScan.sanitized_text);
14625
16044
  } catch {
14626
- return result;
16045
+ return toolError(
16046
+ "server_error",
16047
+ "The response could not be returned safely. Please retry or contact support."
16048
+ );
14627
16049
  }
14628
16050
  }
14629
16051
  return result;
@@ -14673,8 +16095,7 @@ function applyScopeEnforcement(server, scopeResolver) {
14673
16095
  details: {
14674
16096
  source: "wrapper",
14675
16097
  // A thrown exception escaped the handler — an unclassified fault.
14676
- error_type: "server_error",
14677
- exception: err instanceof Error ? err.message.slice(0, 200) : "unknown"
16098
+ error_type: "server_error"
14678
16099
  }
14679
16100
  });
14680
16101
  throw err;
@@ -14807,24 +16228,26 @@ function registerAllTools(server, options) {
14807
16228
  registerSkillsTools(server);
14808
16229
  registerLoopPulseTools(server);
14809
16230
  registerBanditStateTools(server);
16231
+ registerLifecycleTools(server);
14810
16232
  if (!options?.skipApps) {
14811
16233
  registerContentCalendarApp(server);
16234
+ registerAnalyticsPulseApp(server);
14812
16235
  }
14813
16236
  applyAnnotations(server);
14814
16237
  }
14815
16238
 
14816
16239
  // src/prompts.ts
14817
- import { z as z38 } from "zod";
16240
+ import { z as z40 } from "zod";
14818
16241
  function registerPrompts(server) {
14819
16242
  server.prompt(
14820
16243
  "create_weekly_content_plan",
14821
16244
  "Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
14822
16245
  {
14823
- niche: z38.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
14824
- 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(
14825
16248
  'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
14826
16249
  ),
14827
- 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")')
14828
16251
  },
14829
16252
  ({ niche, platforms, tone }) => {
14830
16253
  const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
@@ -14866,8 +16289,8 @@ After building the plan, use \`save_content_plan\` to save it.`
14866
16289
  "analyze_top_content",
14867
16290
  "Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
14868
16291
  {
14869
- timeframe: z38.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
14870
- 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")')
14871
16294
  },
14872
16295
  ({ timeframe, platform: platform2 }) => {
14873
16296
  const period = timeframe || "30 days";
@@ -14904,10 +16327,10 @@ Format as a clear, actionable performance report.`
14904
16327
  "repurpose_content",
14905
16328
  "Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
14906
16329
  {
14907
- source: z38.string().describe(
16330
+ source: z40.string().describe(
14908
16331
  "The source content to repurpose \u2014 a URL, transcript, or the content text itself"
14909
16332
  ),
14910
- target_platforms: z38.string().optional().describe(
16333
+ target_platforms: z40.string().optional().describe(
14911
16334
  'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
14912
16335
  )
14913
16336
  },
@@ -14949,9 +16372,9 @@ For each piece, include the platform, format, character count, and suggested pos
14949
16372
  "setup_brand_voice",
14950
16373
  "Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
14951
16374
  {
14952
- brand_name: z38.string().describe("Your brand or business name"),
14953
- industry: z38.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
14954
- 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")
14955
16378
  },
14956
16379
  ({ brand_name, industry, website }) => {
14957
16380
  const industryContext = industry ? ` in the ${industry} space` : "";
@@ -15314,11 +16737,7 @@ init_request_context();
15314
16737
 
15315
16738
  // src/lib/discovery-catalog.ts
15316
16739
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15317
- var PUBLIC_SCHEMA_OMIT_PROPERTIES = {
15318
- // Internal lineage fields are accepted by the runtime for Social Neuron's own
15319
- // automation, but should not be advertised in public MCP/OpenAPI discovery.
15320
- schedule_post: ["origin", "hermes_run_id"]
15321
- };
16740
+ var PUBLIC_SCHEMA_OMIT_PROPERTIES = {};
15322
16741
  var cached = /* @__PURE__ */ new Map();
15323
16742
  function buildDiscoveryCatalog(profile = "full") {
15324
16743
  const existing = cached.get(profile);
@@ -15445,7 +16864,9 @@ function buildOpenApiDocument() {
15445
16864
  async function computeOpenApiDocument() {
15446
16865
  const discovery = await buildDiscoveryCatalog();
15447
16866
  const schemaByName = new Map(discovery.map((t) => [t.name, t.inputSchema]));
15448
- const publicTools = TOOL_CATALOG.filter((t) => !t.localOnly && !t.internal);
16867
+ const publicTools = TOOL_CATALOG.filter(
16868
+ (t) => !t.localOnly && !t.internal && !t.hiddenFromPublicCount
16869
+ );
15449
16870
  const paths = {};
15450
16871
  for (const tool of publicTools) {
15451
16872
  const scope = TOOL_SCOPES[tool.name];
@@ -15528,7 +16949,11 @@ function getCallHandler() {
15528
16949
  return cachedCallHandler;
15529
16950
  }
15530
16951
  function restToolNames() {
15531
- return new Set(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
+ );
15532
16957
  }
15533
16958
  async function invokeToolRest(name, args) {
15534
16959
  const handler = getCallHandler();
@@ -15603,7 +17028,7 @@ function httpStatusForResult(result) {
15603
17028
  }
15604
17029
 
15605
17030
  // src/lib/token-verifier.ts
15606
- import { createHash as createHash3 } from "node:crypto";
17031
+ import { createHash as createHash2 } from "node:crypto";
15607
17032
  import * as jose from "jose";
15608
17033
  var jwks = null;
15609
17034
  function getJWKS(supabaseUrl) {
@@ -15614,10 +17039,21 @@ function getJWKS(supabaseUrl) {
15614
17039
  return jwks;
15615
17040
  }
15616
17041
  var tokenValidationCache = /* @__PURE__ */ new Map();
15617
- var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS = 3e5;
17042
+ var CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS = 6e4;
15618
17043
  var DEFAULT_MCP_RESOURCE = "https://mcp.socialneuron.com";
17044
+ var VALID_MCP_SCOPES = new Set(getAllScopes());
17045
+ function validScopes(value) {
17046
+ if (!Array.isArray(value)) return [];
17047
+ return Array.from(
17048
+ new Set(
17049
+ value.filter(
17050
+ (scope) => typeof scope === "string" && VALID_MCP_SCOPES.has(scope)
17051
+ )
17052
+ )
17053
+ );
17054
+ }
15619
17055
  function cacheKey(token) {
15620
- return createHash3("sha256").update(token).digest("hex");
17056
+ return createHash2("sha256").update(token).digest("hex");
15621
17057
  }
15622
17058
  function evictFromCache(token) {
15623
17059
  tokenValidationCache.delete(cacheKey(token));
@@ -15652,6 +17088,7 @@ async function verifyCachedOpaqueToken(token, validate, ttlMs) {
15652
17088
  }
15653
17089
  function createTokenVerifier(options) {
15654
17090
  const { supabaseUrl, supabaseAnonKey } = options;
17091
+ const allowSupabaseSessionTokens = options.allowSupabaseSessionTokens ?? process.env.MCP_ALLOW_SUPABASE_SESSION_TOKENS === "true";
15655
17092
  const expectedResource = normalizeResource(
15656
17093
  options.resource ?? process.env.MCP_RESOURCE_URL ?? process.env.MCP_SERVER_URL
15657
17094
  ) ?? DEFAULT_MCP_RESOURCE;
@@ -15667,7 +17104,10 @@ function createTokenVerifier(options) {
15667
17104
  CONNECTOR_TOKEN_VALIDATION_CACHE_TTL_MS
15668
17105
  );
15669
17106
  }
15670
- return verifySupabaseJwt(token, supabaseUrl);
17107
+ if (!allowSupabaseSessionTokens) {
17108
+ throw new Error("Unsupported access token");
17109
+ }
17110
+ return verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey);
15671
17111
  }
15672
17112
  };
15673
17113
  }
@@ -15675,7 +17115,10 @@ function normalizeResource(value) {
15675
17115
  if (!value) return void 0;
15676
17116
  try {
15677
17117
  const parsed = new URL(value);
15678
- 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(/\/$/, "");
15679
17122
  } catch {
15680
17123
  return value.replace(/\/$/, "");
15681
17124
  }
@@ -15689,7 +17132,7 @@ function audienceIncludesExpected(audience, expectedResource) {
15689
17132
  }
15690
17133
  return false;
15691
17134
  }
15692
- async function verifySupabaseJwt(token, supabaseUrl) {
17135
+ async function verifySupabaseJwt(token, supabaseUrl, supabaseAnonKey) {
15693
17136
  const jwksKeySet = getJWKS(supabaseUrl);
15694
17137
  const { payload } = await jose.jwtVerify(token, jwksKeySet, {
15695
17138
  issuer: `${supabaseUrl}/auth/v1`,
@@ -15701,14 +17144,41 @@ async function verifySupabaseJwt(token, supabaseUrl) {
15701
17144
  if (!userId) {
15702
17145
  throw new Error("JWT missing sub claim");
15703
17146
  }
15704
- const appMetadata = payload.app_metadata ?? {};
15705
- 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
+ }
15706
17176
  return {
15707
17177
  token,
15708
17178
  clientId: payload.client_id ?? "supabase-oauth",
15709
- scopes,
17179
+ scopes: validScopes(entitlements.scopes),
15710
17180
  expiresAt: payload.exp,
15711
- extra: { userId }
17181
+ extra: { userId, tier: entitlements.tier }
15712
17182
  };
15713
17183
  }
15714
17184
  async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
@@ -15740,12 +17210,12 @@ async function verifyApiKey(apiKey, supabaseUrl, supabaseAnonKey) {
15740
17210
  throw new Error("API key expired");
15741
17211
  }
15742
17212
  const projectId = data.projectId ?? data.project_id ?? null;
15743
- const extra = { userId: data.userId, email: data.email };
17213
+ const extra = { userId: data.userId };
15744
17214
  if (projectId) extra.projectId = projectId;
15745
17215
  return {
15746
17216
  token: apiKey,
15747
17217
  clientId: "api-key",
15748
- scopes: data.scopes ?? ["mcp:read"],
17218
+ scopes: validScopes(data.scopes),
15749
17219
  expiresAt,
15750
17220
  extra
15751
17221
  };
@@ -15791,9 +17261,9 @@ async function verifyConnectorToken(accessToken, supabaseUrl, supabaseAnonKey, e
15791
17261
  return {
15792
17262
  token: accessToken,
15793
17263
  clientId: data.clientId ?? "connector-oauth",
15794
- scopes: data.scopes ?? ["mcp:read"],
17264
+ scopes: validScopes(data.scopes),
15795
17265
  expiresAt,
15796
- extra: { userId: data.userId, email: data.email, resource: expectedResource }
17266
+ extra: { userId: data.userId, resource: expectedResource }
15797
17267
  };
15798
17268
  } catch (err) {
15799
17269
  if (err instanceof Error && err.name === "AbortError") {
@@ -15940,7 +17410,7 @@ function createClientsStore() {
15940
17410
  function markUnavailable(reason) {
15941
17411
  if (supabaseAvailable) {
15942
17412
  console.error(
15943
- `[oauth] persistent client store unavailable: ${reason}. 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.`
15944
17414
  );
15945
17415
  supabaseAvailable = false;
15946
17416
  }
@@ -15969,7 +17439,7 @@ function createClientsStore() {
15969
17439
  const { error: deleteError } = await supabase.from("mcp_oauth_clients").delete().eq("client_id", clientId);
15970
17440
  if (deleteError) {
15971
17441
  console.error(
15972
- `[oauth] failed to remove client with disallowed redirect URI: ${deleteError.message}`
17442
+ `[oauth] failed to remove client with disallowed redirect URI: ${sanitizeError(deleteError)}`
15973
17443
  );
15974
17444
  }
15975
17445
  return void 0;
@@ -16030,7 +17500,11 @@ function createOAuthProvider(options) {
16030
17500
  const { supabaseUrl, supabaseAnonKey } = options;
16031
17501
  const appBaseUrl = options.appBaseUrl ?? "https://www.socialneuron.com";
16032
17502
  const clientsStore = createClientsStore();
16033
- const tokenVerifier2 = createTokenVerifier({ supabaseUrl, supabaseAnonKey });
17503
+ const tokenVerifier2 = createTokenVerifier({
17504
+ supabaseUrl,
17505
+ supabaseAnonKey,
17506
+ resource: options.resource
17507
+ });
16034
17508
  return {
16035
17509
  get clientsStore() {
16036
17510
  return clientsStore;
@@ -16103,12 +17577,11 @@ function createOAuthProvider(options) {
16103
17577
  }
16104
17578
  clearTimeout(timer);
16105
17579
  if (!response.ok) {
16106
- const err = await response.json().catch(() => ({ error: "Exchange failed" }));
16107
- throw new Error(err.error ?? `HTTP ${response.status}`);
17580
+ throw new Error(`Authorization code exchange failed (HTTP ${response.status})`);
16108
17581
  }
16109
17582
  const data = await response.json();
16110
17583
  if (!data.access_token) {
16111
- throw new Error(data.error ?? "No access token returned from exchange");
17584
+ throw new Error("No access token returned from exchange");
16112
17585
  }
16113
17586
  return {
16114
17587
  access_token: data.access_token,
@@ -16140,12 +17613,11 @@ function createOAuthProvider(options) {
16140
17613
  }
16141
17614
  );
16142
17615
  if (!response.ok) {
16143
- const err = await response.json().catch(() => ({ error: "Refresh failed" }));
16144
- throw new Error(err.error ?? `HTTP ${response.status}`);
17616
+ throw new Error(`Refresh token exchange failed (HTTP ${response.status})`);
16145
17617
  }
16146
17618
  const data = await response.json();
16147
17619
  if (!data.access_token) {
16148
- throw new Error(data.error ?? "No access token returned from refresh");
17620
+ throw new Error("No access token returned from refresh");
16149
17621
  }
16150
17622
  return {
16151
17623
  access_token: data.access_token,
@@ -16189,8 +17661,7 @@ function createOAuthProvider(options) {
16189
17661
  throw new Error(`Token revocation failed: HTTP ${response.status}`);
16190
17662
  }
16191
17663
  } catch (err) {
16192
- const msg = err instanceof Error ? err.message : "unknown";
16193
- console.error(`[oauth] Token revocation call failed: ${msg}`);
17664
+ console.error(`[oauth] Token revocation call failed: ${sanitizeError(err)}`);
16194
17665
  } finally {
16195
17666
  clearTimeout(timer);
16196
17667
  }
@@ -16202,6 +17673,10 @@ function createOAuthProvider(options) {
16202
17673
  init_posthog();
16203
17674
 
16204
17675
  // src/lib/protected-resource-metadata.ts
17676
+ var PROTECTED_RESOURCE_METADATA_PATHS = [
17677
+ "/.well-known/oauth-protected-resource",
17678
+ "/.well-known/oauth-protected-resource/mcp"
17679
+ ];
16205
17680
  function buildProtectedResourceMetadata(options) {
16206
17681
  return {
16207
17682
  resource: options.resourceUrl,
@@ -16249,18 +17724,16 @@ function parseAllowedOrigins(raw) {
16249
17724
  }
16250
17725
  function buildOriginPolicy(input) {
16251
17726
  const envOrigins = parseAllowedOrigins(input.allowedOriginsEnv);
16252
- if (envOrigins.size > 0) {
16253
- return { allowedOrigins: envOrigins, source: "env" };
16254
- }
16255
- const fallback = new Set(PRODUCTION_FALLBACK_ORIGINS);
17727
+ const source = envOrigins.size > 0 ? "env" : "fallback";
17728
+ const allowedOrigins = source === "env" ? envOrigins : new Set(PRODUCTION_FALLBACK_ORIGINS);
16256
17729
  for (const configuredUrl of input.configuredUrls ?? []) {
16257
17730
  const normalized = normalizeOrigin(configuredUrl);
16258
- if (normalized) fallback.add(normalized);
17731
+ if (normalized) allowedOrigins.add(normalized);
16259
17732
  }
16260
- if (input.nodeEnv !== "production") {
16261
- 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);
16262
17735
  }
16263
- return { allowedOrigins: fallback, source: "fallback" };
17736
+ return { allowedOrigins, source };
16264
17737
  }
16265
17738
  function validateBrowserOrigin(originHeader, policy) {
16266
17739
  if (originHeader === void 0) return { allowed: true, origin: null };
@@ -16272,6 +17745,20 @@ function validateBrowserOrigin(originHeader, policy) {
16272
17745
  return { allowed: true, origin: normalized };
16273
17746
  }
16274
17747
 
17748
+ // src/lib/session-lru.ts
17749
+ function findOldestIdleSessionId(sessions2, userId) {
17750
+ let candidateId = null;
17751
+ let candidateActivity = Number.POSITIVE_INFINITY;
17752
+ for (const [sessionId, entry] of sessions2) {
17753
+ if (userId !== void 0 && entry.userId !== userId) continue;
17754
+ if (entry.activeRequests > 0) continue;
17755
+ if (entry.lastActivity >= candidateActivity) continue;
17756
+ candidateId = sessionId;
17757
+ candidateActivity = entry.lastActivity;
17758
+ }
17759
+ return candidateId;
17760
+ }
17761
+
16275
17762
  // src/http.ts
16276
17763
  process.env.MCP_TRANSPORT = "http";
16277
17764
  var PORT = parseInt(process.env.PORT ?? "8080", 10);
@@ -16281,9 +17768,19 @@ var MCP_SERVER_URL = process.env.MCP_SERVER_URL ?? `http://localhost:${PORT}/mcp
16281
17768
  var APP_BASE_URL = process.env.APP_BASE_URL ?? "https://www.socialneuron.com";
16282
17769
  var NODE_ENV = process.env.NODE_ENV ?? "development";
16283
17770
  var TOOL_PROFILE = resolveToolProfile(process.env.MCP_TOOL_PROFILE);
17771
+ var TRUSTED_BROWSER_MCP_CLIENTS = [
17772
+ "https://claude.ai",
17773
+ "https://claude.com",
17774
+ "https://chatgpt.com",
17775
+ "https://chat.openai.com"
17776
+ ];
16284
17777
  var ORIGIN_POLICY = buildOriginPolicy({
16285
17778
  allowedOriginsEnv: process.env.ALLOWED_ORIGINS,
16286
- configuredUrls: [APP_BASE_URL, MCP_SERVER_URL],
17779
+ configuredUrls: [
17780
+ APP_BASE_URL,
17781
+ MCP_SERVER_URL,
17782
+ ...TRUSTED_BROWSER_MCP_CLIENTS
17783
+ ],
16287
17784
  nodeEnv: NODE_ENV
16288
17785
  });
16289
17786
  function deriveOAuthIssuerUrl() {
@@ -16308,6 +17805,18 @@ function deriveOAuthIssuerUrl() {
16308
17805
  return "https://mcp.socialneuron.com";
16309
17806
  }
16310
17807
  var OAUTH_ISSUER_URL = deriveOAuthIssuerUrl();
17808
+ function logOperationalError(label, error) {
17809
+ const category = error === void 0 ? "An internal error occurred." : sanitizeError(error);
17810
+ console.error(`[MCP HTTP] ${label}: ${category}`);
17811
+ }
17812
+ function safeEndpointForLog(value) {
17813
+ try {
17814
+ const url = new URL(value);
17815
+ return `${url.origin}${url.pathname}`;
17816
+ } catch {
17817
+ return "[invalid endpoint configuration]";
17818
+ }
17819
+ }
16311
17820
  if (!SUPABASE_URL2 || !SUPABASE_ANON_KEY) {
16312
17821
  console.error("[MCP HTTP] Missing SUPABASE_URL or SUPABASE_ANON_KEY");
16313
17822
  process.exit(1);
@@ -16319,17 +17828,19 @@ if (SUPABASE_SERVICE_ROLE_KEY && SUPABASE_SERVICE_ROLE_KEY.length < 100) {
16319
17828
  );
16320
17829
  }
16321
17830
  process.on("uncaughtException", (err) => {
16322
- console.error(`[MCP HTTP] Uncaught exception: ${err.message}`);
17831
+ logOperationalError("Uncaught exception", err);
16323
17832
  process.exit(1);
16324
17833
  });
16325
17834
  process.on("unhandledRejection", (reason) => {
16326
- const message = reason instanceof Error ? reason.message : String(reason);
16327
- console.error(`[MCP HTTP] Unhandled rejection: ${message}`);
17835
+ logOperationalError("Unhandled rejection", reason);
16328
17836
  process.exit(1);
16329
17837
  });
16330
17838
  var tokenVerifier = createTokenVerifier({
16331
17839
  supabaseUrl: SUPABASE_URL2,
16332
- supabaseAnonKey: SUPABASE_ANON_KEY
17840
+ supabaseAnonKey: SUPABASE_ANON_KEY,
17841
+ // Match RFC 9728 metadata exactly. Opaque connector tokens minted for the
17842
+ // issuer origin must not be replayable at the more-specific /mcp resource.
17843
+ resource: MCP_SERVER_URL
16333
17844
  });
16334
17845
  initPostHog();
16335
17846
  var MAX_SESSIONS = 500;
@@ -16343,15 +17854,46 @@ function countUserSessions(userId) {
16343
17854
  }
16344
17855
  return count;
16345
17856
  }
17857
+ async function closeSessionEntry(sessionId, entry) {
17858
+ sessions.delete(sessionId);
17859
+ for (const close of [
17860
+ () => entry.transport.close(),
17861
+ () => entry.server.close()
17862
+ ]) {
17863
+ try {
17864
+ await close();
17865
+ } catch {
17866
+ }
17867
+ }
17868
+ }
17869
+ async function reclaimOldestIdleSession(userId) {
17870
+ const sessionId = findOldestIdleSessionId(sessions, userId);
17871
+ if (!sessionId) return false;
17872
+ const entry = sessions.get(sessionId);
17873
+ if (!entry) return false;
17874
+ await closeSessionEntry(sessionId, entry);
17875
+ console.log(
17876
+ `[MCP HTTP] Reclaimed one idle ${userId ? "user" : "global"} session.`
17877
+ );
17878
+ return true;
17879
+ }
17880
+ async function runInSession(entry, callback) {
17881
+ entry.activeRequests += 1;
17882
+ entry.lastActivity = Date.now();
17883
+ try {
17884
+ return await callback();
17885
+ } finally {
17886
+ entry.activeRequests = Math.max(0, entry.activeRequests - 1);
17887
+ entry.lastActivity = Date.now();
17888
+ }
17889
+ }
16346
17890
  var cleanupInterval = setInterval(
16347
17891
  () => {
16348
17892
  const now = Date.now();
16349
17893
  for (const [sessionId, entry] of sessions) {
16350
- if (now - entry.lastActivity > SESSION_TIMEOUT_MS) {
16351
- entry.transport.close();
16352
- entry.server.close();
16353
- sessions.delete(sessionId);
16354
- console.log(`[MCP HTTP] Cleaned up stale session: ${sessionId}`);
17894
+ if (now - entry.lastActivity > SESSION_TIMEOUT_MS && entry.activeRequests === 0) {
17895
+ void closeSessionEntry(sessionId, entry);
17896
+ console.log("[MCP HTTP] Cleaned up one stale session.");
16355
17897
  }
16356
17898
  }
16357
17899
  },
@@ -16374,7 +17916,7 @@ setInterval(() => {
16374
17916
  }
16375
17917
  }, IP_RATE_CLEANUP_INTERVAL).unref();
16376
17918
  app.use((req, res, next) => {
16377
- if (req.path === "/health" || req.path === "/.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")
16378
17920
  return next();
16379
17921
  const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
16380
17922
  const now = Date.now();
@@ -16445,7 +17987,8 @@ app.use((req, res, next) => {
16445
17987
  var oauthProvider = createOAuthProvider({
16446
17988
  supabaseUrl: SUPABASE_URL2,
16447
17989
  supabaseAnonKey: SUPABASE_ANON_KEY,
16448
- appBaseUrl: APP_BASE_URL
17990
+ appBaseUrl: APP_BASE_URL,
17991
+ resource: MCP_SERVER_URL
16449
17992
  });
16450
17993
  var SCOPES_SUPPORTED = [
16451
17994
  "mcp:full",
@@ -16456,7 +17999,7 @@ var SCOPES_SUPPORTED = [
16456
17999
  "mcp:comments",
16457
18000
  "mcp:autopilot"
16458
18001
  ];
16459
- app.get("/.well-known/oauth-protected-resource", (_req, res) => {
18002
+ app.get(PROTECTED_RESOURCE_METADATA_PATHS, (_req, res) => {
16460
18003
  res.json(
16461
18004
  buildProtectedResourceMetadata({
16462
18005
  resourceUrl: MCP_SERVER_URL,
@@ -16512,7 +18055,7 @@ app.use((req, _res, next) => {
16512
18055
  app.use((req, res, next) => {
16513
18056
  authRouter(req, res, (err) => {
16514
18057
  if (err) {
16515
- console.error("[MCP HTTP] Auth router error:", err);
18058
+ logOperationalError("Auth router error", err);
16516
18059
  }
16517
18060
  next(err);
16518
18061
  });
@@ -16545,8 +18088,8 @@ async function authenticateRequest(req, res, next) {
16545
18088
  projectId: authInfo.extra?.projectId ?? null
16546
18089
  };
16547
18090
  next();
16548
- } catch (err) {
16549
- 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.";
16550
18093
  res.setHeader(
16551
18094
  "WWW-Authenticate",
16552
18095
  buildWwwAuthenticateHeader({
@@ -16654,11 +18197,7 @@ app.get(
16654
18197
  status: "ok",
16655
18198
  version: MCP_VERSION,
16656
18199
  transport: "streamable-http",
16657
- sessions: sessions.size,
16658
- sessionCap: MAX_SESSIONS,
16659
- uptime: Math.floor(process.uptime()),
16660
- memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
16661
- env: NODE_ENV
18200
+ uptime: Math.floor(process.uptime())
16662
18201
  });
16663
18202
  }
16664
18203
  );
@@ -16710,7 +18249,8 @@ app.post(
16710
18249
  });
16711
18250
  return;
16712
18251
  }
16713
- const rl = checkRateLimit("read", `rest:${auth.userId}`);
18252
+ const category = rateLimitCategoryForTool(name);
18253
+ const rl = checkRateLimit(category, `rest:${auth.userId}`);
16714
18254
  if (!rl.allowed) {
16715
18255
  res.setHeader("Retry-After", String(rl.retryAfter));
16716
18256
  res.status(429).json({
@@ -16777,7 +18317,9 @@ app.post(
16777
18317
  app.post("/mcp", async (req, res) => {
16778
18318
  const auth = req.auth;
16779
18319
  const existingSessionId = req.headers["mcp-session-id"];
16780
- const 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}`);
16781
18323
  if (!rl.allowed) {
16782
18324
  res.setHeader("Retry-After", String(rl.retryAfter));
16783
18325
  res.status(429).json({
@@ -16797,31 +18339,33 @@ app.post("/mcp", async (req, res) => {
16797
18339
  });
16798
18340
  return;
16799
18341
  }
16800
- entry.lastActivity = Date.now();
16801
- await requestContext.run(
16802
- {
16803
- userId: auth.userId,
16804
- scopes: auth.scopes,
16805
- token: auth.token,
16806
- creditsUsed: 0,
16807
- assetsGenerated: 0,
16808
- projectId: auth.projectId
16809
- },
16810
- () => 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
+ )
16811
18355
  );
16812
18356
  return;
16813
18357
  }
16814
- if (sessions.size >= MAX_SESSIONS) {
18358
+ if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER && !await reclaimOldestIdleSession(auth.userId)) {
16815
18359
  res.status(429).json({
16816
18360
  error: "too_many_sessions",
16817
- error_description: `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.`
16818
18362
  });
16819
18363
  return;
16820
18364
  }
16821
- if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER) {
18365
+ if (sessions.size >= MAX_SESSIONS && !await reclaimOldestIdleSession()) {
16822
18366
  res.status(429).json({
16823
18367
  error: "too_many_sessions",
16824
- 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.`
16825
18369
  });
16826
18370
  return;
16827
18371
  }
@@ -16836,14 +18380,17 @@ app.post("/mcp", async (req, res) => {
16836
18380
  });
16837
18381
  registerPrompts(server);
16838
18382
  registerResources(server);
18383
+ let initializedSessionId = null;
16839
18384
  const transport = new StreamableHTTPServerTransport({
16840
18385
  sessionIdGenerator: () => randomUUID4(),
16841
18386
  onsessioninitialized: (sessionId) => {
18387
+ initializedSessionId = sessionId;
16842
18388
  sessions.set(sessionId, {
16843
18389
  transport,
16844
18390
  server,
16845
18391
  lastActivity: Date.now(),
16846
- userId: auth.userId
18392
+ userId: auth.userId,
18393
+ activeRequests: 1
16847
18394
  });
16848
18395
  }
16849
18396
  });
@@ -16853,20 +18400,29 @@ app.post("/mcp", async (req, res) => {
16853
18400
  }
16854
18401
  };
16855
18402
  await server.connect(transport);
16856
- await requestContext.run(
16857
- {
16858
- userId: auth.userId,
16859
- scopes: auth.scopes,
16860
- token: auth.token,
16861
- creditsUsed: 0,
16862
- assetsGenerated: 0,
16863
- projectId: auth.projectId
16864
- },
16865
- () => transport.handleRequest(req, res, req.body)
16866
- );
18403
+ try {
18404
+ await requestContext.run(
18405
+ {
18406
+ userId: auth.userId,
18407
+ scopes: auth.scopes,
18408
+ token: auth.token,
18409
+ creditsUsed: 0,
18410
+ assetsGenerated: 0,
18411
+ projectId: auth.projectId
18412
+ },
18413
+ () => transport.handleRequest(req, res, req.body)
18414
+ );
18415
+ } finally {
18416
+ if (initializedSessionId) {
18417
+ const entry = sessions.get(initializedSessionId);
18418
+ if (entry) {
18419
+ entry.activeRequests = Math.max(0, entry.activeRequests - 1);
18420
+ entry.lastActivity = Date.now();
18421
+ }
18422
+ }
18423
+ }
16867
18424
  } catch (err) {
16868
- const rawMessage = err instanceof Error ? err.message : "Internal server error";
16869
- console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
18425
+ logOperationalError("POST /mcp error", err);
16870
18426
  if (!res.headersSent) {
16871
18427
  res.status(500).json({
16872
18428
  jsonrpc: "2.0",
@@ -16886,19 +18442,21 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
16886
18442
  res.status(403).json({ error: "Session belongs to another user" });
16887
18443
  return;
16888
18444
  }
16889
- entry.lastActivity = Date.now();
16890
18445
  res.setHeader("X-Accel-Buffering", "no");
16891
18446
  res.setHeader("Cache-Control", "no-cache");
16892
- await requestContext.run(
16893
- {
16894
- userId: req.auth.userId,
16895
- scopes: req.auth.scopes,
16896
- token: req.auth.token,
16897
- creditsUsed: 0,
16898
- assetsGenerated: 0,
16899
- projectId: req.auth.projectId
16900
- },
16901
- () => 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
+ )
16902
18460
  );
16903
18461
  });
16904
18462
  app.delete(
@@ -16915,18 +18473,13 @@ app.delete(
16915
18473
  res.status(403).json({ error: "Session belongs to another user" });
16916
18474
  return;
16917
18475
  }
16918
- await entry.transport.close();
16919
- await entry.server.close();
16920
- sessions.delete(sessionId);
18476
+ await closeSessionEntry(sessionId, entry);
16921
18477
  res.status(200).json({ status: "session_closed" });
16922
18478
  }
16923
18479
  );
16924
18480
  app.use(
16925
18481
  (err, _req, res, _next) => {
16926
- console.error(
16927
- "[MCP HTTP] Unhandled Express error:",
16928
- err.stack || err.message || err
16929
- );
18482
+ logOperationalError("Unhandled Express error", err);
16930
18483
  if (res.headersSent) return;
16931
18484
  const e = err;
16932
18485
  const status = e.status ?? e.statusCode;
@@ -16945,9 +18498,8 @@ var httpServer = app.listen(PORT, "0.0.0.0", () => {
16945
18498
  `[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`
16946
18499
  );
16947
18500
  console.log(`[MCP HTTP] Health: http://localhost:${PORT}/health`);
16948
- console.log(`[MCP HTTP] MCP endpoint: ${MCP_SERVER_URL}`);
18501
+ console.log(`[MCP HTTP] MCP endpoint: ${safeEndpointForLog(MCP_SERVER_URL)}`);
16949
18502
  console.log(`[MCP HTTP] Tool profile: ${TOOL_PROFILE}`);
16950
- console.log(`[MCP HTTP] Environment: ${NODE_ENV}`);
16951
18503
  });
16952
18504
  async function shutdown(signal) {
16953
18505
  console.log(`[MCP HTTP] ${signal} received, shutting down...`);