@socialneuron/mcp-server 1.3.2 → 1.4.1

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
@@ -10,16 +10,24 @@ var __export = (target, all) => {
10
10
 
11
11
  // src/lib/posthog.ts
12
12
  import { createHash } from "node:crypto";
13
- import { PostHog } from "posthog-node";
14
13
  function hashUserId(userId) {
15
14
  return createHash("sha256").update(`${POSTHOG_SALT}:${userId}`).digest("hex").substring(0, 32);
16
15
  }
16
+ function isTelemetryOptedIn() {
17
+ if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true" || process.env.SOCIALNEURON_NO_TELEMETRY === "1") {
18
+ return false;
19
+ }
20
+ return process.env.SOCIALNEURON_TELEMETRY === "1";
21
+ }
17
22
  function initPostHog() {
18
- if (isTelemetryDisabled()) return;
23
+ if (!isTelemetryOptedIn()) return;
19
24
  const key = process.env.POSTHOG_KEY || process.env.VITE_POSTHOG_KEY;
20
25
  const host = process.env.POSTHOG_HOST || process.env.VITE_POSTHOG_HOST || "https://eu.i.posthog.com";
21
26
  if (!key) return;
22
- client = new PostHog(key, { host, flushAt: 5, flushInterval: 1e4 });
27
+ import("posthog-node").then(({ PostHog }) => {
28
+ client = new PostHog(key, { host, flushAt: 5, flushInterval: 1e4 });
29
+ }).catch(() => {
30
+ });
23
31
  }
24
32
  async function captureToolEvent(args) {
25
33
  if (!client) return;
@@ -579,7 +587,7 @@ var TOOL_SCOPES = {
579
587
  adapt_content: "mcp:write",
580
588
  generate_video: "mcp:write",
581
589
  generate_image: "mcp:write",
582
- check_status: "mcp:write",
590
+ check_status: "mcp:read",
583
591
  render_demo_video: "mcp:write",
584
592
  save_brand_profile: "mcp:write",
585
593
  update_platform_voice: "mcp:write",
@@ -767,7 +775,76 @@ async function callEdgeFunction(functionName, body, options) {
767
775
  }
768
776
  }
769
777
 
778
+ // src/lib/rate-limit.ts
779
+ var CATEGORY_CONFIGS = {
780
+ posting: { maxTokens: 30, refillRate: 30 / 60 },
781
+ // 30 req/min
782
+ screenshot: { maxTokens: 10, refillRate: 10 / 60 },
783
+ // 10 req/min
784
+ read: { maxTokens: 60, refillRate: 60 / 60 }
785
+ // 60 req/min
786
+ };
787
+ var RateLimiter = class {
788
+ tokens;
789
+ lastRefill;
790
+ maxTokens;
791
+ refillRate;
792
+ // tokens per second
793
+ constructor(config) {
794
+ this.maxTokens = config.maxTokens;
795
+ this.refillRate = config.refillRate;
796
+ this.tokens = config.maxTokens;
797
+ this.lastRefill = Date.now();
798
+ }
799
+ /**
800
+ * Try to consume one token. Returns true if the request is allowed,
801
+ * false if rate-limited.
802
+ */
803
+ consume() {
804
+ this.refill();
805
+ if (this.tokens >= 1) {
806
+ this.tokens -= 1;
807
+ return true;
808
+ }
809
+ return false;
810
+ }
811
+ /**
812
+ * Seconds until at least one token is available.
813
+ */
814
+ retryAfter() {
815
+ this.refill();
816
+ if (this.tokens >= 1) return 0;
817
+ return Math.ceil((1 - this.tokens) / this.refillRate);
818
+ }
819
+ refill() {
820
+ const now = Date.now();
821
+ const elapsed = (now - this.lastRefill) / 1e3;
822
+ this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
823
+ this.lastRefill = now;
824
+ }
825
+ };
826
+ var limiters = /* @__PURE__ */ new Map();
827
+ function getRateLimiter(category) {
828
+ let limiter = limiters.get(category);
829
+ if (!limiter) {
830
+ const config = CATEGORY_CONFIGS[category] ?? CATEGORY_CONFIGS.read;
831
+ limiter = new RateLimiter(config);
832
+ limiters.set(category, limiter);
833
+ }
834
+ return limiter;
835
+ }
836
+ function checkRateLimit(category, key) {
837
+ const bucketKey = key ? `${category}:${key}` : category;
838
+ const limiter = getRateLimiter(bucketKey);
839
+ const allowed = limiter.consume();
840
+ return {
841
+ allowed,
842
+ retryAfter: allowed ? 0 : limiter.retryAfter()
843
+ };
844
+ }
845
+
770
846
  // src/tools/ideation.ts
847
+ init_supabase();
771
848
  function registerIdeationTools(server) {
772
849
  server.tool(
773
850
  "generate_content",
@@ -788,7 +865,9 @@ function registerIdeationTools(server) {
788
865
  "facebook",
789
866
  "threads",
790
867
  "bluesky"
791
- ]).optional().describe("Target social media platform. Helps tailor tone, length, and format."),
868
+ ]).optional().describe(
869
+ "Target social media platform. Helps tailor tone, length, and format."
870
+ ),
792
871
  brand_voice: z.string().max(500).optional().describe(
793
872
  'Brand voice guidelines to follow (e.g. "professional and empathetic", "playful and Gen-Z"). Leave blank to use a neutral tone.'
794
873
  ),
@@ -799,7 +878,30 @@ function registerIdeationTools(server) {
799
878
  "Project ID to auto-load brand profile and performance context for prompt enrichment."
800
879
  )
801
880
  },
802
- async ({ prompt, content_type, platform: platform2, brand_voice, model, project_id }) => {
881
+ async ({
882
+ prompt,
883
+ content_type,
884
+ platform: platform2,
885
+ brand_voice,
886
+ model,
887
+ project_id
888
+ }) => {
889
+ try {
890
+ const userId = await getDefaultUserId();
891
+ const rl = checkRateLimit("posting", userId);
892
+ if (!rl.allowed) {
893
+ return {
894
+ content: [
895
+ {
896
+ type: "text",
897
+ text: `Rate limited. Retry after ${rl.retryAfter}s.`
898
+ }
899
+ ],
900
+ isError: true
901
+ };
902
+ }
903
+ } catch {
904
+ }
803
905
  let enrichedPrompt = prompt;
804
906
  if (platform2) {
805
907
  enrichedPrompt += `
@@ -931,8 +1033,12 @@ Content Type: ${content_type}`;
931
1033
  category: z.string().optional().describe(
932
1034
  "Category filter (for YouTube). Examples: general, entertainment, education, tech, music, gaming, sports, news."
933
1035
  ),
934
- niche: z.string().optional().describe("Niche keyword filter. Only return trends matching these keywords."),
935
- url: z.string().optional().describe('Required when source is "rss" or "url". The feed or page URL to fetch.'),
1036
+ niche: z.string().optional().describe(
1037
+ "Niche keyword filter. Only return trends matching these keywords."
1038
+ ),
1039
+ url: z.string().optional().describe(
1040
+ 'Required when source is "rss" or "url". The feed or page URL to fetch.'
1041
+ ),
936
1042
  force_refresh: z.boolean().optional().describe("Skip the server-side cache and fetch fresh data.")
937
1043
  },
938
1044
  async ({ source, category, niche, url, force_refresh }) => {
@@ -1007,7 +1113,9 @@ Content Type: ${content_type}`;
1007
1113
  "adapt_content",
1008
1114
  "Adapt existing content for a different social media platform. Rewrites content to match the target platform's norms including character limits, hashtag style, tone, and CTA conventions.",
1009
1115
  {
1010
- content: z.string().max(5e3).describe("The content to adapt. Can be a caption, script, blog excerpt, or any text."),
1116
+ content: z.string().max(5e3).describe(
1117
+ "The content to adapt. Can be a caption, script, blog excerpt, or any text."
1118
+ ),
1011
1119
  source_platform: z.enum([
1012
1120
  "youtube",
1013
1121
  "tiktok",
@@ -1017,7 +1125,9 @@ Content Type: ${content_type}`;
1017
1125
  "facebook",
1018
1126
  "threads",
1019
1127
  "bluesky"
1020
- ]).optional().describe("The platform the content was originally written for. Helps preserve intent."),
1128
+ ]).optional().describe(
1129
+ "The platform the content was originally written for. Helps preserve intent."
1130
+ ),
1021
1131
  target_platform: z.enum([
1022
1132
  "youtube",
1023
1133
  "tiktok",
@@ -1031,9 +1141,33 @@ Content Type: ${content_type}`;
1031
1141
  brand_voice: z.string().max(500).optional().describe(
1032
1142
  'Brand voice guidelines to maintain during adaptation (e.g. "professional", "playful").'
1033
1143
  ),
1034
- project_id: z.string().uuid().optional().describe("Optional project ID to load platform voice overrides from brand profile.")
1144
+ project_id: z.string().uuid().optional().describe(
1145
+ "Optional project ID to load platform voice overrides from brand profile."
1146
+ )
1035
1147
  },
1036
- async ({ content, source_platform, target_platform, brand_voice, project_id }) => {
1148
+ async ({
1149
+ content,
1150
+ source_platform,
1151
+ target_platform,
1152
+ brand_voice,
1153
+ project_id
1154
+ }) => {
1155
+ try {
1156
+ const userId = await getDefaultUserId();
1157
+ const rl = checkRateLimit("posting", userId);
1158
+ if (!rl.allowed) {
1159
+ return {
1160
+ content: [
1161
+ {
1162
+ type: "text",
1163
+ text: `Rate limited. Retry after ${rl.retryAfter}s.`
1164
+ }
1165
+ ],
1166
+ isError: true
1167
+ };
1168
+ }
1169
+ } catch {
1170
+ }
1037
1171
  const platformGuidelines = {
1038
1172
  twitter: "Max 280 characters. Concise, punchy. 1-3 hashtags max. Thread-friendly.",
1039
1173
  threads: "Max 500 characters. Conversational, opinion-driven. Minimal hashtags.",
@@ -1115,76 +1249,6 @@ ${content}`,
1115
1249
 
1116
1250
  // src/tools/content.ts
1117
1251
  import { z as z2 } from "zod";
1118
-
1119
- // src/lib/rate-limit.ts
1120
- var CATEGORY_CONFIGS = {
1121
- posting: { maxTokens: 30, refillRate: 30 / 60 },
1122
- // 30 req/min
1123
- screenshot: { maxTokens: 10, refillRate: 10 / 60 },
1124
- // 10 req/min
1125
- read: { maxTokens: 60, refillRate: 60 / 60 }
1126
- // 60 req/min
1127
- };
1128
- var RateLimiter = class {
1129
- tokens;
1130
- lastRefill;
1131
- maxTokens;
1132
- refillRate;
1133
- // tokens per second
1134
- constructor(config) {
1135
- this.maxTokens = config.maxTokens;
1136
- this.refillRate = config.refillRate;
1137
- this.tokens = config.maxTokens;
1138
- this.lastRefill = Date.now();
1139
- }
1140
- /**
1141
- * Try to consume one token. Returns true if the request is allowed,
1142
- * false if rate-limited.
1143
- */
1144
- consume() {
1145
- this.refill();
1146
- if (this.tokens >= 1) {
1147
- this.tokens -= 1;
1148
- return true;
1149
- }
1150
- return false;
1151
- }
1152
- /**
1153
- * Seconds until at least one token is available.
1154
- */
1155
- retryAfter() {
1156
- this.refill();
1157
- if (this.tokens >= 1) return 0;
1158
- return Math.ceil((1 - this.tokens) / this.refillRate);
1159
- }
1160
- refill() {
1161
- const now = Date.now();
1162
- const elapsed = (now - this.lastRefill) / 1e3;
1163
- this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
1164
- this.lastRefill = now;
1165
- }
1166
- };
1167
- var limiters = /* @__PURE__ */ new Map();
1168
- function getRateLimiter(category) {
1169
- let limiter = limiters.get(category);
1170
- if (!limiter) {
1171
- const config = CATEGORY_CONFIGS[category] ?? CATEGORY_CONFIGS.read;
1172
- limiter = new RateLimiter(config);
1173
- limiters.set(category, limiter);
1174
- }
1175
- return limiter;
1176
- }
1177
- function checkRateLimit(category, key) {
1178
- const bucketKey = key ? `${category}:${key}` : category;
1179
- const limiter = getRateLimiter(bucketKey);
1180
- const allowed = limiter.consume();
1181
- return {
1182
- allowed,
1183
- retryAfter: allowed ? 0 : limiter.retryAfter()
1184
- };
1185
- }
1186
-
1187
- // src/tools/content.ts
1188
1252
  init_supabase();
1189
1253
 
1190
1254
  // src/lib/sanitize-error.ts
@@ -1238,8 +1302,19 @@ function sanitizeDbError(error) {
1238
1302
 
1239
1303
  // src/tools/content.ts
1240
1304
  init_request_context();
1241
- var MAX_CREDITS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
1242
- var MAX_ASSETS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_ASSETS_PER_RUN || 0));
1305
+
1306
+ // src/lib/version.ts
1307
+ var MCP_VERSION = "1.4.1";
1308
+
1309
+ // src/tools/content.ts
1310
+ var MAX_CREDITS_PER_RUN = Math.max(
1311
+ 0,
1312
+ Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0)
1313
+ );
1314
+ var MAX_ASSETS_PER_RUN = Math.max(
1315
+ 0,
1316
+ Number(process.env.SOCIALNEURON_MAX_ASSETS_PER_RUN || 0)
1317
+ );
1243
1318
  var _globalCreditsUsed = 0;
1244
1319
  var _globalAssetsGenerated = 0;
1245
1320
  function getCreditsUsed() {
@@ -1281,7 +1356,7 @@ function getCurrentBudgetStatus() {
1281
1356
  function asEnvelope(data) {
1282
1357
  return {
1283
1358
  _meta: {
1284
- version: "0.2.0",
1359
+ version: MCP_VERSION,
1285
1360
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1286
1361
  },
1287
1362
  data
@@ -1363,8 +1438,12 @@ function registerContentTools(server) {
1363
1438
  enable_audio: z2.boolean().optional().describe(
1364
1439
  "Enable native audio generation. Kling 2.6: doubles cost. Kling 3.0: 50% more (std 30/sec, pro 40/sec). 5+ languages."
1365
1440
  ),
1366
- image_url: z2.string().optional().describe("Start frame image URL for image-to-video (Kling 3.0 frame control)."),
1367
- end_frame_url: z2.string().optional().describe("End frame image URL (Kling 3.0 only). Enables seamless loop transitions."),
1441
+ image_url: z2.string().optional().describe(
1442
+ "Start frame image URL for image-to-video (Kling 3.0 frame control)."
1443
+ ),
1444
+ end_frame_url: z2.string().optional().describe(
1445
+ "End frame image URL (Kling 3.0 only). Enables seamless loop transitions."
1446
+ ),
1368
1447
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
1369
1448
  },
1370
1449
  async ({
@@ -1802,10 +1881,13 @@ function registerContentTools(server) {
1802
1881
  };
1803
1882
  }
1804
1883
  if (job.external_id && (job.status === "pending" || job.status === "processing")) {
1805
- const { data: liveStatus } = await callEdgeFunction("kie-task-status", {
1806
- taskId: job.external_id,
1807
- model: job.model
1808
- });
1884
+ const { data: liveStatus } = await callEdgeFunction(
1885
+ "kie-task-status",
1886
+ {
1887
+ taskId: job.external_id,
1888
+ model: job.model
1889
+ }
1890
+ );
1809
1891
  if (liveStatus) {
1810
1892
  const lines2 = [
1811
1893
  `Job: ${job.id}`,
@@ -1879,7 +1961,12 @@ function registerContentTools(server) {
1879
1961
  });
1880
1962
  if (format === "json") {
1881
1963
  return {
1882
- content: [{ type: "text", text: JSON.stringify(asEnvelope(job), null, 2) }]
1964
+ content: [
1965
+ {
1966
+ type: "text",
1967
+ text: JSON.stringify(asEnvelope(job), null, 2)
1968
+ }
1969
+ ]
1883
1970
  };
1884
1971
  }
1885
1972
  return {
@@ -1897,7 +1984,15 @@ function registerContentTools(server) {
1897
1984
  brand_context: z2.string().max(3e3).optional().describe(
1898
1985
  "Brand context JSON from extract_brand. Include colors, voice tone, visual style keywords for consistent branding across frames."
1899
1986
  ),
1900
- platform: z2.enum(["tiktok", "instagram-reels", "youtube-shorts", "youtube", "general"]).describe("Target platform. Determines aspect ratio, duration, and pacing."),
1987
+ platform: z2.enum([
1988
+ "tiktok",
1989
+ "instagram-reels",
1990
+ "youtube-shorts",
1991
+ "youtube",
1992
+ "general"
1993
+ ]).describe(
1994
+ "Target platform. Determines aspect ratio, duration, and pacing."
1995
+ ),
1901
1996
  target_duration: z2.number().min(5).max(120).optional().describe(
1902
1997
  "Target total duration in seconds. Defaults to 30s for short-form, 60s for YouTube."
1903
1998
  ),
@@ -1905,7 +2000,9 @@ function registerContentTools(server) {
1905
2000
  style: z2.string().optional().describe(
1906
2001
  'Visual style direction (e.g., "cinematic", "anime", "documentary", "motion graphics").'
1907
2002
  ),
1908
- response_format: z2.enum(["text", "json"]).optional().describe("Response format. Defaults to json for structured storyboard data.")
2003
+ response_format: z2.enum(["text", "json"]).optional().describe(
2004
+ "Response format. Defaults to json for structured storyboard data."
2005
+ )
1909
2006
  },
1910
2007
  async ({
1911
2008
  concept,
@@ -1918,7 +2015,11 @@ function registerContentTools(server) {
1918
2015
  }) => {
1919
2016
  const format = response_format ?? "json";
1920
2017
  const startedAt = Date.now();
1921
- const isShortForm = ["tiktok", "instagram-reels", "youtube-shorts"].includes(platform2);
2018
+ const isShortForm = [
2019
+ "tiktok",
2020
+ "instagram-reels",
2021
+ "youtube-shorts"
2022
+ ].includes(platform2);
1922
2023
  const duration = target_duration ?? (isShortForm ? 30 : 60);
1923
2024
  const scenes = num_scenes ?? (isShortForm ? 7 : 10);
1924
2025
  const aspectRatio = isShortForm ? "9:16" : "16:9";
@@ -2011,7 +2112,12 @@ Return ONLY valid JSON in this exact format:
2011
2112
  details: { error }
2012
2113
  });
2013
2114
  return {
2014
- content: [{ type: "text", text: `Storyboard generation failed: ${error}` }],
2115
+ content: [
2116
+ {
2117
+ type: "text",
2118
+ text: `Storyboard generation failed: ${error}`
2119
+ }
2120
+ ],
2015
2121
  isError: true
2016
2122
  };
2017
2123
  }
@@ -2027,7 +2133,12 @@ Return ONLY valid JSON in this exact format:
2027
2133
  try {
2028
2134
  const parsed = JSON.parse(rawContent);
2029
2135
  return {
2030
- content: [{ type: "text", text: JSON.stringify(asEnvelope(parsed), null, 2) }]
2136
+ content: [
2137
+ {
2138
+ type: "text",
2139
+ text: JSON.stringify(asEnvelope(parsed), null, 2)
2140
+ }
2141
+ ]
2031
2142
  };
2032
2143
  } catch {
2033
2144
  return {
@@ -2091,7 +2202,10 @@ Return ONLY valid JSON in this exact format:
2091
2202
  isError: true
2092
2203
  };
2093
2204
  }
2094
- const rateLimit = checkRateLimit("posting", `generate_voiceover:${userId}`);
2205
+ const rateLimit = checkRateLimit(
2206
+ "posting",
2207
+ `generate_voiceover:${userId}`
2208
+ );
2095
2209
  if (!rateLimit.allowed) {
2096
2210
  await logMcpToolInvocation({
2097
2211
  toolName: "generate_voiceover",
@@ -2126,7 +2240,12 @@ Return ONLY valid JSON in this exact format:
2126
2240
  details: { error }
2127
2241
  });
2128
2242
  return {
2129
- content: [{ type: "text", text: `Voiceover generation failed: ${error}` }],
2243
+ content: [
2244
+ {
2245
+ type: "text",
2246
+ text: `Voiceover generation failed: ${error}`
2247
+ }
2248
+ ],
2130
2249
  isError: true
2131
2250
  };
2132
2251
  }
@@ -2139,7 +2258,10 @@ Return ONLY valid JSON in this exact format:
2139
2258
  });
2140
2259
  return {
2141
2260
  content: [
2142
- { type: "text", text: "Voiceover generation failed: no audio URL returned." }
2261
+ {
2262
+ type: "text",
2263
+ text: "Voiceover generation failed: no audio URL returned."
2264
+ }
2143
2265
  ],
2144
2266
  isError: true
2145
2267
  };
@@ -2211,7 +2333,9 @@ Return ONLY valid JSON in this exact format:
2211
2333
  "Carousel template. hormozi-authority: bold typography, one idea per slide, dark backgrounds. educational-series: numbered tips. Default: hormozi-authority."
2212
2334
  ),
2213
2335
  slide_count: z2.number().min(3).max(10).optional().describe("Number of slides (3-10). Default: 7."),
2214
- aspect_ratio: z2.enum(["1:1", "4:5", "9:16"]).optional().describe("Aspect ratio. 1:1 square (default), 4:5 portrait, 9:16 story."),
2336
+ aspect_ratio: z2.enum(["1:1", "4:5", "9:16"]).optional().describe(
2337
+ "Aspect ratio. 1:1 square (default), 4:5 portrait, 9:16 story."
2338
+ ),
2215
2339
  style: z2.enum(["minimal", "bold", "professional", "playful", "hormozi"]).optional().describe(
2216
2340
  "Visual style. hormozi: black bg, bold white text, gold accents. Default: hormozi (when using hormozi-authority template)."
2217
2341
  ),
@@ -2248,7 +2372,10 @@ Return ONLY valid JSON in this exact format:
2248
2372
  };
2249
2373
  }
2250
2374
  const userId = await getDefaultUserId();
2251
- const rateLimit = checkRateLimit("posting", `generate_carousel:${userId}`);
2375
+ const rateLimit = checkRateLimit(
2376
+ "posting",
2377
+ `generate_carousel:${userId}`
2378
+ );
2252
2379
  if (!rateLimit.allowed) {
2253
2380
  await logMcpToolInvocation({
2254
2381
  toolName: "generate_carousel",
@@ -2286,7 +2413,12 @@ Return ONLY valid JSON in this exact format:
2286
2413
  details: { error }
2287
2414
  });
2288
2415
  return {
2289
- content: [{ type: "text", text: `Carousel generation failed: ${error}` }],
2416
+ content: [
2417
+ {
2418
+ type: "text",
2419
+ text: `Carousel generation failed: ${error}`
2420
+ }
2421
+ ],
2290
2422
  isError: true
2291
2423
  };
2292
2424
  }
@@ -2298,7 +2430,12 @@ Return ONLY valid JSON in this exact format:
2298
2430
  details: { error: "No carousel data returned" }
2299
2431
  });
2300
2432
  return {
2301
- content: [{ type: "text", text: "Carousel generation returned no data." }],
2433
+ content: [
2434
+ {
2435
+ type: "text",
2436
+ text: "Carousel generation returned no data."
2437
+ }
2438
+ ],
2302
2439
  isError: true
2303
2440
  };
2304
2441
  }
@@ -2499,7 +2636,7 @@ var PLATFORM_CASE_MAP = {
2499
2636
  function asEnvelope2(data) {
2500
2637
  return {
2501
2638
  _meta: {
2502
- version: "0.2.0",
2639
+ version: MCP_VERSION,
2503
2640
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2504
2641
  },
2505
2642
  data
@@ -2531,15 +2668,21 @@ function registerDistributionTools(server) {
2531
2668
  "threads",
2532
2669
  "bluesky"
2533
2670
  ])
2534
- ).min(1).describe("Target platforms to post to. Each must have an active OAuth connection."),
2671
+ ).min(1).describe(
2672
+ "Target platforms to post to. Each must have an active OAuth connection."
2673
+ ),
2535
2674
  title: z3.string().optional().describe("Post title (used by YouTube and some other platforms)."),
2536
- hashtags: z3.array(z3.string()).optional().describe('Hashtags to append to the caption. Include or omit the "#" prefix.'),
2675
+ hashtags: z3.array(z3.string()).optional().describe(
2676
+ 'Hashtags to append to the caption. Include or omit the "#" prefix.'
2677
+ ),
2537
2678
  schedule_at: z3.string().optional().describe(
2538
2679
  'ISO 8601 datetime for scheduled posting (e.g. "2026-03-15T14:00:00Z"). Omit for immediate posting.'
2539
2680
  ),
2540
2681
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
2541
2682
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
2542
- attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.')
2683
+ attribution: z3.boolean().optional().describe(
2684
+ 'If true, appends "Created with Social Neuron" to the caption. Default: false.'
2685
+ )
2543
2686
  },
2544
2687
  async ({
2545
2688
  media_url,
@@ -2558,7 +2701,12 @@ function registerDistributionTools(server) {
2558
2701
  const startedAt = Date.now();
2559
2702
  if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
2560
2703
  return {
2561
- content: [{ type: "text", text: "Either caption or title is required." }],
2704
+ content: [
2705
+ {
2706
+ type: "text",
2707
+ text: "Either caption or title is required."
2708
+ }
2709
+ ],
2562
2710
  isError: true
2563
2711
  };
2564
2712
  }
@@ -2581,7 +2729,9 @@ function registerDistributionTools(server) {
2581
2729
  isError: true
2582
2730
  };
2583
2731
  }
2584
- const normalizedPlatforms = platforms.map((p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p);
2732
+ const normalizedPlatforms = platforms.map(
2733
+ (p) => PLATFORM_CASE_MAP[p.toLowerCase()] || p
2734
+ );
2585
2735
  let finalCaption = caption;
2586
2736
  if (attribution && finalCaption) {
2587
2737
  finalCaption = `${finalCaption}
@@ -2645,7 +2795,9 @@ Created with Social Neuron`;
2645
2795
  ];
2646
2796
  for (const [platform2, result] of Object.entries(data.results)) {
2647
2797
  if (result.success) {
2648
- lines.push(` ${platform2}: OK (jobId=${result.jobId}, postId=${result.postId})`);
2798
+ lines.push(
2799
+ ` ${platform2}: OK (jobId=${result.jobId}, postId=${result.postId})`
2800
+ );
2649
2801
  } else {
2650
2802
  lines.push(` ${platform2}: FAILED - ${result.error}`);
2651
2803
  }
@@ -2662,7 +2814,12 @@ Created with Social Neuron`;
2662
2814
  });
2663
2815
  if (format === "json") {
2664
2816
  return {
2665
- content: [{ type: "text", text: JSON.stringify(asEnvelope2(data), null, 2) }],
2817
+ content: [
2818
+ {
2819
+ type: "text",
2820
+ text: JSON.stringify(asEnvelope2(data), null, 2)
2821
+ }
2822
+ ],
2666
2823
  isError: !data.success
2667
2824
  };
2668
2825
  }
@@ -2718,12 +2875,17 @@ Created with Social Neuron`;
2718
2875
  for (const account of accounts) {
2719
2876
  const name = account.username || "(unnamed)";
2720
2877
  const platformLower = account.platform.toLowerCase();
2721
- lines.push(` ${platformLower}: ${name} (connected ${account.created_at.split("T")[0]})`);
2878
+ lines.push(
2879
+ ` ${platformLower}: ${name} (connected ${account.created_at.split("T")[0]})`
2880
+ );
2722
2881
  }
2723
2882
  if (format === "json") {
2724
2883
  return {
2725
2884
  content: [
2726
- { type: "text", text: JSON.stringify(asEnvelope2({ accounts }), null, 2) }
2885
+ {
2886
+ type: "text",
2887
+ text: JSON.stringify(asEnvelope2({ accounts }), null, 2)
2888
+ }
2727
2889
  ]
2728
2890
  };
2729
2891
  }
@@ -2785,7 +2947,10 @@ Created with Social Neuron`;
2785
2947
  if (format === "json") {
2786
2948
  return {
2787
2949
  content: [
2788
- { type: "text", text: JSON.stringify(asEnvelope2({ posts: [] }), null, 2) }
2950
+ {
2951
+ type: "text",
2952
+ text: JSON.stringify(asEnvelope2({ posts: [] }), null, 2)
2953
+ }
2789
2954
  ]
2790
2955
  };
2791
2956
  }
@@ -2802,7 +2967,10 @@ Created with Social Neuron`;
2802
2967
  if (format === "json") {
2803
2968
  return {
2804
2969
  content: [
2805
- { type: "text", text: JSON.stringify(asEnvelope2({ posts }), null, 2) }
2970
+ {
2971
+ type: "text",
2972
+ text: JSON.stringify(asEnvelope2({ posts }), null, 2)
2973
+ }
2806
2974
  ]
2807
2975
  };
2808
2976
  }
@@ -2863,7 +3031,13 @@ Created with Social Neuron`;
2863
3031
  min_gap_hours: z3.number().min(1).max(24).default(4).describe("Minimum gap between posts on same platform"),
2864
3032
  response_format: z3.enum(["text", "json"]).default("text")
2865
3033
  },
2866
- async ({ platforms, count, start_after, min_gap_hours, response_format }) => {
3034
+ async ({
3035
+ platforms,
3036
+ count,
3037
+ start_after,
3038
+ min_gap_hours,
3039
+ response_format
3040
+ }) => {
2867
3041
  const startedAt = Date.now();
2868
3042
  try {
2869
3043
  const userId = await getDefaultUserId();
@@ -2874,7 +3048,9 @@ Created with Social Neuron`;
2874
3048
  const gapMs = min_gap_hours * 60 * 60 * 1e3;
2875
3049
  const candidates = [];
2876
3050
  for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
2877
- const date = new Date(startDate.getTime() + dayOffset * 24 * 60 * 60 * 1e3);
3051
+ const date = new Date(
3052
+ startDate.getTime() + dayOffset * 24 * 60 * 60 * 1e3
3053
+ );
2878
3054
  const dayOfWeek = date.getUTCDay();
2879
3055
  for (const platform2 of platforms) {
2880
3056
  const hours = PREFERRED_HOURS[platform2] ?? [12, 16];
@@ -2883,8 +3059,11 @@ Created with Social Neuron`;
2883
3059
  slotDate.setUTCHours(hours[hourIdx], 0, 0, 0);
2884
3060
  if (slotDate <= startDate) continue;
2885
3061
  const hasConflict = (existingPosts ?? []).some((post) => {
2886
- if (String(post.platform).toLowerCase() !== platform2) return false;
2887
- const postTime = new Date(post.scheduled_at ?? post.published_at).getTime();
3062
+ if (String(post.platform).toLowerCase() !== platform2)
3063
+ return false;
3064
+ const postTime = new Date(
3065
+ post.scheduled_at ?? post.published_at
3066
+ ).getTime();
2888
3067
  return Math.abs(postTime - slotDate.getTime()) < gapMs;
2889
3068
  });
2890
3069
  let engagementScore = hours.length - hourIdx;
@@ -2929,15 +3108,22 @@ Created with Social Neuron`;
2929
3108
  };
2930
3109
  }
2931
3110
  const lines = [];
2932
- lines.push(`Found ${slots.length} optimal slots (${conflictsAvoided} conflicts avoided):`);
3111
+ lines.push(
3112
+ `Found ${slots.length} optimal slots (${conflictsAvoided} conflicts avoided):`
3113
+ );
2933
3114
  lines.push("");
2934
3115
  lines.push("Datetime (UTC) | Platform | Score");
2935
3116
  lines.push("-------------------------+------------+------");
2936
3117
  for (const s of slots) {
2937
3118
  const dt = s.datetime.replace("T", " ").slice(0, 19);
2938
- lines.push(`${dt.padEnd(25)}| ${s.platform.padEnd(11)}| ${s.engagement_score}`);
3119
+ lines.push(
3120
+ `${dt.padEnd(25)}| ${s.platform.padEnd(11)}| ${s.engagement_score}`
3121
+ );
2939
3122
  }
2940
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
3123
+ return {
3124
+ content: [{ type: "text", text: lines.join("\n") }],
3125
+ isError: false
3126
+ };
2941
3127
  } catch (err) {
2942
3128
  const durationMs = Date.now() - startedAt;
2943
3129
  const message = err instanceof Error ? err.message : String(err);
@@ -2948,7 +3134,9 @@ Created with Social Neuron`;
2948
3134
  details: { error: message }
2949
3135
  });
2950
3136
  return {
2951
- content: [{ type: "text", text: `Failed to find slots: ${message}` }],
3137
+ content: [
3138
+ { type: "text", text: `Failed to find slots: ${message}` }
3139
+ ],
2952
3140
  isError: true
2953
3141
  };
2954
3142
  }
@@ -2975,8 +3163,12 @@ Created with Social Neuron`;
2975
3163
  auto_slot: z3.boolean().default(true).describe("Auto-assign time slots for posts without schedule_at"),
2976
3164
  dry_run: z3.boolean().default(false).describe("Preview without actually scheduling"),
2977
3165
  response_format: z3.enum(["text", "json"]).default("text"),
2978
- enforce_quality: z3.boolean().default(true).describe("When true, block scheduling for posts that fail quality checks."),
2979
- quality_threshold: z3.number().int().min(0).max(35).optional().describe("Optional quality threshold override. Defaults to project setting or 26."),
3166
+ enforce_quality: z3.boolean().default(true).describe(
3167
+ "When true, block scheduling for posts that fail quality checks."
3168
+ ),
3169
+ quality_threshold: z3.number().int().min(0).max(35).optional().describe(
3170
+ "Optional quality threshold override. Defaults to project setting or 26."
3171
+ ),
2980
3172
  batch_size: z3.number().int().min(1).max(10).default(4).describe("Concurrent schedule calls per platform batch."),
2981
3173
  idempotency_seed: z3.string().max(128).optional().describe("Optional stable seed used when building idempotency keys.")
2982
3174
  },
@@ -3015,17 +3207,25 @@ Created with Social Neuron`;
3015
3207
  if (!stored?.plan_payload) {
3016
3208
  return {
3017
3209
  content: [
3018
- { type: "text", text: `No content plan found for plan_id=${plan_id}` }
3210
+ {
3211
+ type: "text",
3212
+ text: `No content plan found for plan_id=${plan_id}`
3213
+ }
3019
3214
  ],
3020
3215
  isError: true
3021
3216
  };
3022
3217
  }
3023
3218
  const payload = stored.plan_payload;
3024
- const postsFromPayload = Array.isArray(payload.posts) ? payload.posts : Array.isArray(payload.data?.posts) ? payload.data.posts : null;
3219
+ const postsFromPayload = Array.isArray(payload.posts) ? payload.posts : Array.isArray(
3220
+ payload.data?.posts
3221
+ ) ? payload.data.posts : null;
3025
3222
  if (!postsFromPayload) {
3026
3223
  return {
3027
3224
  content: [
3028
- { type: "text", text: `Stored plan ${plan_id} has no posts array.` }
3225
+ {
3226
+ type: "text",
3227
+ text: `Stored plan ${plan_id} has no posts array.`
3228
+ }
3029
3229
  ],
3030
3230
  isError: true
3031
3231
  };
@@ -3117,7 +3317,10 @@ Created with Social Neuron`;
3117
3317
  approvalSummary = {
3118
3318
  total: approvals.length,
3119
3319
  eligible: approvedPosts.length,
3120
- skipped: Math.max(0, workingPlan.posts.length - approvedPosts.length)
3320
+ skipped: Math.max(
3321
+ 0,
3322
+ workingPlan.posts.length - approvedPosts.length
3323
+ )
3121
3324
  };
3122
3325
  workingPlan = {
3123
3326
  ...workingPlan,
@@ -3151,9 +3354,14 @@ Created with Social Neuron`;
3151
3354
  try {
3152
3355
  const { data: settingsData } = await supabase.from("system_settings").select("value").eq("key", "content_safety").maybeSingle();
3153
3356
  if (settingsData?.value?.quality_threshold !== void 0) {
3154
- const parsedThreshold = Number(settingsData.value.quality_threshold);
3357
+ const parsedThreshold = Number(
3358
+ settingsData.value.quality_threshold
3359
+ );
3155
3360
  if (Number.isFinite(parsedThreshold)) {
3156
- effectiveQualityThreshold = Math.max(0, Math.min(35, Math.trunc(parsedThreshold)));
3361
+ effectiveQualityThreshold = Math.max(
3362
+ 0,
3363
+ Math.min(35, Math.trunc(parsedThreshold))
3364
+ );
3157
3365
  }
3158
3366
  }
3159
3367
  if (Array.isArray(settingsData?.value?.custom_banned_terms)) {
@@ -3189,13 +3397,18 @@ Created with Social Neuron`;
3189
3397
  }
3190
3398
  };
3191
3399
  });
3192
- const qualityPassed = postsWithResults.filter((post) => post.quality.passed).length;
3400
+ const qualityPassed = postsWithResults.filter(
3401
+ (post) => post.quality.passed
3402
+ ).length;
3193
3403
  const qualitySummary = {
3194
3404
  total_posts: postsWithResults.length,
3195
3405
  passed: qualityPassed,
3196
3406
  failed: postsWithResults.length - qualityPassed,
3197
3407
  avg_score: postsWithResults.length > 0 ? Number(
3198
- (postsWithResults.reduce((sum, post) => sum + post.quality.score, 0) / postsWithResults.length).toFixed(2)
3408
+ (postsWithResults.reduce(
3409
+ (sum, post) => sum + post.quality.score,
3410
+ 0
3411
+ ) / postsWithResults.length).toFixed(2)
3199
3412
  ) : 0
3200
3413
  };
3201
3414
  if (dry_run) {
@@ -3265,8 +3478,13 @@ Created with Social Neuron`;
3265
3478
  }
3266
3479
  }
3267
3480
  lines2.push("");
3268
- lines2.push(`Summary: ${passed}/${workingPlan.posts.length} passed quality check`);
3269
- return { content: [{ type: "text", text: lines2.join("\n") }], isError: false };
3481
+ lines2.push(
3482
+ `Summary: ${passed}/${workingPlan.posts.length} passed quality check`
3483
+ );
3484
+ return {
3485
+ content: [{ type: "text", text: lines2.join("\n") }],
3486
+ isError: false
3487
+ };
3270
3488
  }
3271
3489
  let scheduled = 0;
3272
3490
  let failed = 0;
@@ -3358,7 +3576,8 @@ Created with Social Neuron`;
3358
3576
  }
3359
3577
  const chunk = (arr, size) => {
3360
3578
  const out = [];
3361
- for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
3579
+ for (let i = 0; i < arr.length; i += size)
3580
+ out.push(arr.slice(i, i + size));
3362
3581
  return out;
3363
3582
  };
3364
3583
  const platformBatches = Array.from(grouped.entries()).map(
@@ -3366,7 +3585,9 @@ Created with Social Neuron`;
3366
3585
  const platformResults = [];
3367
3586
  const batches = chunk(platformPosts, batch_size);
3368
3587
  for (const batch of batches) {
3369
- const settled = await Promise.allSettled(batch.map((post) => scheduleOne(post)));
3588
+ const settled = await Promise.allSettled(
3589
+ batch.map((post) => scheduleOne(post))
3590
+ );
3370
3591
  for (const outcome of settled) {
3371
3592
  if (outcome.status === "fulfilled") {
3372
3593
  platformResults.push(outcome.value);
@@ -3432,7 +3653,11 @@ Created with Social Neuron`;
3432
3653
  plan_id: effectivePlanId,
3433
3654
  approvals: approvalSummary,
3434
3655
  posts: results,
3435
- summary: { total_posts: workingPlan.posts.length, scheduled, failed }
3656
+ summary: {
3657
+ total_posts: workingPlan.posts.length,
3658
+ scheduled,
3659
+ failed
3660
+ }
3436
3661
  }),
3437
3662
  null,
3438
3663
  2
@@ -3470,7 +3695,12 @@ Created with Social Neuron`;
3470
3695
  details: { error: message }
3471
3696
  });
3472
3697
  return {
3473
- content: [{ type: "text", text: `Batch scheduling failed: ${message}` }],
3698
+ content: [
3699
+ {
3700
+ type: "text",
3701
+ text: `Batch scheduling failed: ${message}`
3702
+ }
3703
+ ],
3474
3704
  isError: true
3475
3705
  };
3476
3706
  }
@@ -3484,7 +3714,7 @@ import { z as z4 } from "zod";
3484
3714
  function asEnvelope3(data) {
3485
3715
  return {
3486
3716
  _meta: {
3487
- version: "0.2.0",
3717
+ version: MCP_VERSION,
3488
3718
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3489
3719
  },
3490
3720
  data
@@ -3597,7 +3827,9 @@ function registerAnalyticsTools(server) {
3597
3827
  ]
3598
3828
  };
3599
3829
  }
3600
- const { data: simpleRows, error: simpleError } = await supabase.from("post_analytics").select("id, post_id, platform, views, likes, comments, shares, captured_at").in("post_id", postIds).gte("captured_at", sinceIso).order("captured_at", { ascending: false }).limit(maxPosts);
3830
+ const { data: simpleRows, error: simpleError } = await supabase.from("post_analytics").select(
3831
+ "id, post_id, platform, views, likes, comments, shares, captured_at"
3832
+ ).in("post_id", postIds).gte("captured_at", sinceIso).order("captured_at", { ascending: false }).limit(maxPosts);
3601
3833
  if (simpleError) {
3602
3834
  return {
3603
3835
  content: [
@@ -3609,7 +3841,12 @@ function registerAnalyticsTools(server) {
3609
3841
  isError: true
3610
3842
  };
3611
3843
  }
3612
- return formatSimpleAnalytics(simpleRows, platform2, lookbackDays, format);
3844
+ return formatSimpleAnalytics(
3845
+ simpleRows,
3846
+ platform2,
3847
+ lookbackDays,
3848
+ format
3849
+ );
3613
3850
  }
3614
3851
  if (!rows || rows.length === 0) {
3615
3852
  if (format === "json") {
@@ -3686,7 +3923,10 @@ function registerAnalyticsTools(server) {
3686
3923
  const format = response_format ?? "text";
3687
3924
  const startedAt = Date.now();
3688
3925
  const userId = await getDefaultUserId();
3689
- const rateLimit = checkRateLimit("posting", `refresh_platform_analytics:${userId}`);
3926
+ const rateLimit = checkRateLimit(
3927
+ "posting",
3928
+ `refresh_platform_analytics:${userId}`
3929
+ );
3690
3930
  if (!rateLimit.allowed) {
3691
3931
  await logMcpToolInvocation({
3692
3932
  toolName: "refresh_platform_analytics",
@@ -3704,7 +3944,9 @@ function registerAnalyticsTools(server) {
3704
3944
  isError: true
3705
3945
  };
3706
3946
  }
3707
- const { data, error } = await callEdgeFunction("fetch-analytics", { userId });
3947
+ const { data, error } = await callEdgeFunction("fetch-analytics", {
3948
+ userId
3949
+ });
3708
3950
  if (error) {
3709
3951
  await logMcpToolInvocation({
3710
3952
  toolName: "refresh_platform_analytics",
@@ -3713,7 +3955,12 @@ function registerAnalyticsTools(server) {
3713
3955
  details: { error }
3714
3956
  });
3715
3957
  return {
3716
- content: [{ type: "text", text: `Error refreshing analytics: ${error}` }],
3958
+ content: [
3959
+ {
3960
+ type: "text",
3961
+ text: `Error refreshing analytics: ${error}`
3962
+ }
3963
+ ],
3717
3964
  isError: true
3718
3965
  };
3719
3966
  }
@@ -3726,12 +3973,18 @@ function registerAnalyticsTools(server) {
3726
3973
  details: { error: "Edge function returned success=false" }
3727
3974
  });
3728
3975
  return {
3729
- content: [{ type: "text", text: "Analytics refresh failed." }],
3976
+ content: [
3977
+ { type: "text", text: "Analytics refresh failed." }
3978
+ ],
3730
3979
  isError: true
3731
3980
  };
3732
3981
  }
3733
- const queued = (result.results ?? []).filter((r) => r.status === "queued").length;
3734
- const errored = (result.results ?? []).filter((r) => r.status === "error").length;
3982
+ const queued = (result.results ?? []).filter(
3983
+ (r) => r.status === "queued"
3984
+ ).length;
3985
+ const errored = (result.results ?? []).filter(
3986
+ (r) => r.status === "error"
3987
+ ).length;
3735
3988
  const lines = [
3736
3989
  `Analytics refresh triggered successfully.`,
3737
3990
  ` Posts processed: ${result.postsProcessed}`,
@@ -3777,7 +4030,10 @@ function formatAnalytics(summary, days, format) {
3777
4030
  if (format === "json") {
3778
4031
  return {
3779
4032
  content: [
3780
- { type: "text", text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2) }
4033
+ {
4034
+ type: "text",
4035
+ text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2)
4036
+ }
3781
4037
  ]
3782
4038
  };
3783
4039
  }
@@ -3894,10 +4150,159 @@ function formatSimpleAnalytics(rows, platform2, days, format) {
3894
4150
  // src/tools/brand.ts
3895
4151
  import { z as z5 } from "zod";
3896
4152
  init_supabase();
4153
+
4154
+ // src/lib/ssrf.ts
4155
+ var BLOCKED_IP_PATTERNS = [
4156
+ // IPv4 localhost/loopback
4157
+ /^127\./,
4158
+ /^0\./,
4159
+ // IPv4 private ranges (RFC 1918)
4160
+ /^10\./,
4161
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
4162
+ /^192\.168\./,
4163
+ // IPv4 link-local
4164
+ /^169\.254\./,
4165
+ // Cloud metadata endpoint (AWS, GCP, Azure)
4166
+ /^169\.254\.169\.254$/,
4167
+ // IPv4 broadcast
4168
+ /^255\./,
4169
+ // Shared address space (RFC 6598)
4170
+ /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
4171
+ ];
4172
+ var BLOCKED_IPV6_PATTERNS = [
4173
+ /^::1$/i,
4174
+ // loopback
4175
+ /^::$/i,
4176
+ // unspecified
4177
+ /^fe[89ab][0-9a-f]:/i,
4178
+ // link-local fe80::/10
4179
+ /^fc[0-9a-f]:/i,
4180
+ // unique local fc00::/7
4181
+ /^fd[0-9a-f]:/i,
4182
+ // unique local fc00::/7
4183
+ /^::ffff:127\./i,
4184
+ // IPv4-mapped localhost
4185
+ /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
4186
+ // IPv4-mapped private
4187
+ ];
4188
+ var BLOCKED_HOSTNAMES = [
4189
+ "localhost",
4190
+ "localhost.localdomain",
4191
+ "local",
4192
+ "127.0.0.1",
4193
+ "0.0.0.0",
4194
+ "[::1]",
4195
+ "[::ffff:127.0.0.1]",
4196
+ // Cloud metadata endpoints
4197
+ "metadata.google.internal",
4198
+ "metadata.goog",
4199
+ "instance-data",
4200
+ "instance-data.ec2.internal"
4201
+ ];
4202
+ var ALLOWED_PROTOCOLS = ["http:", "https:"];
4203
+ var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
4204
+ function isBlockedIP(ip) {
4205
+ const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
4206
+ if (normalized.includes(":")) {
4207
+ return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
4208
+ }
4209
+ return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
4210
+ }
4211
+ function isBlockedHostname(hostname) {
4212
+ return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
4213
+ }
4214
+ function isIPAddress(hostname) {
4215
+ const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
4216
+ const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
4217
+ return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
4218
+ }
4219
+ async function validateUrlForSSRF(urlString) {
4220
+ try {
4221
+ const url = new URL(urlString);
4222
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
4223
+ return {
4224
+ isValid: false,
4225
+ error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
4226
+ };
4227
+ }
4228
+ if (url.username || url.password) {
4229
+ return {
4230
+ isValid: false,
4231
+ error: "URLs with embedded credentials are not allowed."
4232
+ };
4233
+ }
4234
+ const hostname = url.hostname.toLowerCase();
4235
+ if (isBlockedHostname(hostname)) {
4236
+ return {
4237
+ isValid: false,
4238
+ error: "Access to internal/localhost addresses is not allowed."
4239
+ };
4240
+ }
4241
+ if (isIPAddress(hostname) && isBlockedIP(hostname)) {
4242
+ return {
4243
+ isValid: false,
4244
+ error: "Access to private/internal IP addresses is not allowed."
4245
+ };
4246
+ }
4247
+ const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
4248
+ if (BLOCKED_PORTS.includes(port)) {
4249
+ return {
4250
+ isValid: false,
4251
+ error: `Access to port ${port} is not allowed.`
4252
+ };
4253
+ }
4254
+ let resolvedIP;
4255
+ if (!isIPAddress(hostname)) {
4256
+ try {
4257
+ const dns = await import("node:dns");
4258
+ const resolver = new dns.promises.Resolver();
4259
+ const resolvedIPs = [];
4260
+ try {
4261
+ const aRecords = await resolver.resolve4(hostname);
4262
+ resolvedIPs.push(...aRecords);
4263
+ } catch {
4264
+ }
4265
+ try {
4266
+ const aaaaRecords = await resolver.resolve6(hostname);
4267
+ resolvedIPs.push(...aaaaRecords);
4268
+ } catch {
4269
+ }
4270
+ if (resolvedIPs.length === 0) {
4271
+ return {
4272
+ isValid: false,
4273
+ error: "DNS resolution failed: hostname did not resolve to any address."
4274
+ };
4275
+ }
4276
+ for (const ip of resolvedIPs) {
4277
+ if (isBlockedIP(ip)) {
4278
+ return {
4279
+ isValid: false,
4280
+ error: "Hostname resolves to a private/internal IP address."
4281
+ };
4282
+ }
4283
+ }
4284
+ resolvedIP = resolvedIPs[0];
4285
+ } catch {
4286
+ return {
4287
+ isValid: false,
4288
+ error: "DNS resolution failed. Cannot verify hostname safety."
4289
+ };
4290
+ }
4291
+ }
4292
+ return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
4293
+ } catch (error) {
4294
+ return {
4295
+ isValid: false,
4296
+ error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
4297
+ };
4298
+ }
4299
+ }
4300
+
4301
+ // src/tools/brand.ts
3897
4302
  function asEnvelope4(data) {
3898
4303
  return {
3899
4304
  _meta: {
3900
- version: "0.2.0",
4305
+ version: MCP_VERSION,
3901
4306
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3902
4307
  },
3903
4308
  data
@@ -3914,6 +4319,15 @@ function registerBrandTools(server) {
3914
4319
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
3915
4320
  },
3916
4321
  async ({ url, response_format }) => {
4322
+ const ssrfCheck = await validateUrlForSSRF(url);
4323
+ if (!ssrfCheck.isValid) {
4324
+ return {
4325
+ content: [
4326
+ { type: "text", text: `URL blocked: ${ssrfCheck.error}` }
4327
+ ],
4328
+ isError: true
4329
+ };
4330
+ }
3917
4331
  const { data, error } = await callEdgeFunction(
3918
4332
  "brand-extract",
3919
4333
  { url },
@@ -3943,7 +4357,12 @@ function registerBrandTools(server) {
3943
4357
  }
3944
4358
  if ((response_format || "text") === "json") {
3945
4359
  return {
3946
- content: [{ type: "text", text: JSON.stringify(asEnvelope4(data), null, 2) }]
4360
+ content: [
4361
+ {
4362
+ type: "text",
4363
+ text: JSON.stringify(asEnvelope4(data), null, 2)
4364
+ }
4365
+ ]
3947
4366
  };
3948
4367
  }
3949
4368
  const lines = [
@@ -4007,7 +4426,12 @@ function registerBrandTools(server) {
4007
4426
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
4008
4427
  if (!membership) {
4009
4428
  return {
4010
- content: [{ type: "text", text: "Project is not accessible to current user." }],
4429
+ content: [
4430
+ {
4431
+ type: "text",
4432
+ text: "Project is not accessible to current user."
4433
+ }
4434
+ ],
4011
4435
  isError: true
4012
4436
  };
4013
4437
  }
@@ -4026,13 +4450,21 @@ function registerBrandTools(server) {
4026
4450
  if (!data) {
4027
4451
  return {
4028
4452
  content: [
4029
- { type: "text", text: "No active brand profile found for this project." }
4453
+ {
4454
+ type: "text",
4455
+ text: "No active brand profile found for this project."
4456
+ }
4030
4457
  ]
4031
4458
  };
4032
4459
  }
4033
4460
  if ((response_format || "text") === "json") {
4034
4461
  return {
4035
- content: [{ type: "text", text: JSON.stringify(asEnvelope4(data), null, 2) }]
4462
+ content: [
4463
+ {
4464
+ type: "text",
4465
+ text: JSON.stringify(asEnvelope4(data), null, 2)
4466
+ }
4467
+ ]
4036
4468
  };
4037
4469
  }
4038
4470
  const lines = [
@@ -4053,11 +4485,18 @@ function registerBrandTools(server) {
4053
4485
  "Persist a brand profile as the active profile for a project.",
4054
4486
  {
4055
4487
  project_id: z5.string().uuid().optional().describe("Project ID. Defaults to active project context."),
4056
- brand_context: z5.record(z5.string(), z5.unknown()).describe("Brand context payload to save to brand_profiles.brand_context."),
4488
+ brand_context: z5.record(z5.string(), z5.unknown()).describe(
4489
+ "Brand context payload to save to brand_profiles.brand_context."
4490
+ ),
4057
4491
  change_summary: z5.string().max(500).optional().describe("Optional summary of changes."),
4058
4492
  changed_paths: z5.array(z5.string()).optional().describe("Optional changed path list."),
4059
4493
  source_url: z5.string().url().optional().describe("Optional source URL for provenance."),
4060
- extraction_method: z5.enum(["manual", "url_extract", "business_profiler", "product_showcase"]).optional().describe("Extraction method metadata."),
4494
+ extraction_method: z5.enum([
4495
+ "manual",
4496
+ "url_extract",
4497
+ "business_profiler",
4498
+ "product_showcase"
4499
+ ]).optional().describe("Extraction method metadata."),
4061
4500
  overall_confidence: z5.number().min(0).max(1).optional().describe("Optional overall confidence score in range 0..1."),
4062
4501
  extraction_metadata: z5.record(z5.string(), z5.unknown()).optional(),
4063
4502
  response_format: z5.enum(["text", "json"]).optional()
@@ -4097,20 +4536,28 @@ function registerBrandTools(server) {
4097
4536
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
4098
4537
  if (!membership) {
4099
4538
  return {
4100
- content: [{ type: "text", text: "Project is not accessible to current user." }],
4539
+ content: [
4540
+ {
4541
+ type: "text",
4542
+ text: "Project is not accessible to current user."
4543
+ }
4544
+ ],
4101
4545
  isError: true
4102
4546
  };
4103
4547
  }
4104
- const { data: profileId, error } = await supabase.rpc("set_active_brand_profile", {
4105
- p_project_id: projectId,
4106
- p_brand_context: brand_context,
4107
- p_change_summary: change_summary || null,
4108
- p_changed_paths: changed_paths || [],
4109
- p_source_url: source_url || null,
4110
- p_extraction_method: extraction_method || "manual",
4111
- p_overall_confidence: overall_confidence ?? null,
4112
- p_extraction_metadata: extraction_metadata || null
4113
- });
4548
+ const { data: profileId, error } = await supabase.rpc(
4549
+ "set_active_brand_profile",
4550
+ {
4551
+ p_project_id: projectId,
4552
+ p_brand_context: brand_context,
4553
+ p_change_summary: change_summary || null,
4554
+ p_changed_paths: changed_paths || [],
4555
+ p_source_url: source_url || null,
4556
+ p_extraction_method: extraction_method || "manual",
4557
+ p_overall_confidence: overall_confidence ?? null,
4558
+ p_extraction_metadata: extraction_metadata || null
4559
+ }
4560
+ );
4114
4561
  if (error) {
4115
4562
  return {
4116
4563
  content: [
@@ -4131,7 +4578,12 @@ function registerBrandTools(server) {
4131
4578
  };
4132
4579
  if ((response_format || "text") === "json") {
4133
4580
  return {
4134
- content: [{ type: "text", text: JSON.stringify(asEnvelope4(payload), null, 2) }]
4581
+ content: [
4582
+ {
4583
+ type: "text",
4584
+ text: JSON.stringify(asEnvelope4(payload), null, 2)
4585
+ }
4586
+ ]
4135
4587
  };
4136
4588
  }
4137
4589
  return {
@@ -4187,7 +4639,10 @@ Version: ${payload.version ?? "N/A"}`
4187
4639
  if (!projectId) {
4188
4640
  return {
4189
4641
  content: [
4190
- { type: "text", text: "No project_id provided and no default project found." }
4642
+ {
4643
+ type: "text",
4644
+ text: "No project_id provided and no default project found."
4645
+ }
4191
4646
  ],
4192
4647
  isError: true
4193
4648
  };
@@ -4202,7 +4657,12 @@ Version: ${payload.version ?? "N/A"}`
4202
4657
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
4203
4658
  if (!membership) {
4204
4659
  return {
4205
- content: [{ type: "text", text: "Project is not accessible to current user." }],
4660
+ content: [
4661
+ {
4662
+ type: "text",
4663
+ text: "Project is not accessible to current user."
4664
+ }
4665
+ ],
4206
4666
  isError: true
4207
4667
  };
4208
4668
  }
@@ -4218,7 +4678,9 @@ Version: ${payload.version ?? "N/A"}`
4218
4678
  isError: true
4219
4679
  };
4220
4680
  }
4221
- const brandContext = { ...existingProfile.brand_context };
4681
+ const brandContext = {
4682
+ ...existingProfile.brand_context
4683
+ };
4222
4684
  const voiceProfile = brandContext.voiceProfile ?? {};
4223
4685
  const platformOverrides = voiceProfile.platformOverrides ?? {};
4224
4686
  const existingOverride = platformOverrides[platform2] ?? {};
@@ -4242,16 +4704,19 @@ Version: ${payload.version ?? "N/A"}`
4242
4704
  ...brandContext,
4243
4705
  voiceProfile: updatedVoiceProfile
4244
4706
  };
4245
- const { data: profileId, error: saveError } = await supabase.rpc("set_active_brand_profile", {
4246
- p_project_id: projectId,
4247
- p_brand_context: updatedContext,
4248
- p_change_summary: `Updated platform voice override for ${platform2}`,
4249
- p_changed_paths: [`voiceProfile.platformOverrides.${platform2}`],
4250
- p_source_url: null,
4251
- p_extraction_method: "manual",
4252
- p_overall_confidence: null,
4253
- p_extraction_metadata: null
4254
- });
4707
+ const { data: profileId, error: saveError } = await supabase.rpc(
4708
+ "set_active_brand_profile",
4709
+ {
4710
+ p_project_id: projectId,
4711
+ p_brand_context: updatedContext,
4712
+ p_change_summary: `Updated platform voice override for ${platform2}`,
4713
+ p_changed_paths: [`voiceProfile.platformOverrides.${platform2}`],
4714
+ p_source_url: null,
4715
+ p_extraction_method: "manual",
4716
+ p_overall_confidence: null,
4717
+ p_extraction_metadata: null
4718
+ }
4719
+ );
4255
4720
  if (saveError) {
4256
4721
  return {
4257
4722
  content: [
@@ -4272,7 +4737,12 @@ Version: ${payload.version ?? "N/A"}`
4272
4737
  };
4273
4738
  if ((response_format || "text") === "json") {
4274
4739
  return {
4275
- content: [{ type: "text", text: JSON.stringify(asEnvelope4(payload), null, 2) }],
4740
+ content: [
4741
+ {
4742
+ type: "text",
4743
+ text: JSON.stringify(asEnvelope4(payload), null, 2)
4744
+ }
4745
+ ],
4276
4746
  isError: false
4277
4747
  };
4278
4748
  }
@@ -4370,155 +4840,6 @@ async function capturePageScreenshot(page, outputPath, selector) {
4370
4840
  // src/tools/screenshot.ts
4371
4841
  import { resolve, relative } from "node:path";
4372
4842
  import { mkdir } from "node:fs/promises";
4373
-
4374
- // src/lib/ssrf.ts
4375
- var BLOCKED_IP_PATTERNS = [
4376
- // IPv4 localhost/loopback
4377
- /^127\./,
4378
- /^0\./,
4379
- // IPv4 private ranges (RFC 1918)
4380
- /^10\./,
4381
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
4382
- /^192\.168\./,
4383
- // IPv4 link-local
4384
- /^169\.254\./,
4385
- // Cloud metadata endpoint (AWS, GCP, Azure)
4386
- /^169\.254\.169\.254$/,
4387
- // IPv4 broadcast
4388
- /^255\./,
4389
- // Shared address space (RFC 6598)
4390
- /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
4391
- ];
4392
- var BLOCKED_IPV6_PATTERNS = [
4393
- /^::1$/i,
4394
- // loopback
4395
- /^::$/i,
4396
- // unspecified
4397
- /^fe[89ab][0-9a-f]:/i,
4398
- // link-local fe80::/10
4399
- /^fc[0-9a-f]:/i,
4400
- // unique local fc00::/7
4401
- /^fd[0-9a-f]:/i,
4402
- // unique local fc00::/7
4403
- /^::ffff:127\./i,
4404
- // IPv4-mapped localhost
4405
- /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
4406
- // IPv4-mapped private
4407
- ];
4408
- var BLOCKED_HOSTNAMES = [
4409
- "localhost",
4410
- "localhost.localdomain",
4411
- "local",
4412
- "127.0.0.1",
4413
- "0.0.0.0",
4414
- "[::1]",
4415
- "[::ffff:127.0.0.1]",
4416
- // Cloud metadata endpoints
4417
- "metadata.google.internal",
4418
- "metadata.goog",
4419
- "instance-data",
4420
- "instance-data.ec2.internal"
4421
- ];
4422
- var ALLOWED_PROTOCOLS = ["http:", "https:"];
4423
- var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
4424
- function isBlockedIP(ip) {
4425
- const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
4426
- if (normalized.includes(":")) {
4427
- return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
4428
- }
4429
- return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
4430
- }
4431
- function isBlockedHostname(hostname) {
4432
- return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
4433
- }
4434
- function isIPAddress(hostname) {
4435
- const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
4436
- const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
4437
- return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
4438
- }
4439
- async function validateUrlForSSRF(urlString) {
4440
- try {
4441
- const url = new URL(urlString);
4442
- if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
4443
- return {
4444
- isValid: false,
4445
- error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
4446
- };
4447
- }
4448
- if (url.username || url.password) {
4449
- return {
4450
- isValid: false,
4451
- error: "URLs with embedded credentials are not allowed."
4452
- };
4453
- }
4454
- const hostname = url.hostname.toLowerCase();
4455
- if (isBlockedHostname(hostname)) {
4456
- return {
4457
- isValid: false,
4458
- error: "Access to internal/localhost addresses is not allowed."
4459
- };
4460
- }
4461
- if (isIPAddress(hostname) && isBlockedIP(hostname)) {
4462
- return {
4463
- isValid: false,
4464
- error: "Access to private/internal IP addresses is not allowed."
4465
- };
4466
- }
4467
- const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
4468
- if (BLOCKED_PORTS.includes(port)) {
4469
- return {
4470
- isValid: false,
4471
- error: `Access to port ${port} is not allowed.`
4472
- };
4473
- }
4474
- let resolvedIP;
4475
- if (!isIPAddress(hostname)) {
4476
- try {
4477
- const dns = await import("node:dns");
4478
- const resolver = new dns.promises.Resolver();
4479
- const resolvedIPs = [];
4480
- try {
4481
- const aRecords = await resolver.resolve4(hostname);
4482
- resolvedIPs.push(...aRecords);
4483
- } catch {
4484
- }
4485
- try {
4486
- const aaaaRecords = await resolver.resolve6(hostname);
4487
- resolvedIPs.push(...aaaaRecords);
4488
- } catch {
4489
- }
4490
- if (resolvedIPs.length === 0) {
4491
- return {
4492
- isValid: false,
4493
- error: "DNS resolution failed: hostname did not resolve to any address."
4494
- };
4495
- }
4496
- for (const ip of resolvedIPs) {
4497
- if (isBlockedIP(ip)) {
4498
- return {
4499
- isValid: false,
4500
- error: "Hostname resolves to a private/internal IP address."
4501
- };
4502
- }
4503
- }
4504
- resolvedIP = resolvedIPs[0];
4505
- } catch {
4506
- return {
4507
- isValid: false,
4508
- error: "DNS resolution failed. Cannot verify hostname safety."
4509
- };
4510
- }
4511
- }
4512
- return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
4513
- } catch (error) {
4514
- return {
4515
- isValid: false,
4516
- error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
4517
- };
4518
- }
4519
- }
4520
-
4521
- // src/tools/screenshot.ts
4522
4843
  init_supabase();
4523
4844
  function registerScreenshotTools(server) {
4524
4845
  server.tool(
@@ -5094,6 +5415,7 @@ function registerRemotionTools(server) {
5094
5415
  // src/tools/insights.ts
5095
5416
  init_supabase();
5096
5417
  import { z as z8 } from "zod";
5418
+ var MAX_INSIGHT_AGE_DAYS = 30;
5097
5419
  var PLATFORM_ENUM = [
5098
5420
  "youtube",
5099
5421
  "tiktok",
@@ -5104,11 +5426,19 @@ var PLATFORM_ENUM = [
5104
5426
  "threads",
5105
5427
  "bluesky"
5106
5428
  ];
5107
- var DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
5429
+ var DAY_NAMES = [
5430
+ "Sunday",
5431
+ "Monday",
5432
+ "Tuesday",
5433
+ "Wednesday",
5434
+ "Thursday",
5435
+ "Friday",
5436
+ "Saturday"
5437
+ ];
5108
5438
  function asEnvelope5(data) {
5109
5439
  return {
5110
5440
  _meta: {
5111
- version: "0.2.0",
5441
+ version: MCP_VERSION,
5112
5442
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5113
5443
  },
5114
5444
  data
@@ -5119,7 +5449,12 @@ function registerInsightsTools(server) {
5119
5449
  "get_performance_insights",
5120
5450
  "Query performance insights derived from post analytics. Returns metrics like engagement rate, view velocity, and click rate aggregated over time. Use this to understand what content is performing well.",
5121
5451
  {
5122
- insight_type: z8.enum(["top_hooks", "optimal_timing", "best_models", "competitor_patterns"]).optional().describe("Filter to a specific insight type."),
5452
+ insight_type: z8.enum([
5453
+ "top_hooks",
5454
+ "optimal_timing",
5455
+ "best_models",
5456
+ "competitor_patterns"
5457
+ ]).optional().describe("Filter to a specific insight type."),
5123
5458
  days: z8.number().min(1).max(90).optional().describe("Number of days to look back. Defaults to 30. Max 90."),
5124
5459
  limit: z8.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
5125
5460
  response_format: z8.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
@@ -5138,10 +5473,13 @@ function registerInsightsTools(server) {
5138
5473
  projectIds.push(...projects.map((p) => p.id));
5139
5474
  }
5140
5475
  }
5476
+ const effectiveDays = Math.min(lookbackDays, MAX_INSIGHT_AGE_DAYS);
5141
5477
  const since = /* @__PURE__ */ new Date();
5142
- since.setDate(since.getDate() - lookbackDays);
5478
+ since.setDate(since.getDate() - effectiveDays);
5143
5479
  const sinceIso = since.toISOString();
5144
- let query = supabase.from("performance_insights").select("id, project_id, insight_type, insight_data, confidence_score, generated_at").gte("generated_at", sinceIso).order("generated_at", { ascending: false }).limit(maxRows);
5480
+ let query = supabase.from("performance_insights").select(
5481
+ "id, project_id, insight_type, insight_data, confidence_score, generated_at"
5482
+ ).gte("generated_at", sinceIso).order("generated_at", { ascending: false }).limit(maxRows);
5145
5483
  if (projectIds.length > 0) {
5146
5484
  query = query.in("project_id", projectIds);
5147
5485
  } else {
@@ -5498,7 +5836,7 @@ init_supabase();
5498
5836
  function asEnvelope6(data) {
5499
5837
  return {
5500
5838
  _meta: {
5501
- version: "0.2.0",
5839
+ version: MCP_VERSION,
5502
5840
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5503
5841
  },
5504
5842
  data
@@ -5509,7 +5847,9 @@ function registerCommentsTools(server) {
5509
5847
  "list_comments",
5510
5848
  "List YouTube comments. Without a video_id, returns recent comments across all channel videos. With a video_id, returns comments for that specific video.",
5511
5849
  {
5512
- video_id: z10.string().optional().describe("YouTube video ID. If omitted, returns comments across all channel videos."),
5850
+ video_id: z10.string().optional().describe(
5851
+ "YouTube video ID. If omitted, returns comments across all channel videos."
5852
+ ),
5513
5853
  max_results: z10.number().min(1).max(100).optional().describe("Maximum number of comments to return. Defaults to 50."),
5514
5854
  page_token: z10.string().optional().describe("Pagination token from a previous list_comments call."),
5515
5855
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
@@ -5524,7 +5864,9 @@ function registerCommentsTools(server) {
5524
5864
  });
5525
5865
  if (error) {
5526
5866
  return {
5527
- content: [{ type: "text", text: `Error listing comments: ${error}` }],
5867
+ content: [
5868
+ { type: "text", text: `Error listing comments: ${error}` }
5869
+ ],
5528
5870
  isError: true
5529
5871
  };
5530
5872
  }
@@ -5536,7 +5878,10 @@ function registerCommentsTools(server) {
5536
5878
  {
5537
5879
  type: "text",
5538
5880
  text: JSON.stringify(
5539
- asEnvelope6({ comments, nextPageToken: result.nextPageToken ?? null }),
5881
+ asEnvelope6({
5882
+ comments,
5883
+ nextPageToken: result.nextPageToken ?? null
5884
+ }),
5540
5885
  null,
5541
5886
  2
5542
5887
  )
@@ -5573,7 +5918,9 @@ function registerCommentsTools(server) {
5573
5918
  "reply_to_comment",
5574
5919
  "Reply to a YouTube comment. Requires the parent comment ID and reply text.",
5575
5920
  {
5576
- parent_id: z10.string().describe("The ID of the parent comment to reply to (from list_comments)."),
5921
+ parent_id: z10.string().describe(
5922
+ "The ID of the parent comment to reply to (from list_comments)."
5923
+ ),
5577
5924
  text: z10.string().min(1).describe("The reply text."),
5578
5925
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5579
5926
  },
@@ -5612,7 +5959,12 @@ function registerCommentsTools(server) {
5612
5959
  details: { error }
5613
5960
  });
5614
5961
  return {
5615
- content: [{ type: "text", text: `Error replying to comment: ${error}` }],
5962
+ content: [
5963
+ {
5964
+ type: "text",
5965
+ text: `Error replying to comment: ${error}`
5966
+ }
5967
+ ],
5616
5968
  isError: true
5617
5969
  };
5618
5970
  }
@@ -5625,7 +5977,12 @@ function registerCommentsTools(server) {
5625
5977
  });
5626
5978
  if (format === "json") {
5627
5979
  return {
5628
- content: [{ type: "text", text: JSON.stringify(asEnvelope6(result), null, 2) }]
5980
+ content: [
5981
+ {
5982
+ type: "text",
5983
+ text: JSON.stringify(asEnvelope6(result), null, 2)
5984
+ }
5985
+ ]
5629
5986
  };
5630
5987
  }
5631
5988
  return {
@@ -5683,7 +6040,9 @@ function registerCommentsTools(server) {
5683
6040
  details: { error }
5684
6041
  });
5685
6042
  return {
5686
- content: [{ type: "text", text: `Error posting comment: ${error}` }],
6043
+ content: [
6044
+ { type: "text", text: `Error posting comment: ${error}` }
6045
+ ],
5687
6046
  isError: true
5688
6047
  };
5689
6048
  }
@@ -5696,7 +6055,12 @@ function registerCommentsTools(server) {
5696
6055
  });
5697
6056
  if (format === "json") {
5698
6057
  return {
5699
- content: [{ type: "text", text: JSON.stringify(asEnvelope6(result), null, 2) }]
6058
+ content: [
6059
+ {
6060
+ type: "text",
6061
+ text: JSON.stringify(asEnvelope6(result), null, 2)
6062
+ }
6063
+ ]
5700
6064
  };
5701
6065
  }
5702
6066
  return {
@@ -5754,7 +6118,12 @@ function registerCommentsTools(server) {
5754
6118
  details: { error }
5755
6119
  });
5756
6120
  return {
5757
- content: [{ type: "text", text: `Error moderating comment: ${error}` }],
6121
+ content: [
6122
+ {
6123
+ type: "text",
6124
+ text: `Error moderating comment: ${error}`
6125
+ }
6126
+ ],
5758
6127
  isError: true
5759
6128
  };
5760
6129
  }
@@ -5833,7 +6202,9 @@ function registerCommentsTools(server) {
5833
6202
  details: { error }
5834
6203
  });
5835
6204
  return {
5836
- content: [{ type: "text", text: `Error deleting comment: ${error}` }],
6205
+ content: [
6206
+ { type: "text", text: `Error deleting comment: ${error}` }
6207
+ ],
5837
6208
  isError: true
5838
6209
  };
5839
6210
  }
@@ -5848,13 +6219,22 @@ function registerCommentsTools(server) {
5848
6219
  content: [
5849
6220
  {
5850
6221
  type: "text",
5851
- text: JSON.stringify(asEnvelope6({ success: true, commentId: comment_id }), null, 2)
6222
+ text: JSON.stringify(
6223
+ asEnvelope6({ success: true, commentId: comment_id }),
6224
+ null,
6225
+ 2
6226
+ )
5852
6227
  }
5853
6228
  ]
5854
6229
  };
5855
6230
  }
5856
6231
  return {
5857
- content: [{ type: "text", text: `Comment ${comment_id} deleted successfully.` }]
6232
+ content: [
6233
+ {
6234
+ type: "text",
6235
+ text: `Comment ${comment_id} deleted successfully.`
6236
+ }
6237
+ ]
5858
6238
  };
5859
6239
  }
5860
6240
  );
@@ -5882,8 +6262,12 @@ function transformInsightsToPerformanceContext(projectId, insights) {
5882
6262
  };
5883
6263
  }
5884
6264
  const topHooksInsight = insights.find((i) => i.insight_type === "top_hooks");
5885
- const optimalTimingInsight = insights.find((i) => i.insight_type === "optimal_timing");
5886
- const bestModelsInsight = insights.find((i) => i.insight_type === "best_models");
6265
+ const optimalTimingInsight = insights.find(
6266
+ (i) => i.insight_type === "optimal_timing"
6267
+ );
6268
+ const bestModelsInsight = insights.find(
6269
+ (i) => i.insight_type === "best_models"
6270
+ );
5887
6271
  const topHooks = topHooksInsight?.insight_data?.hooks || [];
5888
6272
  const hooksSummary = topHooksInsight?.insight_data?.summary || "";
5889
6273
  const timingSummary = optimalTimingInsight?.insight_data?.summary || "";
@@ -5894,7 +6278,10 @@ function transformInsightsToPerformanceContext(projectId, insights) {
5894
6278
  if (hooksSummary) promptParts.push(hooksSummary);
5895
6279
  if (timingSummary) promptParts.push(timingSummary);
5896
6280
  if (modelSummary) promptParts.push(modelSummary);
5897
- if (topHooks.length) promptParts.push(`Top performing hooks: ${topHooks.slice(0, 3).join(", ")}`);
6281
+ if (topHooks.length)
6282
+ promptParts.push(
6283
+ `Top performing hooks: ${topHooks.slice(0, 3).join(", ")}`
6284
+ );
5898
6285
  return {
5899
6286
  projectId,
5900
6287
  hasHistoricalData: true,
@@ -5920,7 +6307,7 @@ function transformInsightsToPerformanceContext(projectId, insights) {
5920
6307
  function asEnvelope7(data) {
5921
6308
  return {
5922
6309
  _meta: {
5923
- version: "0.2.0",
6310
+ version: MCP_VERSION,
5924
6311
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5925
6312
  },
5926
6313
  data
@@ -5943,7 +6330,12 @@ function registerIdeationContextTools(server) {
5943
6330
  const { data: member } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).limit(1).single();
5944
6331
  if (!member?.organization_id) {
5945
6332
  return {
5946
- content: [{ type: "text", text: "No organization found for current user." }],
6333
+ content: [
6334
+ {
6335
+ type: "text",
6336
+ text: "No organization found for current user."
6337
+ }
6338
+ ],
5947
6339
  isError: true
5948
6340
  };
5949
6341
  }
@@ -5951,7 +6343,10 @@ function registerIdeationContextTools(server) {
5951
6343
  if (projectsError) {
5952
6344
  return {
5953
6345
  content: [
5954
- { type: "text", text: `Failed to resolve projects: ${projectsError.message}` }
6346
+ {
6347
+ type: "text",
6348
+ text: `Failed to resolve projects: ${projectsError.message}`
6349
+ }
5955
6350
  ],
5956
6351
  isError: true
5957
6352
  };
@@ -5959,7 +6354,12 @@ function registerIdeationContextTools(server) {
5959
6354
  const projectIds = (projects || []).map((p) => p.id);
5960
6355
  if (projectIds.length === 0) {
5961
6356
  return {
5962
- content: [{ type: "text", text: "No projects found for current user." }],
6357
+ content: [
6358
+ {
6359
+ type: "text",
6360
+ text: "No projects found for current user."
6361
+ }
6362
+ ],
5963
6363
  isError: true
5964
6364
  };
5965
6365
  }
@@ -5968,7 +6368,10 @@ function registerIdeationContextTools(server) {
5968
6368
  if (!selectedProjectId) {
5969
6369
  return {
5970
6370
  content: [
5971
- { type: "text", text: "No accessible project found for current user." }
6371
+ {
6372
+ type: "text",
6373
+ text: "No accessible project found for current user."
6374
+ }
5972
6375
  ],
5973
6376
  isError: true
5974
6377
  };
@@ -5986,7 +6389,9 @@ function registerIdeationContextTools(server) {
5986
6389
  }
5987
6390
  const since = /* @__PURE__ */ new Date();
5988
6391
  since.setDate(since.getDate() - lookbackDays);
5989
- const { data: insights, error } = await supabase.from("performance_insights").select("id, project_id, insight_type, insight_data, generated_at, expires_at").eq("project_id", selectedProjectId).gte("generated_at", since.toISOString()).gt("expires_at", (/* @__PURE__ */ new Date()).toISOString()).order("generated_at", { ascending: false }).limit(30);
6392
+ const { data: insights, error } = await supabase.from("performance_insights").select(
6393
+ "id, project_id, insight_type, insight_data, generated_at, expires_at"
6394
+ ).eq("project_id", selectedProjectId).gte("generated_at", since.toISOString()).gt("expires_at", (/* @__PURE__ */ new Date()).toISOString()).order("generated_at", { ascending: false }).limit(30);
5990
6395
  if (error) {
5991
6396
  return {
5992
6397
  content: [
@@ -6004,7 +6409,12 @@ function registerIdeationContextTools(server) {
6004
6409
  );
6005
6410
  if (format === "json") {
6006
6411
  return {
6007
- content: [{ type: "text", text: JSON.stringify(asEnvelope7(context), null, 2) }]
6412
+ content: [
6413
+ {
6414
+ type: "text",
6415
+ text: JSON.stringify(asEnvelope7(context), null, 2)
6416
+ }
6417
+ ]
6008
6418
  };
6009
6419
  }
6010
6420
  const lines = [
@@ -6028,7 +6438,7 @@ import { z as z12 } from "zod";
6028
6438
  function asEnvelope8(data) {
6029
6439
  return {
6030
6440
  _meta: {
6031
- version: "0.2.0",
6441
+ version: MCP_VERSION,
6032
6442
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6033
6443
  },
6034
6444
  data
@@ -6067,7 +6477,12 @@ function registerCreditsTools(server) {
6067
6477
  };
6068
6478
  if ((response_format || "text") === "json") {
6069
6479
  return {
6070
- content: [{ type: "text", text: JSON.stringify(asEnvelope8(payload), null, 2) }]
6480
+ content: [
6481
+ {
6482
+ type: "text",
6483
+ text: JSON.stringify(asEnvelope8(payload), null, 2)
6484
+ }
6485
+ ]
6071
6486
  };
6072
6487
  }
6073
6488
  return {
@@ -6101,7 +6516,12 @@ Monthly used: ${payload.monthlyUsed}` + (payload.monthlyLimit ? ` / ${payload.mo
6101
6516
  };
6102
6517
  if ((response_format || "text") === "json") {
6103
6518
  return {
6104
- content: [{ type: "text", text: JSON.stringify(asEnvelope8(payload), null, 2) }]
6519
+ content: [
6520
+ {
6521
+ type: "text",
6522
+ text: JSON.stringify(asEnvelope8(payload), null, 2)
6523
+ }
6524
+ ]
6105
6525
  };
6106
6526
  }
6107
6527
  return {
@@ -6128,7 +6548,7 @@ import { z as z13 } from "zod";
6128
6548
  function asEnvelope9(data) {
6129
6549
  return {
6130
6550
  _meta: {
6131
- version: "0.2.0",
6551
+ version: MCP_VERSION,
6132
6552
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6133
6553
  },
6134
6554
  data
@@ -6167,7 +6587,12 @@ function registerLoopSummaryTools(server) {
6167
6587
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
6168
6588
  if (!membership) {
6169
6589
  return {
6170
- content: [{ type: "text", text: "Project is not accessible to current user." }],
6590
+ content: [
6591
+ {
6592
+ type: "text",
6593
+ text: "Project is not accessible to current user."
6594
+ }
6595
+ ],
6171
6596
  isError: true
6172
6597
  };
6173
6598
  }
@@ -6190,7 +6615,12 @@ function registerLoopSummaryTools(server) {
6190
6615
  };
6191
6616
  if ((response_format || "text") === "json") {
6192
6617
  return {
6193
- content: [{ type: "text", text: JSON.stringify(asEnvelope9(payload), null, 2) }]
6618
+ content: [
6619
+ {
6620
+ type: "text",
6621
+ text: JSON.stringify(asEnvelope9(payload), null, 2)
6622
+ }
6623
+ ]
6194
6624
  };
6195
6625
  }
6196
6626
  return {
@@ -6213,11 +6643,6 @@ Next Action: ${payload.recommendedNextAction}`
6213
6643
  // src/tools/usage.ts
6214
6644
  init_supabase();
6215
6645
  import { z as z14 } from "zod";
6216
-
6217
- // src/lib/version.ts
6218
- var MCP_VERSION = "1.3.2";
6219
-
6220
- // src/tools/usage.ts
6221
6646
  function asEnvelope10(data) {
6222
6647
  return {
6223
6648
  _meta: {
@@ -6523,7 +6948,10 @@ ${"=".repeat(40)}
6523
6948
  import { z as z16 } from "zod";
6524
6949
  init_supabase();
6525
6950
  function asEnvelope12(data) {
6526
- return { _meta: { version: "0.2.0", timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
6951
+ return {
6952
+ _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
6953
+ data
6954
+ };
6527
6955
  }
6528
6956
  function isYouTubeUrl(url) {
6529
6957
  if (/youtube\.com\/watch|youtu\.be\//.test(url)) return "video";
@@ -6554,13 +6982,17 @@ Metadata:`);
6554
6982
  if (m.tags?.length) lines.push(` Tags: ${m.tags.join(", ")}`);
6555
6983
  }
6556
6984
  if (content.features?.length)
6557
- lines.push(`
6985
+ lines.push(
6986
+ `
6558
6987
  Features:
6559
- ${content.features.map((f) => ` - ${f}`).join("\n")}`);
6988
+ ${content.features.map((f) => ` - ${f}`).join("\n")}`
6989
+ );
6560
6990
  if (content.benefits?.length)
6561
- lines.push(`
6991
+ lines.push(
6992
+ `
6562
6993
  Benefits:
6563
- ${content.benefits.map((b) => ` - ${b}`).join("\n")}`);
6994
+ ${content.benefits.map((b) => ` - ${b}`).join("\n")}`
6995
+ );
6564
6996
  if (content.usp) lines.push(`
6565
6997
  USP: ${content.usp}`);
6566
6998
  if (content.suggested_hooks?.length)
@@ -6582,12 +7014,20 @@ function registerExtractionTools(server) {
6582
7014
  max_results: z16.number().min(1).max(100).default(10).describe("Max comments to include"),
6583
7015
  response_format: z16.enum(["text", "json"]).default("text")
6584
7016
  },
6585
- async ({ url, extract_type, include_comments, max_results, response_format }) => {
7017
+ async ({
7018
+ url,
7019
+ extract_type,
7020
+ include_comments,
7021
+ max_results,
7022
+ response_format
7023
+ }) => {
6586
7024
  const startedAt = Date.now();
6587
7025
  const ssrfCheck = await validateUrlForSSRF(url);
6588
7026
  if (!ssrfCheck.isValid) {
6589
7027
  return {
6590
- content: [{ type: "text", text: `URL blocked: ${ssrfCheck.error}` }],
7028
+ content: [
7029
+ { type: "text", text: `URL blocked: ${ssrfCheck.error}` }
7030
+ ],
6591
7031
  isError: true
6592
7032
  };
6593
7033
  }
@@ -6715,13 +7155,21 @@ function registerExtractionTools(server) {
6715
7155
  if (response_format === "json") {
6716
7156
  return {
6717
7157
  content: [
6718
- { type: "text", text: JSON.stringify(asEnvelope12(extracted), null, 2) }
7158
+ {
7159
+ type: "text",
7160
+ text: JSON.stringify(asEnvelope12(extracted), null, 2)
7161
+ }
6719
7162
  ],
6720
7163
  isError: false
6721
7164
  };
6722
7165
  }
6723
7166
  return {
6724
- content: [{ type: "text", text: formatExtractedContentAsText(extracted) }],
7167
+ content: [
7168
+ {
7169
+ type: "text",
7170
+ text: formatExtractedContentAsText(extracted)
7171
+ }
7172
+ ],
6725
7173
  isError: false
6726
7174
  };
6727
7175
  } catch (err) {
@@ -6734,7 +7182,9 @@ function registerExtractionTools(server) {
6734
7182
  details: { url, error: message }
6735
7183
  });
6736
7184
  return {
6737
- content: [{ type: "text", text: `Extraction failed: ${message}` }],
7185
+ content: [
7186
+ { type: "text", text: `Extraction failed: ${message}` }
7187
+ ],
6738
7188
  isError: true
6739
7189
  };
6740
7190
  }
@@ -6746,7 +7196,10 @@ function registerExtractionTools(server) {
6746
7196
  import { z as z17 } from "zod";
6747
7197
  init_supabase();
6748
7198
  function asEnvelope13(data) {
6749
- return { _meta: { version: "0.2.0", timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
7199
+ return {
7200
+ _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
7201
+ data
7202
+ };
6750
7203
  }
6751
7204
  function registerQualityTools(server) {
6752
7205
  server.tool(
@@ -6802,7 +7255,12 @@ function registerQualityTools(server) {
6802
7255
  });
6803
7256
  if (response_format === "json") {
6804
7257
  return {
6805
- content: [{ type: "text", text: JSON.stringify(asEnvelope13(result), null, 2) }],
7258
+ content: [
7259
+ {
7260
+ type: "text",
7261
+ text: JSON.stringify(asEnvelope13(result), null, 2)
7262
+ }
7263
+ ],
6806
7264
  isError: false
6807
7265
  };
6808
7266
  }
@@ -6812,7 +7270,9 @@ function registerQualityTools(server) {
6812
7270
  );
6813
7271
  lines.push("");
6814
7272
  for (const cat of result.categories) {
6815
- lines.push(` ${cat.name}: ${cat.score}/${cat.maxScore} \u2014 ${cat.detail}`);
7273
+ lines.push(
7274
+ ` ${cat.name}: ${cat.score}/${cat.maxScore} \u2014 ${cat.detail}`
7275
+ );
6816
7276
  }
6817
7277
  if (result.blockers.length > 0) {
6818
7278
  lines.push("");
@@ -6823,7 +7283,10 @@ function registerQualityTools(server) {
6823
7283
  }
6824
7284
  lines.push("");
6825
7285
  lines.push(`Threshold: ${result.threshold}/${result.maxTotal}`);
6826
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
7286
+ return {
7287
+ content: [{ type: "text", text: lines.join("\n") }],
7288
+ isError: false
7289
+ };
6827
7290
  }
6828
7291
  );
6829
7292
  server.tool(
@@ -6864,7 +7327,9 @@ function registerQualityTools(server) {
6864
7327
  });
6865
7328
  const scores = postsWithQuality.map((p) => p.quality.score);
6866
7329
  const passed = postsWithQuality.filter((p) => p.quality.passed).length;
6867
- const avgScore = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : 0;
7330
+ const avgScore = scores.length > 0 ? Math.round(
7331
+ scores.reduce((a, b) => a + b, 0) / scores.length * 10
7332
+ ) / 10 : 0;
6868
7333
  const summary = {
6869
7334
  total_posts: plan.posts.length,
6870
7335
  passed,
@@ -6883,25 +7348,36 @@ function registerQualityTools(server) {
6883
7348
  content: [
6884
7349
  {
6885
7350
  type: "text",
6886
- text: JSON.stringify(asEnvelope13({ posts: postsWithQuality, summary }), null, 2)
7351
+ text: JSON.stringify(
7352
+ asEnvelope13({ posts: postsWithQuality, summary }),
7353
+ null,
7354
+ 2
7355
+ )
6887
7356
  }
6888
7357
  ],
6889
7358
  isError: false
6890
7359
  };
6891
7360
  }
6892
7361
  const lines = [];
6893
- lines.push(`PLAN QUALITY: ${passed}/${plan.posts.length} passed (avg: ${avgScore}/35)`);
7362
+ lines.push(
7363
+ `PLAN QUALITY: ${passed}/${plan.posts.length} passed (avg: ${avgScore}/35)`
7364
+ );
6894
7365
  lines.push("");
6895
7366
  for (const post of postsWithQuality) {
6896
7367
  const icon = post.quality.passed ? "[PASS]" : "[FAIL]";
6897
- lines.push(`${icon} ${post.id} | ${post.platform} | ${post.quality.score}/35`);
7368
+ lines.push(
7369
+ `${icon} ${post.id} | ${post.platform} | ${post.quality.score}/35`
7370
+ );
6898
7371
  if (post.quality.blockers.length > 0) {
6899
7372
  for (const b of post.quality.blockers) {
6900
7373
  lines.push(` - ${b}`);
6901
7374
  }
6902
7375
  }
6903
7376
  }
6904
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
7377
+ return {
7378
+ content: [{ type: "text", text: lines.join("\n") }],
7379
+ isError: false
7380
+ };
6905
7381
  }
6906
7382
  );
6907
7383
  }
@@ -6914,7 +7390,10 @@ function toRecord(value) {
6914
7390
  return value && typeof value === "object" ? value : void 0;
6915
7391
  }
6916
7392
  function asEnvelope14(data) {
6917
- return { _meta: { version: "0.2.0", timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
7393
+ return {
7394
+ _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
7395
+ data
7396
+ };
6918
7397
  }
6919
7398
  function tomorrowIsoDate() {
6920
7399
  const d = /* @__PURE__ */ new Date();
@@ -6952,7 +7431,9 @@ function formatPlanAsText(plan) {
6952
7431
  lines.push(`WEEKLY CONTENT PLAN: "${plan.topic}"`);
6953
7432
  lines.push(`Period: ${plan.start_date} to ${plan.end_date}`);
6954
7433
  lines.push(`Platforms: ${plan.platforms.join(", ")}`);
6955
- lines.push(`Posts: ${plan.posts.length} | Estimated credits: ~${plan.estimated_credits}`);
7434
+ lines.push(
7435
+ `Posts: ${plan.posts.length} | Estimated credits: ~${plan.estimated_credits}`
7436
+ );
6956
7437
  if (plan.plan_id) lines.push(`Plan ID: ${plan.plan_id}`);
6957
7438
  if (plan.insights_applied?.has_historical_data) {
6958
7439
  lines.push("");
@@ -6967,7 +7448,9 @@ function formatPlanAsText(plan) {
6967
7448
  `- Best posting time: ${days[timing.dayOfWeek] ?? timing.dayOfWeek} ${timing.hourOfDay}:00`
6968
7449
  );
6969
7450
  }
6970
- lines.push(`- Recommended model: ${plan.insights_applied.recommended_model ?? "N/A"}`);
7451
+ lines.push(
7452
+ `- Recommended model: ${plan.insights_applied.recommended_model ?? "N/A"}`
7453
+ );
6971
7454
  lines.push(`- Insights count: ${plan.insights_applied.insights_count}`);
6972
7455
  }
6973
7456
  lines.push("");
@@ -6988,9 +7471,11 @@ function formatPlanAsText(plan) {
6988
7471
  ` Caption: ${post.caption.slice(0, 200)}${post.caption.length > 200 ? "..." : ""}`
6989
7472
  );
6990
7473
  if (post.title) lines.push(` Title: ${post.title}`);
6991
- if (post.visual_direction) lines.push(` Visual: ${post.visual_direction}`);
7474
+ if (post.visual_direction)
7475
+ lines.push(` Visual: ${post.visual_direction}`);
6992
7476
  if (post.media_type) lines.push(` Media: ${post.media_type}`);
6993
- if (post.hashtags?.length) lines.push(` Hashtags: ${post.hashtags.join(" ")}`);
7477
+ if (post.hashtags?.length)
7478
+ lines.push(` Hashtags: ${post.hashtags.join(" ")}`);
6994
7479
  lines.push("");
6995
7480
  }
6996
7481
  }
@@ -7073,7 +7558,10 @@ function registerPlanningTools(server) {
7073
7558
  "mcp-data",
7074
7559
  {
7075
7560
  action: "brand-profile",
7076
- ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
7561
+ ...resolvedProjectId ? {
7562
+ projectId: resolvedProjectId,
7563
+ project_id: resolvedProjectId
7564
+ } : {}
7077
7565
  },
7078
7566
  { timeoutMs: 15e3 }
7079
7567
  );
@@ -7277,7 +7765,12 @@ ${rawText.slice(0, 1e3)}`
7277
7765
  details: { topic, error: `plan persistence failed: ${message}` }
7278
7766
  });
7279
7767
  return {
7280
- content: [{ type: "text", text: `Plan persistence failed: ${message}` }],
7768
+ content: [
7769
+ {
7770
+ type: "text",
7771
+ text: `Plan persistence failed: ${message}`
7772
+ }
7773
+ ],
7281
7774
  isError: true
7282
7775
  };
7283
7776
  }
@@ -7291,7 +7784,12 @@ ${rawText.slice(0, 1e3)}`
7291
7784
  });
7292
7785
  if (response_format === "json") {
7293
7786
  return {
7294
- content: [{ type: "text", text: JSON.stringify(asEnvelope14(plan), null, 2) }],
7787
+ content: [
7788
+ {
7789
+ type: "text",
7790
+ text: JSON.stringify(asEnvelope14(plan), null, 2)
7791
+ }
7792
+ ],
7295
7793
  isError: false
7296
7794
  };
7297
7795
  }
@@ -7309,7 +7807,12 @@ ${rawText.slice(0, 1e3)}`
7309
7807
  details: { topic, error: message }
7310
7808
  });
7311
7809
  return {
7312
- content: [{ type: "text", text: `Plan generation failed: ${message}` }],
7810
+ content: [
7811
+ {
7812
+ type: "text",
7813
+ text: `Plan generation failed: ${message}`
7814
+ }
7815
+ ],
7313
7816
  isError: true
7314
7817
  };
7315
7818
  }
@@ -7369,7 +7872,11 @@ ${rawText.slice(0, 1e3)}`
7369
7872
  toolName: "save_content_plan",
7370
7873
  status: "success",
7371
7874
  durationMs,
7372
- details: { plan_id: planId, project_id: resolvedProjectId, status: normalizedStatus }
7875
+ details: {
7876
+ plan_id: planId,
7877
+ project_id: resolvedProjectId,
7878
+ status: normalizedStatus
7879
+ }
7373
7880
  });
7374
7881
  const result = {
7375
7882
  plan_id: planId,
@@ -7378,13 +7885,21 @@ ${rawText.slice(0, 1e3)}`
7378
7885
  };
7379
7886
  if (response_format === "json") {
7380
7887
  return {
7381
- content: [{ type: "text", text: JSON.stringify(asEnvelope14(result), null, 2) }],
7888
+ content: [
7889
+ {
7890
+ type: "text",
7891
+ text: JSON.stringify(asEnvelope14(result), null, 2)
7892
+ }
7893
+ ],
7382
7894
  isError: false
7383
7895
  };
7384
7896
  }
7385
7897
  return {
7386
7898
  content: [
7387
- { type: "text", text: `Saved content plan ${planId} (${normalizedStatus}).` }
7899
+ {
7900
+ type: "text",
7901
+ text: `Saved content plan ${planId} (${normalizedStatus}).`
7902
+ }
7388
7903
  ],
7389
7904
  isError: false
7390
7905
  };
@@ -7398,7 +7913,12 @@ ${rawText.slice(0, 1e3)}`
7398
7913
  details: { error: message }
7399
7914
  });
7400
7915
  return {
7401
- content: [{ type: "text", text: `Failed to save content plan: ${message}` }],
7916
+ content: [
7917
+ {
7918
+ type: "text",
7919
+ text: `Failed to save content plan: ${message}`
7920
+ }
7921
+ ],
7402
7922
  isError: true
7403
7923
  };
7404
7924
  }
@@ -7414,7 +7934,9 @@ ${rawText.slice(0, 1e3)}`
7414
7934
  async ({ plan_id, response_format }) => {
7415
7935
  const supabase = getSupabaseClient();
7416
7936
  const userId = await getDefaultUserId();
7417
- const { data, error } = await supabase.from("content_plans").select("id, topic, status, plan_payload, insights_applied, created_at, updated_at").eq("id", plan_id).eq("user_id", userId).maybeSingle();
7937
+ const { data, error } = await supabase.from("content_plans").select(
7938
+ "id, topic, status, plan_payload, insights_applied, created_at, updated_at"
7939
+ ).eq("id", plan_id).eq("user_id", userId).maybeSingle();
7418
7940
  if (error) {
7419
7941
  return {
7420
7942
  content: [
@@ -7429,7 +7951,10 @@ ${rawText.slice(0, 1e3)}`
7429
7951
  if (!data) {
7430
7952
  return {
7431
7953
  content: [
7432
- { type: "text", text: `No content plan found for plan_id=${plan_id}` }
7954
+ {
7955
+ type: "text",
7956
+ text: `No content plan found for plan_id=${plan_id}`
7957
+ }
7433
7958
  ],
7434
7959
  isError: true
7435
7960
  };
@@ -7445,7 +7970,12 @@ ${rawText.slice(0, 1e3)}`
7445
7970
  };
7446
7971
  if (response_format === "json") {
7447
7972
  return {
7448
- content: [{ type: "text", text: JSON.stringify(asEnvelope14(payload), null, 2) }],
7973
+ content: [
7974
+ {
7975
+ type: "text",
7976
+ text: JSON.stringify(asEnvelope14(payload), null, 2)
7977
+ }
7978
+ ],
7449
7979
  isError: false
7450
7980
  };
7451
7981
  }
@@ -7456,7 +7986,10 @@ ${rawText.slice(0, 1e3)}`
7456
7986
  `Status: ${data.status}`,
7457
7987
  `Posts: ${Array.isArray(plan?.posts) ? plan.posts.length : 0}`
7458
7988
  ];
7459
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
7989
+ return {
7990
+ content: [{ type: "text", text: lines.join("\n") }],
7991
+ isError: false
7992
+ };
7460
7993
  }
7461
7994
  );
7462
7995
  server.tool(
@@ -7499,14 +8032,19 @@ ${rawText.slice(0, 1e3)}`
7499
8032
  if (!stored?.plan_payload) {
7500
8033
  return {
7501
8034
  content: [
7502
- { type: "text", text: `No content plan found for plan_id=${plan_id}` }
8035
+ {
8036
+ type: "text",
8037
+ text: `No content plan found for plan_id=${plan_id}`
8038
+ }
7503
8039
  ],
7504
8040
  isError: true
7505
8041
  };
7506
8042
  }
7507
8043
  const plan = stored.plan_payload;
7508
8044
  const existingPosts = Array.isArray(plan.posts) ? plan.posts : [];
7509
- const updatesById = new Map(post_updates.map((update) => [update.post_id, update]));
8045
+ const updatesById = new Map(
8046
+ post_updates.map((update) => [update.post_id, update])
8047
+ );
7510
8048
  const updatedPosts = existingPosts.map((post) => {
7511
8049
  const update = updatesById.get(post.id);
7512
8050
  if (!update) return post;
@@ -7524,7 +8062,9 @@ ${rawText.slice(0, 1e3)}`
7524
8062
  ...update.status !== void 0 ? { status: update.status } : {}
7525
8063
  };
7526
8064
  });
7527
- const nextStatus = updatedPosts.length > 0 && updatedPosts.every((post) => post.status === "approved" || post.status === "edited") ? "approved" : stored.status === "scheduled" || stored.status === "completed" ? stored.status : "draft";
8065
+ const nextStatus = updatedPosts.length > 0 && updatedPosts.every(
8066
+ (post) => post.status === "approved" || post.status === "edited"
8067
+ ) ? "approved" : stored.status === "scheduled" || stored.status === "completed" ? stored.status : "draft";
7528
8068
  const updatedPlan = {
7529
8069
  ...plan,
7530
8070
  posts: updatedPosts
@@ -7551,7 +8091,12 @@ ${rawText.slice(0, 1e3)}`
7551
8091
  };
7552
8092
  if (response_format === "json") {
7553
8093
  return {
7554
- content: [{ type: "text", text: JSON.stringify(asEnvelope14(payload), null, 2) }],
8094
+ content: [
8095
+ {
8096
+ type: "text",
8097
+ text: JSON.stringify(asEnvelope14(payload), null, 2)
8098
+ }
8099
+ ],
7555
8100
  isError: false
7556
8101
  };
7557
8102
  }
@@ -7591,7 +8136,10 @@ ${rawText.slice(0, 1e3)}`
7591
8136
  if (!stored?.plan_payload || !stored.project_id) {
7592
8137
  return {
7593
8138
  content: [
7594
- { type: "text", text: `No content plan found for plan_id=${plan_id}` }
8139
+ {
8140
+ type: "text",
8141
+ text: `No content plan found for plan_id=${plan_id}`
8142
+ }
7595
8143
  ],
7596
8144
  isError: true
7597
8145
  };
@@ -7600,7 +8148,12 @@ ${rawText.slice(0, 1e3)}`
7600
8148
  const posts = Array.isArray(plan.posts) ? plan.posts : [];
7601
8149
  if (posts.length === 0) {
7602
8150
  return {
7603
- content: [{ type: "text", text: `Plan ${plan_id} has no posts to submit.` }],
8151
+ content: [
8152
+ {
8153
+ type: "text",
8154
+ text: `Plan ${plan_id} has no posts to submit.`
8155
+ }
8156
+ ],
7604
8157
  isError: true
7605
8158
  };
7606
8159
  }
@@ -7643,7 +8196,12 @@ ${rawText.slice(0, 1e3)}`
7643
8196
  };
7644
8197
  if (response_format === "json") {
7645
8198
  return {
7646
- content: [{ type: "text", text: JSON.stringify(asEnvelope14(payload), null, 2) }],
8199
+ content: [
8200
+ {
8201
+ type: "text",
8202
+ text: JSON.stringify(asEnvelope14(payload), null, 2)
8203
+ }
8204
+ ],
7647
8205
  isError: false
7648
8206
  };
7649
8207
  }
@@ -7666,7 +8224,7 @@ import { z as z19 } from "zod";
7666
8224
  function asEnvelope15(data) {
7667
8225
  return {
7668
8226
  _meta: {
7669
- version: "0.2.0",
8227
+ version: MCP_VERSION,
7670
8228
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7671
8229
  },
7672
8230
  data
@@ -7705,14 +8263,24 @@ function registerPlanApprovalTools(server) {
7705
8263
  if (!projectId) {
7706
8264
  return {
7707
8265
  content: [
7708
- { type: "text", text: "No project_id provided and no default project found." }
8266
+ {
8267
+ type: "text",
8268
+ text: "No project_id provided and no default project found."
8269
+ }
7709
8270
  ],
7710
8271
  isError: true
7711
8272
  };
7712
8273
  }
7713
- const accessError = await assertProjectAccess(supabase, userId, projectId);
8274
+ const accessError = await assertProjectAccess(
8275
+ supabase,
8276
+ userId,
8277
+ projectId
8278
+ );
7714
8279
  if (accessError) {
7715
- return { content: [{ type: "text", text: accessError }], isError: true };
8280
+ return {
8281
+ content: [{ type: "text", text: accessError }],
8282
+ isError: true
8283
+ };
7716
8284
  }
7717
8285
  const rows = posts.map((post) => ({
7718
8286
  plan_id,
@@ -7741,7 +8309,12 @@ function registerPlanApprovalTools(server) {
7741
8309
  };
7742
8310
  if ((response_format || "text") === "json") {
7743
8311
  return {
7744
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(payload), null, 2) }],
8312
+ content: [
8313
+ {
8314
+ type: "text",
8315
+ text: JSON.stringify(asEnvelope15(payload), null, 2)
8316
+ }
8317
+ ],
7745
8318
  isError: false
7746
8319
  };
7747
8320
  }
@@ -7790,14 +8363,22 @@ function registerPlanApprovalTools(server) {
7790
8363
  };
7791
8364
  if ((response_format || "text") === "json") {
7792
8365
  return {
7793
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(payload), null, 2) }],
8366
+ content: [
8367
+ {
8368
+ type: "text",
8369
+ text: JSON.stringify(asEnvelope15(payload), null, 2)
8370
+ }
8371
+ ],
7794
8372
  isError: false
7795
8373
  };
7796
8374
  }
7797
8375
  if (!data || data.length === 0) {
7798
8376
  return {
7799
8377
  content: [
7800
- { type: "text", text: `No approval items found for plan ${plan_id}.` }
8378
+ {
8379
+ type: "text",
8380
+ text: `No approval items found for plan ${plan_id}.`
8381
+ }
7801
8382
  ],
7802
8383
  isError: false
7803
8384
  };
@@ -7810,7 +8391,10 @@ function registerPlanApprovalTools(server) {
7810
8391
  }
7811
8392
  lines.push("");
7812
8393
  lines.push(`Total: ${data.length}`);
7813
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
8394
+ return {
8395
+ content: [{ type: "text", text: lines.join("\n") }],
8396
+ isError: false
8397
+ };
7814
8398
  }
7815
8399
  );
7816
8400
  server.tool(
@@ -7829,7 +8413,10 @@ function registerPlanApprovalTools(server) {
7829
8413
  if (decision === "edited" && !edited_post) {
7830
8414
  return {
7831
8415
  content: [
7832
- { type: "text", text: 'edited_post is required when decision is "edited".' }
8416
+ {
8417
+ type: "text",
8418
+ text: 'edited_post is required when decision is "edited".'
8419
+ }
7833
8420
  ],
7834
8421
  isError: true
7835
8422
  };
@@ -7842,7 +8429,9 @@ function registerPlanApprovalTools(server) {
7842
8429
  if (decision === "edited") {
7843
8430
  updates.edited_post = edited_post;
7844
8431
  }
7845
- const { data, error } = await supabase.from("content_plan_approvals").update(updates).eq("id", approval_id).eq("user_id", userId).eq("status", "pending").select("id, plan_id, post_id, status, reason, decided_at, original_post, edited_post").maybeSingle();
8432
+ const { data, error } = await supabase.from("content_plan_approvals").update(updates).eq("id", approval_id).eq("user_id", userId).eq("status", "pending").select(
8433
+ "id, plan_id, post_id, status, reason, decided_at, original_post, edited_post"
8434
+ ).maybeSingle();
7846
8435
  if (error) {
7847
8436
  return {
7848
8437
  content: [
@@ -7867,7 +8456,12 @@ function registerPlanApprovalTools(server) {
7867
8456
  }
7868
8457
  if ((response_format || "text") === "json") {
7869
8458
  return {
7870
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(data), null, 2) }],
8459
+ content: [
8460
+ {
8461
+ type: "text",
8462
+ text: JSON.stringify(asEnvelope15(data), null, 2)
8463
+ }
8464
+ ],
7871
8465
  isError: false
7872
8466
  };
7873
8467
  }
@@ -7932,7 +8526,7 @@ var TOOL_CATALOG = [
7932
8526
  name: "check_status",
7933
8527
  description: "Check status of async content generation job",
7934
8528
  module: "content",
7935
- scope: "mcp:write"
8529
+ scope: "mcp:read"
7936
8530
  },
7937
8531
  {
7938
8532
  name: "create_storyboard",
@@ -8365,12 +8959,34 @@ function getJWKS(supabaseUrl) {
8365
8959
  }
8366
8960
  return jwks;
8367
8961
  }
8962
+ var apiKeyCache = /* @__PURE__ */ new Map();
8963
+ var API_KEY_CACHE_TTL_MS = 6e4;
8368
8964
  function createTokenVerifier(options) {
8369
8965
  const { supabaseUrl, supabaseAnonKey } = options;
8370
8966
  return {
8371
8967
  async verifyAccessToken(token) {
8372
8968
  if (token.startsWith("snk_")) {
8373
- return verifyApiKey(token, supabaseUrl, supabaseAnonKey);
8969
+ const cached = apiKeyCache.get(token);
8970
+ if (cached && cached.expiresAt > Date.now()) {
8971
+ return cached.authInfo;
8972
+ }
8973
+ if (cached) apiKeyCache.delete(token);
8974
+ const authInfo = await verifyApiKey(
8975
+ token,
8976
+ supabaseUrl,
8977
+ supabaseAnonKey
8978
+ );
8979
+ apiKeyCache.set(token, {
8980
+ authInfo,
8981
+ expiresAt: Date.now() + API_KEY_CACHE_TTL_MS
8982
+ });
8983
+ if (apiKeyCache.size > 100) {
8984
+ const now = Date.now();
8985
+ for (const [k, v] of apiKeyCache) {
8986
+ if (v.expiresAt <= now) apiKeyCache.delete(k);
8987
+ }
8988
+ }
8989
+ return authInfo;
8374
8990
  }
8375
8991
  return verifySupabaseJwt(token, supabaseUrl);
8376
8992
  }
@@ -8450,6 +9066,12 @@ if (!SUPABASE_URL2 || !SUPABASE_ANON_KEY) {
8450
9066
  console.error("[MCP HTTP] Missing SUPABASE_URL or SUPABASE_ANON_KEY");
8451
9067
  process.exit(1);
8452
9068
  }
9069
+ var SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? "";
9070
+ if (SUPABASE_SERVICE_ROLE_KEY && SUPABASE_SERVICE_ROLE_KEY.length < 100) {
9071
+ console.error(
9072
+ `[MCP HTTP] SUPABASE_SERVICE_ROLE_KEY looks invalid (${SUPABASE_SERVICE_ROLE_KEY.length} chars, expected 200+). Edge function calls may fail. Check your environment variables.`
9073
+ );
9074
+ }
8453
9075
  process.on("uncaughtException", (err) => {
8454
9076
  console.error(`[MCP HTTP] Uncaught exception: ${err.message}`);
8455
9077
  process.exit(1);
@@ -8490,14 +9112,63 @@ var cleanupInterval = setInterval(
8490
9112
  5 * 60 * 1e3
8491
9113
  );
8492
9114
  var app = express();
9115
+ app.disable("x-powered-by");
8493
9116
  app.use(express.json());
8494
9117
  app.set("trust proxy", 1);
9118
+ var ipBuckets = /* @__PURE__ */ new Map();
9119
+ var IP_RATE_MAX = 60;
9120
+ var IP_RATE_REFILL = 60 / 60;
9121
+ var IP_RATE_CLEANUP_INTERVAL = 10 * 60 * 1e3;
9122
+ setInterval(() => {
9123
+ const cutoff = Date.now() - 5 * 60 * 1e3;
9124
+ for (const [ip, bucket] of ipBuckets) {
9125
+ if (bucket.lastRefill < cutoff) ipBuckets.delete(ip);
9126
+ }
9127
+ }, IP_RATE_CLEANUP_INTERVAL).unref();
9128
+ app.use((req, res, next) => {
9129
+ if (req.path === "/health") return next();
9130
+ const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
9131
+ const now = Date.now();
9132
+ let bucket = ipBuckets.get(ip);
9133
+ if (!bucket) {
9134
+ bucket = { tokens: IP_RATE_MAX, lastRefill: now };
9135
+ ipBuckets.set(ip, bucket);
9136
+ }
9137
+ const elapsed = (now - bucket.lastRefill) / 1e3;
9138
+ bucket.tokens = Math.min(
9139
+ IP_RATE_MAX,
9140
+ bucket.tokens + elapsed * IP_RATE_REFILL
9141
+ );
9142
+ bucket.lastRefill = now;
9143
+ if (bucket.tokens < 1) {
9144
+ const retryAfter = Math.ceil((1 - bucket.tokens) / IP_RATE_REFILL);
9145
+ res.setHeader("Retry-After", String(retryAfter));
9146
+ res.status(429).json({
9147
+ error: "rate_limited",
9148
+ error_description: "Too many requests from this IP. Please slow down.",
9149
+ retry_after: retryAfter
9150
+ });
9151
+ return;
9152
+ }
9153
+ bucket.tokens -= 1;
9154
+ next();
9155
+ });
9156
+ app.use((_req, res, next) => {
9157
+ res.setHeader(
9158
+ "Strict-Transport-Security",
9159
+ "max-age=63072000; includeSubDomains"
9160
+ );
9161
+ res.setHeader("X-Content-Type-Options", "nosniff");
9162
+ next();
9163
+ });
8495
9164
  app.use((_req, res, next) => {
8496
9165
  res.setHeader("Access-Control-Allow-Origin", "*");
8497
9166
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
8498
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id");
9167
+ res.setHeader(
9168
+ "Access-Control-Allow-Headers",
9169
+ "Content-Type, Authorization, Mcp-Session-Id"
9170
+ );
8499
9171
  res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
8500
- res.setHeader("Vary", "Origin");
8501
9172
  if (_req.method === "OPTIONS") {
8502
9173
  res.status(204).end();
8503
9174
  return;
@@ -8532,9 +9203,16 @@ async function authenticateRequest(req, res, next) {
8532
9203
  const token = authHeader.slice(7);
8533
9204
  try {
8534
9205
  const authInfo = await tokenVerifier.verifyAccessToken(token);
9206
+ let scopes = authInfo.scopes;
9207
+ const scopeParam = req.query.scope;
9208
+ if (scopeParam) {
9209
+ const requestedScopes = scopeParam.split(",").map((s) => s.trim()).filter(Boolean);
9210
+ scopes = requestedScopes.filter((s) => authInfo.scopes.includes(s));
9211
+ if (scopes.length === 0) scopes = authInfo.scopes;
9212
+ }
8535
9213
  req.auth = {
8536
9214
  userId: authInfo.extra?.userId ?? authInfo.clientId,
8537
- scopes: authInfo.scopes,
9215
+ scopes,
8538
9216
  clientId: authInfo.clientId,
8539
9217
  token: authInfo.token
8540
9218
  };
@@ -8550,97 +9228,115 @@ async function authenticateRequest(req, res, next) {
8550
9228
  app.get("/health", (_req, res) => {
8551
9229
  res.json({ status: "ok", version: MCP_VERSION });
8552
9230
  });
8553
- app.get("/health/details", authenticateRequest, (_req, res) => {
8554
- res.json({
8555
- status: "ok",
8556
- version: MCP_VERSION,
8557
- transport: "streamable-http",
8558
- sessions: sessions.size,
8559
- sessionCap: MAX_SESSIONS,
8560
- uptime: Math.floor(process.uptime()),
8561
- memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
8562
- env: NODE_ENV
8563
- });
8564
- });
8565
- app.post("/mcp", authenticateRequest, async (req, res) => {
8566
- const auth = req.auth;
8567
- const existingSessionId = req.headers["mcp-session-id"];
8568
- const rl = checkRateLimit("read", auth.userId);
8569
- if (!rl.allowed) {
8570
- res.setHeader("Retry-After", String(rl.retryAfter));
8571
- res.status(429).json({
8572
- error: "rate_limited",
8573
- error_description: "Too many requests. Please slow down.",
8574
- retry_after: rl.retryAfter
9231
+ app.get(
9232
+ "/health/details",
9233
+ authenticateRequest,
9234
+ (_req, res) => {
9235
+ res.json({
9236
+ status: "ok",
9237
+ version: MCP_VERSION,
9238
+ transport: "streamable-http",
9239
+ sessions: sessions.size,
9240
+ sessionCap: MAX_SESSIONS,
9241
+ uptime: Math.floor(process.uptime()),
9242
+ memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
9243
+ env: NODE_ENV
8575
9244
  });
8576
- return;
8577
9245
  }
8578
- try {
8579
- if (existingSessionId && sessions.has(existingSessionId)) {
8580
- const entry = sessions.get(existingSessionId);
8581
- if (entry.userId !== auth.userId) {
8582
- res.status(403).json({
8583
- error: "forbidden",
8584
- error_description: "Session belongs to another user"
8585
- });
8586
- return;
8587
- }
8588
- entry.lastActivity = Date.now();
8589
- await requestContext.run(
8590
- { userId: auth.userId, scopes: auth.scopes, creditsUsed: 0, assetsGenerated: 0 },
8591
- () => entry.transport.handleRequest(req, res, req.body)
8592
- );
8593
- return;
8594
- }
8595
- if (sessions.size >= MAX_SESSIONS) {
8596
- res.status(429).json({
8597
- error: "too_many_sessions",
8598
- error_description: `Server session limit reached (${MAX_SESSIONS}). Try again later.`
8599
- });
8600
- return;
8601
- }
8602
- if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER) {
9246
+ );
9247
+ app.post(
9248
+ "/mcp",
9249
+ authenticateRequest,
9250
+ async (req, res) => {
9251
+ const auth = req.auth;
9252
+ const existingSessionId = req.headers["mcp-session-id"];
9253
+ const rl = checkRateLimit("read", auth.userId);
9254
+ if (!rl.allowed) {
9255
+ res.setHeader("Retry-After", String(rl.retryAfter));
8603
9256
  res.status(429).json({
8604
- error: "too_many_sessions",
8605
- error_description: `Per-user session limit reached (${MAX_SESSIONS_PER_USER}). Close existing sessions or wait for timeout.`
9257
+ error: "rate_limited",
9258
+ error_description: "Too many requests. Please slow down.",
9259
+ retry_after: rl.retryAfter
8606
9260
  });
8607
9261
  return;
8608
9262
  }
8609
- const server = new McpServer({
8610
- name: "socialneuron",
8611
- version: MCP_VERSION
8612
- });
8613
- applyScopeEnforcement(server, () => getRequestScopes() ?? auth.scopes);
8614
- registerAllTools(server, { skipScreenshots: true });
8615
- const transport = new StreamableHTTPServerTransport({
8616
- sessionIdGenerator: () => randomUUID3(),
8617
- onsessioninitialized: (sessionId) => {
8618
- sessions.set(sessionId, {
8619
- transport,
8620
- server,
8621
- lastActivity: Date.now(),
8622
- userId: auth.userId
9263
+ try {
9264
+ if (existingSessionId && sessions.has(existingSessionId)) {
9265
+ const entry = sessions.get(existingSessionId);
9266
+ if (entry.userId !== auth.userId) {
9267
+ res.status(403).json({
9268
+ error: "forbidden",
9269
+ error_description: "Session belongs to another user"
9270
+ });
9271
+ return;
9272
+ }
9273
+ entry.lastActivity = Date.now();
9274
+ await requestContext.run(
9275
+ {
9276
+ userId: auth.userId,
9277
+ scopes: auth.scopes,
9278
+ creditsUsed: 0,
9279
+ assetsGenerated: 0
9280
+ },
9281
+ () => entry.transport.handleRequest(req, res, req.body)
9282
+ );
9283
+ return;
9284
+ }
9285
+ if (sessions.size >= MAX_SESSIONS) {
9286
+ res.status(429).json({
9287
+ error: "too_many_sessions",
9288
+ error_description: `Server session limit reached (${MAX_SESSIONS}). Try again later.`
8623
9289
  });
9290
+ return;
8624
9291
  }
8625
- });
8626
- transport.onclose = () => {
8627
- if (transport.sessionId) {
8628
- sessions.delete(transport.sessionId);
9292
+ if (countUserSessions(auth.userId) >= MAX_SESSIONS_PER_USER) {
9293
+ res.status(429).json({
9294
+ error: "too_many_sessions",
9295
+ error_description: `Per-user session limit reached (${MAX_SESSIONS_PER_USER}). Close existing sessions or wait for timeout.`
9296
+ });
9297
+ return;
9298
+ }
9299
+ const server = new McpServer({
9300
+ name: "socialneuron",
9301
+ version: MCP_VERSION
9302
+ });
9303
+ applyScopeEnforcement(server, () => getRequestScopes() ?? auth.scopes);
9304
+ registerAllTools(server, { skipScreenshots: true });
9305
+ const transport = new StreamableHTTPServerTransport({
9306
+ sessionIdGenerator: () => randomUUID3(),
9307
+ onsessioninitialized: (sessionId) => {
9308
+ sessions.set(sessionId, {
9309
+ transport,
9310
+ server,
9311
+ lastActivity: Date.now(),
9312
+ userId: auth.userId
9313
+ });
9314
+ }
9315
+ });
9316
+ transport.onclose = () => {
9317
+ if (transport.sessionId) {
9318
+ sessions.delete(transport.sessionId);
9319
+ }
9320
+ };
9321
+ await server.connect(transport);
9322
+ await requestContext.run(
9323
+ {
9324
+ userId: auth.userId,
9325
+ scopes: auth.scopes,
9326
+ creditsUsed: 0,
9327
+ assetsGenerated: 0
9328
+ },
9329
+ () => transport.handleRequest(req, res, req.body)
9330
+ );
9331
+ } catch (err) {
9332
+ const message = err instanceof Error ? err.message : "Internal server error";
9333
+ console.error(`[MCP HTTP] POST /mcp error: ${message}`);
9334
+ if (!res.headersSent) {
9335
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message } });
8629
9336
  }
8630
- };
8631
- await server.connect(transport);
8632
- await requestContext.run(
8633
- { userId: auth.userId, scopes: auth.scopes, creditsUsed: 0, assetsGenerated: 0 },
8634
- () => transport.handleRequest(req, res, req.body)
8635
- );
8636
- } catch (err) {
8637
- const message = err instanceof Error ? err.message : "Internal server error";
8638
- console.error(`[MCP HTTP] POST /mcp error: ${message}`);
8639
- if (!res.headersSent) {
8640
- res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message } });
8641
9337
  }
8642
9338
  }
8643
- });
9339
+ );
8644
9340
  app.get("/mcp", authenticateRequest, async (req, res) => {
8645
9341
  const sessionId = req.headers["mcp-session-id"];
8646
9342
  if (!sessionId || !sessions.has(sessionId)) {
@@ -8656,28 +9352,39 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
8656
9352
  res.setHeader("X-Accel-Buffering", "no");
8657
9353
  res.setHeader("Cache-Control", "no-cache");
8658
9354
  await requestContext.run(
8659
- { userId: req.auth.userId, scopes: req.auth.scopes, creditsUsed: 0, assetsGenerated: 0 },
9355
+ {
9356
+ userId: req.auth.userId,
9357
+ scopes: req.auth.scopes,
9358
+ creditsUsed: 0,
9359
+ assetsGenerated: 0
9360
+ },
8660
9361
  () => entry.transport.handleRequest(req, res)
8661
9362
  );
8662
9363
  });
8663
- app.delete("/mcp", authenticateRequest, async (req, res) => {
8664
- const sessionId = req.headers["mcp-session-id"];
8665
- if (!sessionId || !sessions.has(sessionId)) {
8666
- res.status(400).json({ error: "Invalid or missing session ID" });
8667
- return;
8668
- }
8669
- const entry = sessions.get(sessionId);
8670
- if (entry.userId !== req.auth.userId) {
8671
- res.status(403).json({ error: "Session belongs to another user" });
8672
- return;
9364
+ app.delete(
9365
+ "/mcp",
9366
+ authenticateRequest,
9367
+ async (req, res) => {
9368
+ const sessionId = req.headers["mcp-session-id"];
9369
+ if (!sessionId || !sessions.has(sessionId)) {
9370
+ res.status(400).json({ error: "Invalid or missing session ID" });
9371
+ return;
9372
+ }
9373
+ const entry = sessions.get(sessionId);
9374
+ if (entry.userId !== req.auth.userId) {
9375
+ res.status(403).json({ error: "Session belongs to another user" });
9376
+ return;
9377
+ }
9378
+ await entry.transport.close();
9379
+ await entry.server.close();
9380
+ sessions.delete(sessionId);
9381
+ res.status(200).json({ status: "session_closed" });
8673
9382
  }
8674
- await entry.transport.close();
8675
- await entry.server.close();
8676
- sessions.delete(sessionId);
8677
- res.status(200).json({ status: "session_closed" });
8678
- });
9383
+ );
8679
9384
  var httpServer = app.listen(PORT, "0.0.0.0", () => {
8680
- console.log(`[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`);
9385
+ console.log(
9386
+ `[MCP HTTP] Social Neuron MCP Server listening on 0.0.0.0:${PORT}`
9387
+ );
8681
9388
  console.log(`[MCP HTTP] Health: http://localhost:${PORT}/health`);
8682
9389
  console.log(`[MCP HTTP] MCP endpoint: ${MCP_SERVER_URL}`);
8683
9390
  console.log(`[MCP HTTP] Environment: ${NODE_ENV}`);