@socialneuron/mcp-server 1.4.0 → 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.
Files changed (3) hide show
  1. package/dist/http.js +1223 -524
  2. package/dist/index.js +1063 -426
  3. package/package.json +1 -1
package/dist/http.js CHANGED
@@ -587,7 +587,7 @@ var TOOL_SCOPES = {
587
587
  adapt_content: "mcp:write",
588
588
  generate_video: "mcp:write",
589
589
  generate_image: "mcp:write",
590
- check_status: "mcp:write",
590
+ check_status: "mcp:read",
591
591
  render_demo_video: "mcp:write",
592
592
  save_brand_profile: "mcp:write",
593
593
  update_platform_voice: "mcp:write",
@@ -775,7 +775,76 @@ async function callEdgeFunction(functionName, body, options) {
775
775
  }
776
776
  }
777
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
+
778
846
  // src/tools/ideation.ts
847
+ init_supabase();
779
848
  function registerIdeationTools(server) {
780
849
  server.tool(
781
850
  "generate_content",
@@ -796,7 +865,9 @@ function registerIdeationTools(server) {
796
865
  "facebook",
797
866
  "threads",
798
867
  "bluesky"
799
- ]).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
+ ),
800
871
  brand_voice: z.string().max(500).optional().describe(
801
872
  'Brand voice guidelines to follow (e.g. "professional and empathetic", "playful and Gen-Z"). Leave blank to use a neutral tone.'
802
873
  ),
@@ -807,7 +878,30 @@ function registerIdeationTools(server) {
807
878
  "Project ID to auto-load brand profile and performance context for prompt enrichment."
808
879
  )
809
880
  },
810
- 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
+ }
811
905
  let enrichedPrompt = prompt;
812
906
  if (platform2) {
813
907
  enrichedPrompt += `
@@ -939,8 +1033,12 @@ Content Type: ${content_type}`;
939
1033
  category: z.string().optional().describe(
940
1034
  "Category filter (for YouTube). Examples: general, entertainment, education, tech, music, gaming, sports, news."
941
1035
  ),
942
- niche: z.string().optional().describe("Niche keyword filter. Only return trends matching these keywords."),
943
- 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
+ ),
944
1042
  force_refresh: z.boolean().optional().describe("Skip the server-side cache and fetch fresh data.")
945
1043
  },
946
1044
  async ({ source, category, niche, url, force_refresh }) => {
@@ -1015,7 +1113,9 @@ Content Type: ${content_type}`;
1015
1113
  "adapt_content",
1016
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.",
1017
1115
  {
1018
- 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
+ ),
1019
1119
  source_platform: z.enum([
1020
1120
  "youtube",
1021
1121
  "tiktok",
@@ -1025,7 +1125,9 @@ Content Type: ${content_type}`;
1025
1125
  "facebook",
1026
1126
  "threads",
1027
1127
  "bluesky"
1028
- ]).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
+ ),
1029
1131
  target_platform: z.enum([
1030
1132
  "youtube",
1031
1133
  "tiktok",
@@ -1039,9 +1141,33 @@ Content Type: ${content_type}`;
1039
1141
  brand_voice: z.string().max(500).optional().describe(
1040
1142
  'Brand voice guidelines to maintain during adaptation (e.g. "professional", "playful").'
1041
1143
  ),
1042
- 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
+ )
1043
1147
  },
1044
- 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
+ }
1045
1171
  const platformGuidelines = {
1046
1172
  twitter: "Max 280 characters. Concise, punchy. 1-3 hashtags max. Thread-friendly.",
1047
1173
  threads: "Max 500 characters. Conversational, opinion-driven. Minimal hashtags.",
@@ -1123,76 +1249,6 @@ ${content}`,
1123
1249
 
1124
1250
  // src/tools/content.ts
1125
1251
  import { z as z2 } from "zod";
1126
-
1127
- // src/lib/rate-limit.ts
1128
- var CATEGORY_CONFIGS = {
1129
- posting: { maxTokens: 30, refillRate: 30 / 60 },
1130
- // 30 req/min
1131
- screenshot: { maxTokens: 10, refillRate: 10 / 60 },
1132
- // 10 req/min
1133
- read: { maxTokens: 60, refillRate: 60 / 60 }
1134
- // 60 req/min
1135
- };
1136
- var RateLimiter = class {
1137
- tokens;
1138
- lastRefill;
1139
- maxTokens;
1140
- refillRate;
1141
- // tokens per second
1142
- constructor(config) {
1143
- this.maxTokens = config.maxTokens;
1144
- this.refillRate = config.refillRate;
1145
- this.tokens = config.maxTokens;
1146
- this.lastRefill = Date.now();
1147
- }
1148
- /**
1149
- * Try to consume one token. Returns true if the request is allowed,
1150
- * false if rate-limited.
1151
- */
1152
- consume() {
1153
- this.refill();
1154
- if (this.tokens >= 1) {
1155
- this.tokens -= 1;
1156
- return true;
1157
- }
1158
- return false;
1159
- }
1160
- /**
1161
- * Seconds until at least one token is available.
1162
- */
1163
- retryAfter() {
1164
- this.refill();
1165
- if (this.tokens >= 1) return 0;
1166
- return Math.ceil((1 - this.tokens) / this.refillRate);
1167
- }
1168
- refill() {
1169
- const now = Date.now();
1170
- const elapsed = (now - this.lastRefill) / 1e3;
1171
- this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
1172
- this.lastRefill = now;
1173
- }
1174
- };
1175
- var limiters = /* @__PURE__ */ new Map();
1176
- function getRateLimiter(category) {
1177
- let limiter = limiters.get(category);
1178
- if (!limiter) {
1179
- const config = CATEGORY_CONFIGS[category] ?? CATEGORY_CONFIGS.read;
1180
- limiter = new RateLimiter(config);
1181
- limiters.set(category, limiter);
1182
- }
1183
- return limiter;
1184
- }
1185
- function checkRateLimit(category, key) {
1186
- const bucketKey = key ? `${category}:${key}` : category;
1187
- const limiter = getRateLimiter(bucketKey);
1188
- const allowed = limiter.consume();
1189
- return {
1190
- allowed,
1191
- retryAfter: allowed ? 0 : limiter.retryAfter()
1192
- };
1193
- }
1194
-
1195
- // src/tools/content.ts
1196
1252
  init_supabase();
1197
1253
 
1198
1254
  // src/lib/sanitize-error.ts
@@ -1246,8 +1302,19 @@ function sanitizeDbError(error) {
1246
1302
 
1247
1303
  // src/tools/content.ts
1248
1304
  init_request_context();
1249
- var MAX_CREDITS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
1250
- 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
+ );
1251
1318
  var _globalCreditsUsed = 0;
1252
1319
  var _globalAssetsGenerated = 0;
1253
1320
  function getCreditsUsed() {
@@ -1289,7 +1356,7 @@ function getCurrentBudgetStatus() {
1289
1356
  function asEnvelope(data) {
1290
1357
  return {
1291
1358
  _meta: {
1292
- version: "0.2.0",
1359
+ version: MCP_VERSION,
1293
1360
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1294
1361
  },
1295
1362
  data
@@ -1371,8 +1438,12 @@ function registerContentTools(server) {
1371
1438
  enable_audio: z2.boolean().optional().describe(
1372
1439
  "Enable native audio generation. Kling 2.6: doubles cost. Kling 3.0: 50% more (std 30/sec, pro 40/sec). 5+ languages."
1373
1440
  ),
1374
- image_url: z2.string().optional().describe("Start frame image URL for image-to-video (Kling 3.0 frame control)."),
1375
- 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
+ ),
1376
1447
  response_format: z2.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
1377
1448
  },
1378
1449
  async ({
@@ -1810,10 +1881,13 @@ function registerContentTools(server) {
1810
1881
  };
1811
1882
  }
1812
1883
  if (job.external_id && (job.status === "pending" || job.status === "processing")) {
1813
- const { data: liveStatus } = await callEdgeFunction("kie-task-status", {
1814
- taskId: job.external_id,
1815
- model: job.model
1816
- });
1884
+ const { data: liveStatus } = await callEdgeFunction(
1885
+ "kie-task-status",
1886
+ {
1887
+ taskId: job.external_id,
1888
+ model: job.model
1889
+ }
1890
+ );
1817
1891
  if (liveStatus) {
1818
1892
  const lines2 = [
1819
1893
  `Job: ${job.id}`,
@@ -1887,7 +1961,12 @@ function registerContentTools(server) {
1887
1961
  });
1888
1962
  if (format === "json") {
1889
1963
  return {
1890
- 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
+ ]
1891
1970
  };
1892
1971
  }
1893
1972
  return {
@@ -1905,7 +1984,15 @@ function registerContentTools(server) {
1905
1984
  brand_context: z2.string().max(3e3).optional().describe(
1906
1985
  "Brand context JSON from extract_brand. Include colors, voice tone, visual style keywords for consistent branding across frames."
1907
1986
  ),
1908
- 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
+ ),
1909
1996
  target_duration: z2.number().min(5).max(120).optional().describe(
1910
1997
  "Target total duration in seconds. Defaults to 30s for short-form, 60s for YouTube."
1911
1998
  ),
@@ -1913,7 +2000,9 @@ function registerContentTools(server) {
1913
2000
  style: z2.string().optional().describe(
1914
2001
  'Visual style direction (e.g., "cinematic", "anime", "documentary", "motion graphics").'
1915
2002
  ),
1916
- 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
+ )
1917
2006
  },
1918
2007
  async ({
1919
2008
  concept,
@@ -1926,7 +2015,11 @@ function registerContentTools(server) {
1926
2015
  }) => {
1927
2016
  const format = response_format ?? "json";
1928
2017
  const startedAt = Date.now();
1929
- const isShortForm = ["tiktok", "instagram-reels", "youtube-shorts"].includes(platform2);
2018
+ const isShortForm = [
2019
+ "tiktok",
2020
+ "instagram-reels",
2021
+ "youtube-shorts"
2022
+ ].includes(platform2);
1930
2023
  const duration = target_duration ?? (isShortForm ? 30 : 60);
1931
2024
  const scenes = num_scenes ?? (isShortForm ? 7 : 10);
1932
2025
  const aspectRatio = isShortForm ? "9:16" : "16:9";
@@ -2019,7 +2112,12 @@ Return ONLY valid JSON in this exact format:
2019
2112
  details: { error }
2020
2113
  });
2021
2114
  return {
2022
- content: [{ type: "text", text: `Storyboard generation failed: ${error}` }],
2115
+ content: [
2116
+ {
2117
+ type: "text",
2118
+ text: `Storyboard generation failed: ${error}`
2119
+ }
2120
+ ],
2023
2121
  isError: true
2024
2122
  };
2025
2123
  }
@@ -2035,7 +2133,12 @@ Return ONLY valid JSON in this exact format:
2035
2133
  try {
2036
2134
  const parsed = JSON.parse(rawContent);
2037
2135
  return {
2038
- 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
+ ]
2039
2142
  };
2040
2143
  } catch {
2041
2144
  return {
@@ -2099,7 +2202,10 @@ Return ONLY valid JSON in this exact format:
2099
2202
  isError: true
2100
2203
  };
2101
2204
  }
2102
- const rateLimit = checkRateLimit("posting", `generate_voiceover:${userId}`);
2205
+ const rateLimit = checkRateLimit(
2206
+ "posting",
2207
+ `generate_voiceover:${userId}`
2208
+ );
2103
2209
  if (!rateLimit.allowed) {
2104
2210
  await logMcpToolInvocation({
2105
2211
  toolName: "generate_voiceover",
@@ -2134,7 +2240,12 @@ Return ONLY valid JSON in this exact format:
2134
2240
  details: { error }
2135
2241
  });
2136
2242
  return {
2137
- content: [{ type: "text", text: `Voiceover generation failed: ${error}` }],
2243
+ content: [
2244
+ {
2245
+ type: "text",
2246
+ text: `Voiceover generation failed: ${error}`
2247
+ }
2248
+ ],
2138
2249
  isError: true
2139
2250
  };
2140
2251
  }
@@ -2147,7 +2258,10 @@ Return ONLY valid JSON in this exact format:
2147
2258
  });
2148
2259
  return {
2149
2260
  content: [
2150
- { type: "text", text: "Voiceover generation failed: no audio URL returned." }
2261
+ {
2262
+ type: "text",
2263
+ text: "Voiceover generation failed: no audio URL returned."
2264
+ }
2151
2265
  ],
2152
2266
  isError: true
2153
2267
  };
@@ -2219,7 +2333,9 @@ Return ONLY valid JSON in this exact format:
2219
2333
  "Carousel template. hormozi-authority: bold typography, one idea per slide, dark backgrounds. educational-series: numbered tips. Default: hormozi-authority."
2220
2334
  ),
2221
2335
  slide_count: z2.number().min(3).max(10).optional().describe("Number of slides (3-10). Default: 7."),
2222
- 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
+ ),
2223
2339
  style: z2.enum(["minimal", "bold", "professional", "playful", "hormozi"]).optional().describe(
2224
2340
  "Visual style. hormozi: black bg, bold white text, gold accents. Default: hormozi (when using hormozi-authority template)."
2225
2341
  ),
@@ -2256,7 +2372,10 @@ Return ONLY valid JSON in this exact format:
2256
2372
  };
2257
2373
  }
2258
2374
  const userId = await getDefaultUserId();
2259
- const rateLimit = checkRateLimit("posting", `generate_carousel:${userId}`);
2375
+ const rateLimit = checkRateLimit(
2376
+ "posting",
2377
+ `generate_carousel:${userId}`
2378
+ );
2260
2379
  if (!rateLimit.allowed) {
2261
2380
  await logMcpToolInvocation({
2262
2381
  toolName: "generate_carousel",
@@ -2294,7 +2413,12 @@ Return ONLY valid JSON in this exact format:
2294
2413
  details: { error }
2295
2414
  });
2296
2415
  return {
2297
- content: [{ type: "text", text: `Carousel generation failed: ${error}` }],
2416
+ content: [
2417
+ {
2418
+ type: "text",
2419
+ text: `Carousel generation failed: ${error}`
2420
+ }
2421
+ ],
2298
2422
  isError: true
2299
2423
  };
2300
2424
  }
@@ -2306,7 +2430,12 @@ Return ONLY valid JSON in this exact format:
2306
2430
  details: { error: "No carousel data returned" }
2307
2431
  });
2308
2432
  return {
2309
- content: [{ type: "text", text: "Carousel generation returned no data." }],
2433
+ content: [
2434
+ {
2435
+ type: "text",
2436
+ text: "Carousel generation returned no data."
2437
+ }
2438
+ ],
2310
2439
  isError: true
2311
2440
  };
2312
2441
  }
@@ -2507,7 +2636,7 @@ var PLATFORM_CASE_MAP = {
2507
2636
  function asEnvelope2(data) {
2508
2637
  return {
2509
2638
  _meta: {
2510
- version: "0.2.0",
2639
+ version: MCP_VERSION,
2511
2640
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2512
2641
  },
2513
2642
  data
@@ -2539,15 +2668,21 @@ function registerDistributionTools(server) {
2539
2668
  "threads",
2540
2669
  "bluesky"
2541
2670
  ])
2542
- ).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
+ ),
2543
2674
  title: z3.string().optional().describe("Post title (used by YouTube and some other platforms)."),
2544
- 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
+ ),
2545
2678
  schedule_at: z3.string().optional().describe(
2546
2679
  'ISO 8601 datetime for scheduled posting (e.g. "2026-03-15T14:00:00Z"). Omit for immediate posting.'
2547
2680
  ),
2548
2681
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
2549
2682
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
2550
- 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
+ )
2551
2686
  },
2552
2687
  async ({
2553
2688
  media_url,
@@ -2566,7 +2701,12 @@ function registerDistributionTools(server) {
2566
2701
  const startedAt = Date.now();
2567
2702
  if ((!caption || caption.trim().length === 0) && (!title || title.trim().length === 0)) {
2568
2703
  return {
2569
- 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
+ ],
2570
2710
  isError: true
2571
2711
  };
2572
2712
  }
@@ -2589,7 +2729,9 @@ function registerDistributionTools(server) {
2589
2729
  isError: true
2590
2730
  };
2591
2731
  }
2592
- 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
+ );
2593
2735
  let finalCaption = caption;
2594
2736
  if (attribution && finalCaption) {
2595
2737
  finalCaption = `${finalCaption}
@@ -2653,7 +2795,9 @@ Created with Social Neuron`;
2653
2795
  ];
2654
2796
  for (const [platform2, result] of Object.entries(data.results)) {
2655
2797
  if (result.success) {
2656
- lines.push(` ${platform2}: OK (jobId=${result.jobId}, postId=${result.postId})`);
2798
+ lines.push(
2799
+ ` ${platform2}: OK (jobId=${result.jobId}, postId=${result.postId})`
2800
+ );
2657
2801
  } else {
2658
2802
  lines.push(` ${platform2}: FAILED - ${result.error}`);
2659
2803
  }
@@ -2670,7 +2814,12 @@ Created with Social Neuron`;
2670
2814
  });
2671
2815
  if (format === "json") {
2672
2816
  return {
2673
- 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
+ ],
2674
2823
  isError: !data.success
2675
2824
  };
2676
2825
  }
@@ -2726,12 +2875,17 @@ Created with Social Neuron`;
2726
2875
  for (const account of accounts) {
2727
2876
  const name = account.username || "(unnamed)";
2728
2877
  const platformLower = account.platform.toLowerCase();
2729
- 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
+ );
2730
2881
  }
2731
2882
  if (format === "json") {
2732
2883
  return {
2733
2884
  content: [
2734
- { type: "text", text: JSON.stringify(asEnvelope2({ accounts }), null, 2) }
2885
+ {
2886
+ type: "text",
2887
+ text: JSON.stringify(asEnvelope2({ accounts }), null, 2)
2888
+ }
2735
2889
  ]
2736
2890
  };
2737
2891
  }
@@ -2793,7 +2947,10 @@ Created with Social Neuron`;
2793
2947
  if (format === "json") {
2794
2948
  return {
2795
2949
  content: [
2796
- { type: "text", text: JSON.stringify(asEnvelope2({ posts: [] }), null, 2) }
2950
+ {
2951
+ type: "text",
2952
+ text: JSON.stringify(asEnvelope2({ posts: [] }), null, 2)
2953
+ }
2797
2954
  ]
2798
2955
  };
2799
2956
  }
@@ -2810,7 +2967,10 @@ Created with Social Neuron`;
2810
2967
  if (format === "json") {
2811
2968
  return {
2812
2969
  content: [
2813
- { type: "text", text: JSON.stringify(asEnvelope2({ posts }), null, 2) }
2970
+ {
2971
+ type: "text",
2972
+ text: JSON.stringify(asEnvelope2({ posts }), null, 2)
2973
+ }
2814
2974
  ]
2815
2975
  };
2816
2976
  }
@@ -2871,7 +3031,13 @@ Created with Social Neuron`;
2871
3031
  min_gap_hours: z3.number().min(1).max(24).default(4).describe("Minimum gap between posts on same platform"),
2872
3032
  response_format: z3.enum(["text", "json"]).default("text")
2873
3033
  },
2874
- 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
+ }) => {
2875
3041
  const startedAt = Date.now();
2876
3042
  try {
2877
3043
  const userId = await getDefaultUserId();
@@ -2882,7 +3048,9 @@ Created with Social Neuron`;
2882
3048
  const gapMs = min_gap_hours * 60 * 60 * 1e3;
2883
3049
  const candidates = [];
2884
3050
  for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
2885
- 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
+ );
2886
3054
  const dayOfWeek = date.getUTCDay();
2887
3055
  for (const platform2 of platforms) {
2888
3056
  const hours = PREFERRED_HOURS[platform2] ?? [12, 16];
@@ -2891,8 +3059,11 @@ Created with Social Neuron`;
2891
3059
  slotDate.setUTCHours(hours[hourIdx], 0, 0, 0);
2892
3060
  if (slotDate <= startDate) continue;
2893
3061
  const hasConflict = (existingPosts ?? []).some((post) => {
2894
- if (String(post.platform).toLowerCase() !== platform2) return false;
2895
- 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();
2896
3067
  return Math.abs(postTime - slotDate.getTime()) < gapMs;
2897
3068
  });
2898
3069
  let engagementScore = hours.length - hourIdx;
@@ -2937,15 +3108,22 @@ Created with Social Neuron`;
2937
3108
  };
2938
3109
  }
2939
3110
  const lines = [];
2940
- lines.push(`Found ${slots.length} optimal slots (${conflictsAvoided} conflicts avoided):`);
3111
+ lines.push(
3112
+ `Found ${slots.length} optimal slots (${conflictsAvoided} conflicts avoided):`
3113
+ );
2941
3114
  lines.push("");
2942
3115
  lines.push("Datetime (UTC) | Platform | Score");
2943
3116
  lines.push("-------------------------+------------+------");
2944
3117
  for (const s of slots) {
2945
3118
  const dt = s.datetime.replace("T", " ").slice(0, 19);
2946
- 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
+ );
2947
3122
  }
2948
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
3123
+ return {
3124
+ content: [{ type: "text", text: lines.join("\n") }],
3125
+ isError: false
3126
+ };
2949
3127
  } catch (err) {
2950
3128
  const durationMs = Date.now() - startedAt;
2951
3129
  const message = err instanceof Error ? err.message : String(err);
@@ -2956,7 +3134,9 @@ Created with Social Neuron`;
2956
3134
  details: { error: message }
2957
3135
  });
2958
3136
  return {
2959
- content: [{ type: "text", text: `Failed to find slots: ${message}` }],
3137
+ content: [
3138
+ { type: "text", text: `Failed to find slots: ${message}` }
3139
+ ],
2960
3140
  isError: true
2961
3141
  };
2962
3142
  }
@@ -2983,8 +3163,12 @@ Created with Social Neuron`;
2983
3163
  auto_slot: z3.boolean().default(true).describe("Auto-assign time slots for posts without schedule_at"),
2984
3164
  dry_run: z3.boolean().default(false).describe("Preview without actually scheduling"),
2985
3165
  response_format: z3.enum(["text", "json"]).default("text"),
2986
- enforce_quality: z3.boolean().default(true).describe("When true, block scheduling for posts that fail quality checks."),
2987
- 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
+ ),
2988
3172
  batch_size: z3.number().int().min(1).max(10).default(4).describe("Concurrent schedule calls per platform batch."),
2989
3173
  idempotency_seed: z3.string().max(128).optional().describe("Optional stable seed used when building idempotency keys.")
2990
3174
  },
@@ -3023,17 +3207,25 @@ Created with Social Neuron`;
3023
3207
  if (!stored?.plan_payload) {
3024
3208
  return {
3025
3209
  content: [
3026
- { 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
+ }
3027
3214
  ],
3028
3215
  isError: true
3029
3216
  };
3030
3217
  }
3031
3218
  const payload = stored.plan_payload;
3032
- 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;
3033
3222
  if (!postsFromPayload) {
3034
3223
  return {
3035
3224
  content: [
3036
- { 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
+ }
3037
3229
  ],
3038
3230
  isError: true
3039
3231
  };
@@ -3125,7 +3317,10 @@ Created with Social Neuron`;
3125
3317
  approvalSummary = {
3126
3318
  total: approvals.length,
3127
3319
  eligible: approvedPosts.length,
3128
- skipped: Math.max(0, workingPlan.posts.length - approvedPosts.length)
3320
+ skipped: Math.max(
3321
+ 0,
3322
+ workingPlan.posts.length - approvedPosts.length
3323
+ )
3129
3324
  };
3130
3325
  workingPlan = {
3131
3326
  ...workingPlan,
@@ -3159,9 +3354,14 @@ Created with Social Neuron`;
3159
3354
  try {
3160
3355
  const { data: settingsData } = await supabase.from("system_settings").select("value").eq("key", "content_safety").maybeSingle();
3161
3356
  if (settingsData?.value?.quality_threshold !== void 0) {
3162
- const parsedThreshold = Number(settingsData.value.quality_threshold);
3357
+ const parsedThreshold = Number(
3358
+ settingsData.value.quality_threshold
3359
+ );
3163
3360
  if (Number.isFinite(parsedThreshold)) {
3164
- effectiveQualityThreshold = Math.max(0, Math.min(35, Math.trunc(parsedThreshold)));
3361
+ effectiveQualityThreshold = Math.max(
3362
+ 0,
3363
+ Math.min(35, Math.trunc(parsedThreshold))
3364
+ );
3165
3365
  }
3166
3366
  }
3167
3367
  if (Array.isArray(settingsData?.value?.custom_banned_terms)) {
@@ -3197,13 +3397,18 @@ Created with Social Neuron`;
3197
3397
  }
3198
3398
  };
3199
3399
  });
3200
- const qualityPassed = postsWithResults.filter((post) => post.quality.passed).length;
3400
+ const qualityPassed = postsWithResults.filter(
3401
+ (post) => post.quality.passed
3402
+ ).length;
3201
3403
  const qualitySummary = {
3202
3404
  total_posts: postsWithResults.length,
3203
3405
  passed: qualityPassed,
3204
3406
  failed: postsWithResults.length - qualityPassed,
3205
3407
  avg_score: postsWithResults.length > 0 ? Number(
3206
- (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)
3207
3412
  ) : 0
3208
3413
  };
3209
3414
  if (dry_run) {
@@ -3273,8 +3478,13 @@ Created with Social Neuron`;
3273
3478
  }
3274
3479
  }
3275
3480
  lines2.push("");
3276
- lines2.push(`Summary: ${passed}/${workingPlan.posts.length} passed quality check`);
3277
- 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
+ };
3278
3488
  }
3279
3489
  let scheduled = 0;
3280
3490
  let failed = 0;
@@ -3366,7 +3576,8 @@ Created with Social Neuron`;
3366
3576
  }
3367
3577
  const chunk = (arr, size) => {
3368
3578
  const out = [];
3369
- 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));
3370
3581
  return out;
3371
3582
  };
3372
3583
  const platformBatches = Array.from(grouped.entries()).map(
@@ -3374,7 +3585,9 @@ Created with Social Neuron`;
3374
3585
  const platformResults = [];
3375
3586
  const batches = chunk(platformPosts, batch_size);
3376
3587
  for (const batch of batches) {
3377
- const settled = await Promise.allSettled(batch.map((post) => scheduleOne(post)));
3588
+ const settled = await Promise.allSettled(
3589
+ batch.map((post) => scheduleOne(post))
3590
+ );
3378
3591
  for (const outcome of settled) {
3379
3592
  if (outcome.status === "fulfilled") {
3380
3593
  platformResults.push(outcome.value);
@@ -3440,7 +3653,11 @@ Created with Social Neuron`;
3440
3653
  plan_id: effectivePlanId,
3441
3654
  approvals: approvalSummary,
3442
3655
  posts: results,
3443
- summary: { total_posts: workingPlan.posts.length, scheduled, failed }
3656
+ summary: {
3657
+ total_posts: workingPlan.posts.length,
3658
+ scheduled,
3659
+ failed
3660
+ }
3444
3661
  }),
3445
3662
  null,
3446
3663
  2
@@ -3478,7 +3695,12 @@ Created with Social Neuron`;
3478
3695
  details: { error: message }
3479
3696
  });
3480
3697
  return {
3481
- content: [{ type: "text", text: `Batch scheduling failed: ${message}` }],
3698
+ content: [
3699
+ {
3700
+ type: "text",
3701
+ text: `Batch scheduling failed: ${message}`
3702
+ }
3703
+ ],
3482
3704
  isError: true
3483
3705
  };
3484
3706
  }
@@ -3492,7 +3714,7 @@ import { z as z4 } from "zod";
3492
3714
  function asEnvelope3(data) {
3493
3715
  return {
3494
3716
  _meta: {
3495
- version: "0.2.0",
3717
+ version: MCP_VERSION,
3496
3718
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3497
3719
  },
3498
3720
  data
@@ -3605,7 +3827,9 @@ function registerAnalyticsTools(server) {
3605
3827
  ]
3606
3828
  };
3607
3829
  }
3608
- 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);
3609
3833
  if (simpleError) {
3610
3834
  return {
3611
3835
  content: [
@@ -3617,7 +3841,12 @@ function registerAnalyticsTools(server) {
3617
3841
  isError: true
3618
3842
  };
3619
3843
  }
3620
- return formatSimpleAnalytics(simpleRows, platform2, lookbackDays, format);
3844
+ return formatSimpleAnalytics(
3845
+ simpleRows,
3846
+ platform2,
3847
+ lookbackDays,
3848
+ format
3849
+ );
3621
3850
  }
3622
3851
  if (!rows || rows.length === 0) {
3623
3852
  if (format === "json") {
@@ -3694,7 +3923,10 @@ function registerAnalyticsTools(server) {
3694
3923
  const format = response_format ?? "text";
3695
3924
  const startedAt = Date.now();
3696
3925
  const userId = await getDefaultUserId();
3697
- const rateLimit = checkRateLimit("posting", `refresh_platform_analytics:${userId}`);
3926
+ const rateLimit = checkRateLimit(
3927
+ "posting",
3928
+ `refresh_platform_analytics:${userId}`
3929
+ );
3698
3930
  if (!rateLimit.allowed) {
3699
3931
  await logMcpToolInvocation({
3700
3932
  toolName: "refresh_platform_analytics",
@@ -3712,7 +3944,9 @@ function registerAnalyticsTools(server) {
3712
3944
  isError: true
3713
3945
  };
3714
3946
  }
3715
- const { data, error } = await callEdgeFunction("fetch-analytics", { userId });
3947
+ const { data, error } = await callEdgeFunction("fetch-analytics", {
3948
+ userId
3949
+ });
3716
3950
  if (error) {
3717
3951
  await logMcpToolInvocation({
3718
3952
  toolName: "refresh_platform_analytics",
@@ -3721,7 +3955,12 @@ function registerAnalyticsTools(server) {
3721
3955
  details: { error }
3722
3956
  });
3723
3957
  return {
3724
- content: [{ type: "text", text: `Error refreshing analytics: ${error}` }],
3958
+ content: [
3959
+ {
3960
+ type: "text",
3961
+ text: `Error refreshing analytics: ${error}`
3962
+ }
3963
+ ],
3725
3964
  isError: true
3726
3965
  };
3727
3966
  }
@@ -3734,12 +3973,18 @@ function registerAnalyticsTools(server) {
3734
3973
  details: { error: "Edge function returned success=false" }
3735
3974
  });
3736
3975
  return {
3737
- content: [{ type: "text", text: "Analytics refresh failed." }],
3976
+ content: [
3977
+ { type: "text", text: "Analytics refresh failed." }
3978
+ ],
3738
3979
  isError: true
3739
3980
  };
3740
3981
  }
3741
- const queued = (result.results ?? []).filter((r) => r.status === "queued").length;
3742
- 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;
3743
3988
  const lines = [
3744
3989
  `Analytics refresh triggered successfully.`,
3745
3990
  ` Posts processed: ${result.postsProcessed}`,
@@ -3785,7 +4030,10 @@ function formatAnalytics(summary, days, format) {
3785
4030
  if (format === "json") {
3786
4031
  return {
3787
4032
  content: [
3788
- { type: "text", text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2) }
4033
+ {
4034
+ type: "text",
4035
+ text: JSON.stringify(asEnvelope3({ ...summary, days }), null, 2)
4036
+ }
3789
4037
  ]
3790
4038
  };
3791
4039
  }
@@ -3902,10 +4150,159 @@ function formatSimpleAnalytics(rows, platform2, days, format) {
3902
4150
  // src/tools/brand.ts
3903
4151
  import { z as z5 } from "zod";
3904
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
3905
4302
  function asEnvelope4(data) {
3906
4303
  return {
3907
4304
  _meta: {
3908
- version: "0.2.0",
4305
+ version: MCP_VERSION,
3909
4306
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3910
4307
  },
3911
4308
  data
@@ -3922,6 +4319,15 @@ function registerBrandTools(server) {
3922
4319
  response_format: z5.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
3923
4320
  },
3924
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
+ }
3925
4331
  const { data, error } = await callEdgeFunction(
3926
4332
  "brand-extract",
3927
4333
  { url },
@@ -3951,7 +4357,12 @@ function registerBrandTools(server) {
3951
4357
  }
3952
4358
  if ((response_format || "text") === "json") {
3953
4359
  return {
3954
- 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
+ ]
3955
4366
  };
3956
4367
  }
3957
4368
  const lines = [
@@ -4015,7 +4426,12 @@ function registerBrandTools(server) {
4015
4426
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
4016
4427
  if (!membership) {
4017
4428
  return {
4018
- 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
+ ],
4019
4435
  isError: true
4020
4436
  };
4021
4437
  }
@@ -4034,13 +4450,21 @@ function registerBrandTools(server) {
4034
4450
  if (!data) {
4035
4451
  return {
4036
4452
  content: [
4037
- { 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
+ }
4038
4457
  ]
4039
4458
  };
4040
4459
  }
4041
4460
  if ((response_format || "text") === "json") {
4042
4461
  return {
4043
- 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
+ ]
4044
4468
  };
4045
4469
  }
4046
4470
  const lines = [
@@ -4061,11 +4485,18 @@ function registerBrandTools(server) {
4061
4485
  "Persist a brand profile as the active profile for a project.",
4062
4486
  {
4063
4487
  project_id: z5.string().uuid().optional().describe("Project ID. Defaults to active project context."),
4064
- 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
+ ),
4065
4491
  change_summary: z5.string().max(500).optional().describe("Optional summary of changes."),
4066
4492
  changed_paths: z5.array(z5.string()).optional().describe("Optional changed path list."),
4067
4493
  source_url: z5.string().url().optional().describe("Optional source URL for provenance."),
4068
- 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."),
4069
4500
  overall_confidence: z5.number().min(0).max(1).optional().describe("Optional overall confidence score in range 0..1."),
4070
4501
  extraction_metadata: z5.record(z5.string(), z5.unknown()).optional(),
4071
4502
  response_format: z5.enum(["text", "json"]).optional()
@@ -4105,20 +4536,28 @@ function registerBrandTools(server) {
4105
4536
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
4106
4537
  if (!membership) {
4107
4538
  return {
4108
- 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
+ ],
4109
4545
  isError: true
4110
4546
  };
4111
4547
  }
4112
- const { data: profileId, error } = await supabase.rpc("set_active_brand_profile", {
4113
- p_project_id: projectId,
4114
- p_brand_context: brand_context,
4115
- p_change_summary: change_summary || null,
4116
- p_changed_paths: changed_paths || [],
4117
- p_source_url: source_url || null,
4118
- p_extraction_method: extraction_method || "manual",
4119
- p_overall_confidence: overall_confidence ?? null,
4120
- p_extraction_metadata: extraction_metadata || null
4121
- });
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
+ );
4122
4561
  if (error) {
4123
4562
  return {
4124
4563
  content: [
@@ -4139,7 +4578,12 @@ function registerBrandTools(server) {
4139
4578
  };
4140
4579
  if ((response_format || "text") === "json") {
4141
4580
  return {
4142
- 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
+ ]
4143
4587
  };
4144
4588
  }
4145
4589
  return {
@@ -4195,7 +4639,10 @@ Version: ${payload.version ?? "N/A"}`
4195
4639
  if (!projectId) {
4196
4640
  return {
4197
4641
  content: [
4198
- { 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
+ }
4199
4646
  ],
4200
4647
  isError: true
4201
4648
  };
@@ -4210,7 +4657,12 @@ Version: ${payload.version ?? "N/A"}`
4210
4657
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
4211
4658
  if (!membership) {
4212
4659
  return {
4213
- 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
+ ],
4214
4666
  isError: true
4215
4667
  };
4216
4668
  }
@@ -4226,7 +4678,9 @@ Version: ${payload.version ?? "N/A"}`
4226
4678
  isError: true
4227
4679
  };
4228
4680
  }
4229
- const brandContext = { ...existingProfile.brand_context };
4681
+ const brandContext = {
4682
+ ...existingProfile.brand_context
4683
+ };
4230
4684
  const voiceProfile = brandContext.voiceProfile ?? {};
4231
4685
  const platformOverrides = voiceProfile.platformOverrides ?? {};
4232
4686
  const existingOverride = platformOverrides[platform2] ?? {};
@@ -4250,16 +4704,19 @@ Version: ${payload.version ?? "N/A"}`
4250
4704
  ...brandContext,
4251
4705
  voiceProfile: updatedVoiceProfile
4252
4706
  };
4253
- const { data: profileId, error: saveError } = await supabase.rpc("set_active_brand_profile", {
4254
- p_project_id: projectId,
4255
- p_brand_context: updatedContext,
4256
- p_change_summary: `Updated platform voice override for ${platform2}`,
4257
- p_changed_paths: [`voiceProfile.platformOverrides.${platform2}`],
4258
- p_source_url: null,
4259
- p_extraction_method: "manual",
4260
- p_overall_confidence: null,
4261
- p_extraction_metadata: null
4262
- });
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
+ );
4263
4720
  if (saveError) {
4264
4721
  return {
4265
4722
  content: [
@@ -4280,7 +4737,12 @@ Version: ${payload.version ?? "N/A"}`
4280
4737
  };
4281
4738
  if ((response_format || "text") === "json") {
4282
4739
  return {
4283
- 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
+ ],
4284
4746
  isError: false
4285
4747
  };
4286
4748
  }
@@ -4378,155 +4840,6 @@ async function capturePageScreenshot(page, outputPath, selector) {
4378
4840
  // src/tools/screenshot.ts
4379
4841
  import { resolve, relative } from "node:path";
4380
4842
  import { mkdir } from "node:fs/promises";
4381
-
4382
- // src/lib/ssrf.ts
4383
- var BLOCKED_IP_PATTERNS = [
4384
- // IPv4 localhost/loopback
4385
- /^127\./,
4386
- /^0\./,
4387
- // IPv4 private ranges (RFC 1918)
4388
- /^10\./,
4389
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
4390
- /^192\.168\./,
4391
- // IPv4 link-local
4392
- /^169\.254\./,
4393
- // Cloud metadata endpoint (AWS, GCP, Azure)
4394
- /^169\.254\.169\.254$/,
4395
- // IPv4 broadcast
4396
- /^255\./,
4397
- // Shared address space (RFC 6598)
4398
- /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
4399
- ];
4400
- var BLOCKED_IPV6_PATTERNS = [
4401
- /^::1$/i,
4402
- // loopback
4403
- /^::$/i,
4404
- // unspecified
4405
- /^fe[89ab][0-9a-f]:/i,
4406
- // link-local fe80::/10
4407
- /^fc[0-9a-f]:/i,
4408
- // unique local fc00::/7
4409
- /^fd[0-9a-f]:/i,
4410
- // unique local fc00::/7
4411
- /^::ffff:127\./i,
4412
- // IPv4-mapped localhost
4413
- /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
4414
- // IPv4-mapped private
4415
- ];
4416
- var BLOCKED_HOSTNAMES = [
4417
- "localhost",
4418
- "localhost.localdomain",
4419
- "local",
4420
- "127.0.0.1",
4421
- "0.0.0.0",
4422
- "[::1]",
4423
- "[::ffff:127.0.0.1]",
4424
- // Cloud metadata endpoints
4425
- "metadata.google.internal",
4426
- "metadata.goog",
4427
- "instance-data",
4428
- "instance-data.ec2.internal"
4429
- ];
4430
- var ALLOWED_PROTOCOLS = ["http:", "https:"];
4431
- var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
4432
- function isBlockedIP(ip) {
4433
- const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
4434
- if (normalized.includes(":")) {
4435
- return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
4436
- }
4437
- return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
4438
- }
4439
- function isBlockedHostname(hostname) {
4440
- return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
4441
- }
4442
- function isIPAddress(hostname) {
4443
- const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
4444
- const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
4445
- return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
4446
- }
4447
- async function validateUrlForSSRF(urlString) {
4448
- try {
4449
- const url = new URL(urlString);
4450
- if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
4451
- return {
4452
- isValid: false,
4453
- error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
4454
- };
4455
- }
4456
- if (url.username || url.password) {
4457
- return {
4458
- isValid: false,
4459
- error: "URLs with embedded credentials are not allowed."
4460
- };
4461
- }
4462
- const hostname = url.hostname.toLowerCase();
4463
- if (isBlockedHostname(hostname)) {
4464
- return {
4465
- isValid: false,
4466
- error: "Access to internal/localhost addresses is not allowed."
4467
- };
4468
- }
4469
- if (isIPAddress(hostname) && isBlockedIP(hostname)) {
4470
- return {
4471
- isValid: false,
4472
- error: "Access to private/internal IP addresses is not allowed."
4473
- };
4474
- }
4475
- const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
4476
- if (BLOCKED_PORTS.includes(port)) {
4477
- return {
4478
- isValid: false,
4479
- error: `Access to port ${port} is not allowed.`
4480
- };
4481
- }
4482
- let resolvedIP;
4483
- if (!isIPAddress(hostname)) {
4484
- try {
4485
- const dns = await import("node:dns");
4486
- const resolver = new dns.promises.Resolver();
4487
- const resolvedIPs = [];
4488
- try {
4489
- const aRecords = await resolver.resolve4(hostname);
4490
- resolvedIPs.push(...aRecords);
4491
- } catch {
4492
- }
4493
- try {
4494
- const aaaaRecords = await resolver.resolve6(hostname);
4495
- resolvedIPs.push(...aaaaRecords);
4496
- } catch {
4497
- }
4498
- if (resolvedIPs.length === 0) {
4499
- return {
4500
- isValid: false,
4501
- error: "DNS resolution failed: hostname did not resolve to any address."
4502
- };
4503
- }
4504
- for (const ip of resolvedIPs) {
4505
- if (isBlockedIP(ip)) {
4506
- return {
4507
- isValid: false,
4508
- error: "Hostname resolves to a private/internal IP address."
4509
- };
4510
- }
4511
- }
4512
- resolvedIP = resolvedIPs[0];
4513
- } catch {
4514
- return {
4515
- isValid: false,
4516
- error: "DNS resolution failed. Cannot verify hostname safety."
4517
- };
4518
- }
4519
- }
4520
- return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
4521
- } catch (error) {
4522
- return {
4523
- isValid: false,
4524
- error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
4525
- };
4526
- }
4527
- }
4528
-
4529
- // src/tools/screenshot.ts
4530
4843
  init_supabase();
4531
4844
  function registerScreenshotTools(server) {
4532
4845
  server.tool(
@@ -5102,6 +5415,7 @@ function registerRemotionTools(server) {
5102
5415
  // src/tools/insights.ts
5103
5416
  init_supabase();
5104
5417
  import { z as z8 } from "zod";
5418
+ var MAX_INSIGHT_AGE_DAYS = 30;
5105
5419
  var PLATFORM_ENUM = [
5106
5420
  "youtube",
5107
5421
  "tiktok",
@@ -5112,11 +5426,19 @@ var PLATFORM_ENUM = [
5112
5426
  "threads",
5113
5427
  "bluesky"
5114
5428
  ];
5115
- 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
+ ];
5116
5438
  function asEnvelope5(data) {
5117
5439
  return {
5118
5440
  _meta: {
5119
- version: "0.2.0",
5441
+ version: MCP_VERSION,
5120
5442
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5121
5443
  },
5122
5444
  data
@@ -5127,7 +5449,12 @@ function registerInsightsTools(server) {
5127
5449
  "get_performance_insights",
5128
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.",
5129
5451
  {
5130
- 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."),
5131
5458
  days: z8.number().min(1).max(90).optional().describe("Number of days to look back. Defaults to 30. Max 90."),
5132
5459
  limit: z8.number().min(1).max(50).optional().describe("Maximum number of insights to return. Defaults to 10."),
5133
5460
  response_format: z8.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
@@ -5146,10 +5473,13 @@ function registerInsightsTools(server) {
5146
5473
  projectIds.push(...projects.map((p) => p.id));
5147
5474
  }
5148
5475
  }
5476
+ const effectiveDays = Math.min(lookbackDays, MAX_INSIGHT_AGE_DAYS);
5149
5477
  const since = /* @__PURE__ */ new Date();
5150
- since.setDate(since.getDate() - lookbackDays);
5478
+ since.setDate(since.getDate() - effectiveDays);
5151
5479
  const sinceIso = since.toISOString();
5152
- 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);
5153
5483
  if (projectIds.length > 0) {
5154
5484
  query = query.in("project_id", projectIds);
5155
5485
  } else {
@@ -5506,7 +5836,7 @@ init_supabase();
5506
5836
  function asEnvelope6(data) {
5507
5837
  return {
5508
5838
  _meta: {
5509
- version: "0.2.0",
5839
+ version: MCP_VERSION,
5510
5840
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5511
5841
  },
5512
5842
  data
@@ -5517,7 +5847,9 @@ function registerCommentsTools(server) {
5517
5847
  "list_comments",
5518
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.",
5519
5849
  {
5520
- 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
+ ),
5521
5853
  max_results: z10.number().min(1).max(100).optional().describe("Maximum number of comments to return. Defaults to 50."),
5522
5854
  page_token: z10.string().optional().describe("Pagination token from a previous list_comments call."),
5523
5855
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
@@ -5532,7 +5864,9 @@ function registerCommentsTools(server) {
5532
5864
  });
5533
5865
  if (error) {
5534
5866
  return {
5535
- content: [{ type: "text", text: `Error listing comments: ${error}` }],
5867
+ content: [
5868
+ { type: "text", text: `Error listing comments: ${error}` }
5869
+ ],
5536
5870
  isError: true
5537
5871
  };
5538
5872
  }
@@ -5544,7 +5878,10 @@ function registerCommentsTools(server) {
5544
5878
  {
5545
5879
  type: "text",
5546
5880
  text: JSON.stringify(
5547
- asEnvelope6({ comments, nextPageToken: result.nextPageToken ?? null }),
5881
+ asEnvelope6({
5882
+ comments,
5883
+ nextPageToken: result.nextPageToken ?? null
5884
+ }),
5548
5885
  null,
5549
5886
  2
5550
5887
  )
@@ -5581,7 +5918,9 @@ function registerCommentsTools(server) {
5581
5918
  "reply_to_comment",
5582
5919
  "Reply to a YouTube comment. Requires the parent comment ID and reply text.",
5583
5920
  {
5584
- 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
+ ),
5585
5924
  text: z10.string().min(1).describe("The reply text."),
5586
5925
  response_format: z10.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
5587
5926
  },
@@ -5620,7 +5959,12 @@ function registerCommentsTools(server) {
5620
5959
  details: { error }
5621
5960
  });
5622
5961
  return {
5623
- content: [{ type: "text", text: `Error replying to comment: ${error}` }],
5962
+ content: [
5963
+ {
5964
+ type: "text",
5965
+ text: `Error replying to comment: ${error}`
5966
+ }
5967
+ ],
5624
5968
  isError: true
5625
5969
  };
5626
5970
  }
@@ -5633,7 +5977,12 @@ function registerCommentsTools(server) {
5633
5977
  });
5634
5978
  if (format === "json") {
5635
5979
  return {
5636
- 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
+ ]
5637
5986
  };
5638
5987
  }
5639
5988
  return {
@@ -5691,7 +6040,9 @@ function registerCommentsTools(server) {
5691
6040
  details: { error }
5692
6041
  });
5693
6042
  return {
5694
- content: [{ type: "text", text: `Error posting comment: ${error}` }],
6043
+ content: [
6044
+ { type: "text", text: `Error posting comment: ${error}` }
6045
+ ],
5695
6046
  isError: true
5696
6047
  };
5697
6048
  }
@@ -5704,7 +6055,12 @@ function registerCommentsTools(server) {
5704
6055
  });
5705
6056
  if (format === "json") {
5706
6057
  return {
5707
- 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
+ ]
5708
6064
  };
5709
6065
  }
5710
6066
  return {
@@ -5762,7 +6118,12 @@ function registerCommentsTools(server) {
5762
6118
  details: { error }
5763
6119
  });
5764
6120
  return {
5765
- content: [{ type: "text", text: `Error moderating comment: ${error}` }],
6121
+ content: [
6122
+ {
6123
+ type: "text",
6124
+ text: `Error moderating comment: ${error}`
6125
+ }
6126
+ ],
5766
6127
  isError: true
5767
6128
  };
5768
6129
  }
@@ -5841,7 +6202,9 @@ function registerCommentsTools(server) {
5841
6202
  details: { error }
5842
6203
  });
5843
6204
  return {
5844
- content: [{ type: "text", text: `Error deleting comment: ${error}` }],
6205
+ content: [
6206
+ { type: "text", text: `Error deleting comment: ${error}` }
6207
+ ],
5845
6208
  isError: true
5846
6209
  };
5847
6210
  }
@@ -5856,13 +6219,22 @@ function registerCommentsTools(server) {
5856
6219
  content: [
5857
6220
  {
5858
6221
  type: "text",
5859
- 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
+ )
5860
6227
  }
5861
6228
  ]
5862
6229
  };
5863
6230
  }
5864
6231
  return {
5865
- content: [{ type: "text", text: `Comment ${comment_id} deleted successfully.` }]
6232
+ content: [
6233
+ {
6234
+ type: "text",
6235
+ text: `Comment ${comment_id} deleted successfully.`
6236
+ }
6237
+ ]
5866
6238
  };
5867
6239
  }
5868
6240
  );
@@ -5890,8 +6262,12 @@ function transformInsightsToPerformanceContext(projectId, insights) {
5890
6262
  };
5891
6263
  }
5892
6264
  const topHooksInsight = insights.find((i) => i.insight_type === "top_hooks");
5893
- const optimalTimingInsight = insights.find((i) => i.insight_type === "optimal_timing");
5894
- 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
+ );
5895
6271
  const topHooks = topHooksInsight?.insight_data?.hooks || [];
5896
6272
  const hooksSummary = topHooksInsight?.insight_data?.summary || "";
5897
6273
  const timingSummary = optimalTimingInsight?.insight_data?.summary || "";
@@ -5902,7 +6278,10 @@ function transformInsightsToPerformanceContext(projectId, insights) {
5902
6278
  if (hooksSummary) promptParts.push(hooksSummary);
5903
6279
  if (timingSummary) promptParts.push(timingSummary);
5904
6280
  if (modelSummary) promptParts.push(modelSummary);
5905
- 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
+ );
5906
6285
  return {
5907
6286
  projectId,
5908
6287
  hasHistoricalData: true,
@@ -5928,7 +6307,7 @@ function transformInsightsToPerformanceContext(projectId, insights) {
5928
6307
  function asEnvelope7(data) {
5929
6308
  return {
5930
6309
  _meta: {
5931
- version: "0.2.0",
6310
+ version: MCP_VERSION,
5932
6311
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
5933
6312
  },
5934
6313
  data
@@ -5951,7 +6330,12 @@ function registerIdeationContextTools(server) {
5951
6330
  const { data: member } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).limit(1).single();
5952
6331
  if (!member?.organization_id) {
5953
6332
  return {
5954
- 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
+ ],
5955
6339
  isError: true
5956
6340
  };
5957
6341
  }
@@ -5959,7 +6343,10 @@ function registerIdeationContextTools(server) {
5959
6343
  if (projectsError) {
5960
6344
  return {
5961
6345
  content: [
5962
- { type: "text", text: `Failed to resolve projects: ${projectsError.message}` }
6346
+ {
6347
+ type: "text",
6348
+ text: `Failed to resolve projects: ${projectsError.message}`
6349
+ }
5963
6350
  ],
5964
6351
  isError: true
5965
6352
  };
@@ -5967,7 +6354,12 @@ function registerIdeationContextTools(server) {
5967
6354
  const projectIds = (projects || []).map((p) => p.id);
5968
6355
  if (projectIds.length === 0) {
5969
6356
  return {
5970
- 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
+ ],
5971
6363
  isError: true
5972
6364
  };
5973
6365
  }
@@ -5976,7 +6368,10 @@ function registerIdeationContextTools(server) {
5976
6368
  if (!selectedProjectId) {
5977
6369
  return {
5978
6370
  content: [
5979
- { type: "text", text: "No accessible project found for current user." }
6371
+ {
6372
+ type: "text",
6373
+ text: "No accessible project found for current user."
6374
+ }
5980
6375
  ],
5981
6376
  isError: true
5982
6377
  };
@@ -5994,7 +6389,9 @@ function registerIdeationContextTools(server) {
5994
6389
  }
5995
6390
  const since = /* @__PURE__ */ new Date();
5996
6391
  since.setDate(since.getDate() - lookbackDays);
5997
- 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);
5998
6395
  if (error) {
5999
6396
  return {
6000
6397
  content: [
@@ -6012,7 +6409,12 @@ function registerIdeationContextTools(server) {
6012
6409
  );
6013
6410
  if (format === "json") {
6014
6411
  return {
6015
- 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
+ ]
6016
6418
  };
6017
6419
  }
6018
6420
  const lines = [
@@ -6036,7 +6438,7 @@ import { z as z12 } from "zod";
6036
6438
  function asEnvelope8(data) {
6037
6439
  return {
6038
6440
  _meta: {
6039
- version: "0.2.0",
6441
+ version: MCP_VERSION,
6040
6442
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6041
6443
  },
6042
6444
  data
@@ -6075,7 +6477,12 @@ function registerCreditsTools(server) {
6075
6477
  };
6076
6478
  if ((response_format || "text") === "json") {
6077
6479
  return {
6078
- 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
+ ]
6079
6486
  };
6080
6487
  }
6081
6488
  return {
@@ -6109,7 +6516,12 @@ Monthly used: ${payload.monthlyUsed}` + (payload.monthlyLimit ? ` / ${payload.mo
6109
6516
  };
6110
6517
  if ((response_format || "text") === "json") {
6111
6518
  return {
6112
- 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
+ ]
6113
6525
  };
6114
6526
  }
6115
6527
  return {
@@ -6136,7 +6548,7 @@ import { z as z13 } from "zod";
6136
6548
  function asEnvelope9(data) {
6137
6549
  return {
6138
6550
  _meta: {
6139
- version: "0.2.0",
6551
+ version: MCP_VERSION,
6140
6552
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
6141
6553
  },
6142
6554
  data
@@ -6175,7 +6587,12 @@ function registerLoopSummaryTools(server) {
6175
6587
  const { data: membership } = await supabase.from("organization_members").select("organization_id").eq("user_id", userId).eq("organization_id", project.organization_id).maybeSingle();
6176
6588
  if (!membership) {
6177
6589
  return {
6178
- 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
+ ],
6179
6596
  isError: true
6180
6597
  };
6181
6598
  }
@@ -6198,7 +6615,12 @@ function registerLoopSummaryTools(server) {
6198
6615
  };
6199
6616
  if ((response_format || "text") === "json") {
6200
6617
  return {
6201
- 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
+ ]
6202
6624
  };
6203
6625
  }
6204
6626
  return {
@@ -6221,11 +6643,6 @@ Next Action: ${payload.recommendedNextAction}`
6221
6643
  // src/tools/usage.ts
6222
6644
  init_supabase();
6223
6645
  import { z as z14 } from "zod";
6224
-
6225
- // src/lib/version.ts
6226
- var MCP_VERSION = "1.4.0";
6227
-
6228
- // src/tools/usage.ts
6229
6646
  function asEnvelope10(data) {
6230
6647
  return {
6231
6648
  _meta: {
@@ -6531,7 +6948,10 @@ ${"=".repeat(40)}
6531
6948
  import { z as z16 } from "zod";
6532
6949
  init_supabase();
6533
6950
  function asEnvelope12(data) {
6534
- 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
+ };
6535
6955
  }
6536
6956
  function isYouTubeUrl(url) {
6537
6957
  if (/youtube\.com\/watch|youtu\.be\//.test(url)) return "video";
@@ -6562,13 +6982,17 @@ Metadata:`);
6562
6982
  if (m.tags?.length) lines.push(` Tags: ${m.tags.join(", ")}`);
6563
6983
  }
6564
6984
  if (content.features?.length)
6565
- lines.push(`
6985
+ lines.push(
6986
+ `
6566
6987
  Features:
6567
- ${content.features.map((f) => ` - ${f}`).join("\n")}`);
6988
+ ${content.features.map((f) => ` - ${f}`).join("\n")}`
6989
+ );
6568
6990
  if (content.benefits?.length)
6569
- lines.push(`
6991
+ lines.push(
6992
+ `
6570
6993
  Benefits:
6571
- ${content.benefits.map((b) => ` - ${b}`).join("\n")}`);
6994
+ ${content.benefits.map((b) => ` - ${b}`).join("\n")}`
6995
+ );
6572
6996
  if (content.usp) lines.push(`
6573
6997
  USP: ${content.usp}`);
6574
6998
  if (content.suggested_hooks?.length)
@@ -6590,12 +7014,20 @@ function registerExtractionTools(server) {
6590
7014
  max_results: z16.number().min(1).max(100).default(10).describe("Max comments to include"),
6591
7015
  response_format: z16.enum(["text", "json"]).default("text")
6592
7016
  },
6593
- 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
+ }) => {
6594
7024
  const startedAt = Date.now();
6595
7025
  const ssrfCheck = await validateUrlForSSRF(url);
6596
7026
  if (!ssrfCheck.isValid) {
6597
7027
  return {
6598
- content: [{ type: "text", text: `URL blocked: ${ssrfCheck.error}` }],
7028
+ content: [
7029
+ { type: "text", text: `URL blocked: ${ssrfCheck.error}` }
7030
+ ],
6599
7031
  isError: true
6600
7032
  };
6601
7033
  }
@@ -6723,13 +7155,21 @@ function registerExtractionTools(server) {
6723
7155
  if (response_format === "json") {
6724
7156
  return {
6725
7157
  content: [
6726
- { type: "text", text: JSON.stringify(asEnvelope12(extracted), null, 2) }
7158
+ {
7159
+ type: "text",
7160
+ text: JSON.stringify(asEnvelope12(extracted), null, 2)
7161
+ }
6727
7162
  ],
6728
7163
  isError: false
6729
7164
  };
6730
7165
  }
6731
7166
  return {
6732
- content: [{ type: "text", text: formatExtractedContentAsText(extracted) }],
7167
+ content: [
7168
+ {
7169
+ type: "text",
7170
+ text: formatExtractedContentAsText(extracted)
7171
+ }
7172
+ ],
6733
7173
  isError: false
6734
7174
  };
6735
7175
  } catch (err) {
@@ -6742,7 +7182,9 @@ function registerExtractionTools(server) {
6742
7182
  details: { url, error: message }
6743
7183
  });
6744
7184
  return {
6745
- content: [{ type: "text", text: `Extraction failed: ${message}` }],
7185
+ content: [
7186
+ { type: "text", text: `Extraction failed: ${message}` }
7187
+ ],
6746
7188
  isError: true
6747
7189
  };
6748
7190
  }
@@ -6754,7 +7196,10 @@ function registerExtractionTools(server) {
6754
7196
  import { z as z17 } from "zod";
6755
7197
  init_supabase();
6756
7198
  function asEnvelope13(data) {
6757
- 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
+ };
6758
7203
  }
6759
7204
  function registerQualityTools(server) {
6760
7205
  server.tool(
@@ -6810,7 +7255,12 @@ function registerQualityTools(server) {
6810
7255
  });
6811
7256
  if (response_format === "json") {
6812
7257
  return {
6813
- 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
+ ],
6814
7264
  isError: false
6815
7265
  };
6816
7266
  }
@@ -6820,7 +7270,9 @@ function registerQualityTools(server) {
6820
7270
  );
6821
7271
  lines.push("");
6822
7272
  for (const cat of result.categories) {
6823
- 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
+ );
6824
7276
  }
6825
7277
  if (result.blockers.length > 0) {
6826
7278
  lines.push("");
@@ -6831,7 +7283,10 @@ function registerQualityTools(server) {
6831
7283
  }
6832
7284
  lines.push("");
6833
7285
  lines.push(`Threshold: ${result.threshold}/${result.maxTotal}`);
6834
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
7286
+ return {
7287
+ content: [{ type: "text", text: lines.join("\n") }],
7288
+ isError: false
7289
+ };
6835
7290
  }
6836
7291
  );
6837
7292
  server.tool(
@@ -6872,7 +7327,9 @@ function registerQualityTools(server) {
6872
7327
  });
6873
7328
  const scores = postsWithQuality.map((p) => p.quality.score);
6874
7329
  const passed = postsWithQuality.filter((p) => p.quality.passed).length;
6875
- 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;
6876
7333
  const summary = {
6877
7334
  total_posts: plan.posts.length,
6878
7335
  passed,
@@ -6891,25 +7348,36 @@ function registerQualityTools(server) {
6891
7348
  content: [
6892
7349
  {
6893
7350
  type: "text",
6894
- text: JSON.stringify(asEnvelope13({ posts: postsWithQuality, summary }), null, 2)
7351
+ text: JSON.stringify(
7352
+ asEnvelope13({ posts: postsWithQuality, summary }),
7353
+ null,
7354
+ 2
7355
+ )
6895
7356
  }
6896
7357
  ],
6897
7358
  isError: false
6898
7359
  };
6899
7360
  }
6900
7361
  const lines = [];
6901
- 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
+ );
6902
7365
  lines.push("");
6903
7366
  for (const post of postsWithQuality) {
6904
7367
  const icon = post.quality.passed ? "[PASS]" : "[FAIL]";
6905
- 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
+ );
6906
7371
  if (post.quality.blockers.length > 0) {
6907
7372
  for (const b of post.quality.blockers) {
6908
7373
  lines.push(` - ${b}`);
6909
7374
  }
6910
7375
  }
6911
7376
  }
6912
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
7377
+ return {
7378
+ content: [{ type: "text", text: lines.join("\n") }],
7379
+ isError: false
7380
+ };
6913
7381
  }
6914
7382
  );
6915
7383
  }
@@ -6922,7 +7390,10 @@ function toRecord(value) {
6922
7390
  return value && typeof value === "object" ? value : void 0;
6923
7391
  }
6924
7392
  function asEnvelope14(data) {
6925
- 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
+ };
6926
7397
  }
6927
7398
  function tomorrowIsoDate() {
6928
7399
  const d = /* @__PURE__ */ new Date();
@@ -6960,7 +7431,9 @@ function formatPlanAsText(plan) {
6960
7431
  lines.push(`WEEKLY CONTENT PLAN: "${plan.topic}"`);
6961
7432
  lines.push(`Period: ${plan.start_date} to ${plan.end_date}`);
6962
7433
  lines.push(`Platforms: ${plan.platforms.join(", ")}`);
6963
- 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
+ );
6964
7437
  if (plan.plan_id) lines.push(`Plan ID: ${plan.plan_id}`);
6965
7438
  if (plan.insights_applied?.has_historical_data) {
6966
7439
  lines.push("");
@@ -6975,7 +7448,9 @@ function formatPlanAsText(plan) {
6975
7448
  `- Best posting time: ${days[timing.dayOfWeek] ?? timing.dayOfWeek} ${timing.hourOfDay}:00`
6976
7449
  );
6977
7450
  }
6978
- 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
+ );
6979
7454
  lines.push(`- Insights count: ${plan.insights_applied.insights_count}`);
6980
7455
  }
6981
7456
  lines.push("");
@@ -6996,9 +7471,11 @@ function formatPlanAsText(plan) {
6996
7471
  ` Caption: ${post.caption.slice(0, 200)}${post.caption.length > 200 ? "..." : ""}`
6997
7472
  );
6998
7473
  if (post.title) lines.push(` Title: ${post.title}`);
6999
- if (post.visual_direction) lines.push(` Visual: ${post.visual_direction}`);
7474
+ if (post.visual_direction)
7475
+ lines.push(` Visual: ${post.visual_direction}`);
7000
7476
  if (post.media_type) lines.push(` Media: ${post.media_type}`);
7001
- if (post.hashtags?.length) lines.push(` Hashtags: ${post.hashtags.join(" ")}`);
7477
+ if (post.hashtags?.length)
7478
+ lines.push(` Hashtags: ${post.hashtags.join(" ")}`);
7002
7479
  lines.push("");
7003
7480
  }
7004
7481
  }
@@ -7081,7 +7558,10 @@ function registerPlanningTools(server) {
7081
7558
  "mcp-data",
7082
7559
  {
7083
7560
  action: "brand-profile",
7084
- ...resolvedProjectId ? { projectId: resolvedProjectId, project_id: resolvedProjectId } : {}
7561
+ ...resolvedProjectId ? {
7562
+ projectId: resolvedProjectId,
7563
+ project_id: resolvedProjectId
7564
+ } : {}
7085
7565
  },
7086
7566
  { timeoutMs: 15e3 }
7087
7567
  );
@@ -7285,7 +7765,12 @@ ${rawText.slice(0, 1e3)}`
7285
7765
  details: { topic, error: `plan persistence failed: ${message}` }
7286
7766
  });
7287
7767
  return {
7288
- content: [{ type: "text", text: `Plan persistence failed: ${message}` }],
7768
+ content: [
7769
+ {
7770
+ type: "text",
7771
+ text: `Plan persistence failed: ${message}`
7772
+ }
7773
+ ],
7289
7774
  isError: true
7290
7775
  };
7291
7776
  }
@@ -7299,7 +7784,12 @@ ${rawText.slice(0, 1e3)}`
7299
7784
  });
7300
7785
  if (response_format === "json") {
7301
7786
  return {
7302
- 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
+ ],
7303
7793
  isError: false
7304
7794
  };
7305
7795
  }
@@ -7317,7 +7807,12 @@ ${rawText.slice(0, 1e3)}`
7317
7807
  details: { topic, error: message }
7318
7808
  });
7319
7809
  return {
7320
- content: [{ type: "text", text: `Plan generation failed: ${message}` }],
7810
+ content: [
7811
+ {
7812
+ type: "text",
7813
+ text: `Plan generation failed: ${message}`
7814
+ }
7815
+ ],
7321
7816
  isError: true
7322
7817
  };
7323
7818
  }
@@ -7377,7 +7872,11 @@ ${rawText.slice(0, 1e3)}`
7377
7872
  toolName: "save_content_plan",
7378
7873
  status: "success",
7379
7874
  durationMs,
7380
- details: { plan_id: planId, project_id: resolvedProjectId, status: normalizedStatus }
7875
+ details: {
7876
+ plan_id: planId,
7877
+ project_id: resolvedProjectId,
7878
+ status: normalizedStatus
7879
+ }
7381
7880
  });
7382
7881
  const result = {
7383
7882
  plan_id: planId,
@@ -7386,13 +7885,21 @@ ${rawText.slice(0, 1e3)}`
7386
7885
  };
7387
7886
  if (response_format === "json") {
7388
7887
  return {
7389
- 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
+ ],
7390
7894
  isError: false
7391
7895
  };
7392
7896
  }
7393
7897
  return {
7394
7898
  content: [
7395
- { type: "text", text: `Saved content plan ${planId} (${normalizedStatus}).` }
7899
+ {
7900
+ type: "text",
7901
+ text: `Saved content plan ${planId} (${normalizedStatus}).`
7902
+ }
7396
7903
  ],
7397
7904
  isError: false
7398
7905
  };
@@ -7406,7 +7913,12 @@ ${rawText.slice(0, 1e3)}`
7406
7913
  details: { error: message }
7407
7914
  });
7408
7915
  return {
7409
- 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
+ ],
7410
7922
  isError: true
7411
7923
  };
7412
7924
  }
@@ -7422,7 +7934,9 @@ ${rawText.slice(0, 1e3)}`
7422
7934
  async ({ plan_id, response_format }) => {
7423
7935
  const supabase = getSupabaseClient();
7424
7936
  const userId = await getDefaultUserId();
7425
- 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();
7426
7940
  if (error) {
7427
7941
  return {
7428
7942
  content: [
@@ -7437,7 +7951,10 @@ ${rawText.slice(0, 1e3)}`
7437
7951
  if (!data) {
7438
7952
  return {
7439
7953
  content: [
7440
- { 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
+ }
7441
7958
  ],
7442
7959
  isError: true
7443
7960
  };
@@ -7453,7 +7970,12 @@ ${rawText.slice(0, 1e3)}`
7453
7970
  };
7454
7971
  if (response_format === "json") {
7455
7972
  return {
7456
- 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
+ ],
7457
7979
  isError: false
7458
7980
  };
7459
7981
  }
@@ -7464,7 +7986,10 @@ ${rawText.slice(0, 1e3)}`
7464
7986
  `Status: ${data.status}`,
7465
7987
  `Posts: ${Array.isArray(plan?.posts) ? plan.posts.length : 0}`
7466
7988
  ];
7467
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
7989
+ return {
7990
+ content: [{ type: "text", text: lines.join("\n") }],
7991
+ isError: false
7992
+ };
7468
7993
  }
7469
7994
  );
7470
7995
  server.tool(
@@ -7507,14 +8032,19 @@ ${rawText.slice(0, 1e3)}`
7507
8032
  if (!stored?.plan_payload) {
7508
8033
  return {
7509
8034
  content: [
7510
- { 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
+ }
7511
8039
  ],
7512
8040
  isError: true
7513
8041
  };
7514
8042
  }
7515
8043
  const plan = stored.plan_payload;
7516
8044
  const existingPosts = Array.isArray(plan.posts) ? plan.posts : [];
7517
- 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
+ );
7518
8048
  const updatedPosts = existingPosts.map((post) => {
7519
8049
  const update = updatesById.get(post.id);
7520
8050
  if (!update) return post;
@@ -7532,7 +8062,9 @@ ${rawText.slice(0, 1e3)}`
7532
8062
  ...update.status !== void 0 ? { status: update.status } : {}
7533
8063
  };
7534
8064
  });
7535
- 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";
7536
8068
  const updatedPlan = {
7537
8069
  ...plan,
7538
8070
  posts: updatedPosts
@@ -7559,7 +8091,12 @@ ${rawText.slice(0, 1e3)}`
7559
8091
  };
7560
8092
  if (response_format === "json") {
7561
8093
  return {
7562
- 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
+ ],
7563
8100
  isError: false
7564
8101
  };
7565
8102
  }
@@ -7599,7 +8136,10 @@ ${rawText.slice(0, 1e3)}`
7599
8136
  if (!stored?.plan_payload || !stored.project_id) {
7600
8137
  return {
7601
8138
  content: [
7602
- { 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
+ }
7603
8143
  ],
7604
8144
  isError: true
7605
8145
  };
@@ -7608,7 +8148,12 @@ ${rawText.slice(0, 1e3)}`
7608
8148
  const posts = Array.isArray(plan.posts) ? plan.posts : [];
7609
8149
  if (posts.length === 0) {
7610
8150
  return {
7611
- 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
+ ],
7612
8157
  isError: true
7613
8158
  };
7614
8159
  }
@@ -7651,7 +8196,12 @@ ${rawText.slice(0, 1e3)}`
7651
8196
  };
7652
8197
  if (response_format === "json") {
7653
8198
  return {
7654
- 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
+ ],
7655
8205
  isError: false
7656
8206
  };
7657
8207
  }
@@ -7674,7 +8224,7 @@ import { z as z19 } from "zod";
7674
8224
  function asEnvelope15(data) {
7675
8225
  return {
7676
8226
  _meta: {
7677
- version: "0.2.0",
8227
+ version: MCP_VERSION,
7678
8228
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7679
8229
  },
7680
8230
  data
@@ -7713,14 +8263,24 @@ function registerPlanApprovalTools(server) {
7713
8263
  if (!projectId) {
7714
8264
  return {
7715
8265
  content: [
7716
- { 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
+ }
7717
8270
  ],
7718
8271
  isError: true
7719
8272
  };
7720
8273
  }
7721
- const accessError = await assertProjectAccess(supabase, userId, projectId);
8274
+ const accessError = await assertProjectAccess(
8275
+ supabase,
8276
+ userId,
8277
+ projectId
8278
+ );
7722
8279
  if (accessError) {
7723
- return { content: [{ type: "text", text: accessError }], isError: true };
8280
+ return {
8281
+ content: [{ type: "text", text: accessError }],
8282
+ isError: true
8283
+ };
7724
8284
  }
7725
8285
  const rows = posts.map((post) => ({
7726
8286
  plan_id,
@@ -7749,7 +8309,12 @@ function registerPlanApprovalTools(server) {
7749
8309
  };
7750
8310
  if ((response_format || "text") === "json") {
7751
8311
  return {
7752
- 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
+ ],
7753
8318
  isError: false
7754
8319
  };
7755
8320
  }
@@ -7798,14 +8363,22 @@ function registerPlanApprovalTools(server) {
7798
8363
  };
7799
8364
  if ((response_format || "text") === "json") {
7800
8365
  return {
7801
- 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
+ ],
7802
8372
  isError: false
7803
8373
  };
7804
8374
  }
7805
8375
  if (!data || data.length === 0) {
7806
8376
  return {
7807
8377
  content: [
7808
- { 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
+ }
7809
8382
  ],
7810
8383
  isError: false
7811
8384
  };
@@ -7818,7 +8391,10 @@ function registerPlanApprovalTools(server) {
7818
8391
  }
7819
8392
  lines.push("");
7820
8393
  lines.push(`Total: ${data.length}`);
7821
- return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
8394
+ return {
8395
+ content: [{ type: "text", text: lines.join("\n") }],
8396
+ isError: false
8397
+ };
7822
8398
  }
7823
8399
  );
7824
8400
  server.tool(
@@ -7837,7 +8413,10 @@ function registerPlanApprovalTools(server) {
7837
8413
  if (decision === "edited" && !edited_post) {
7838
8414
  return {
7839
8415
  content: [
7840
- { 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
+ }
7841
8420
  ],
7842
8421
  isError: true
7843
8422
  };
@@ -7850,7 +8429,9 @@ function registerPlanApprovalTools(server) {
7850
8429
  if (decision === "edited") {
7851
8430
  updates.edited_post = edited_post;
7852
8431
  }
7853
- 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();
7854
8435
  if (error) {
7855
8436
  return {
7856
8437
  content: [
@@ -7875,7 +8456,12 @@ function registerPlanApprovalTools(server) {
7875
8456
  }
7876
8457
  if ((response_format || "text") === "json") {
7877
8458
  return {
7878
- 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
+ ],
7879
8465
  isError: false
7880
8466
  };
7881
8467
  }
@@ -7940,7 +8526,7 @@ var TOOL_CATALOG = [
7940
8526
  name: "check_status",
7941
8527
  description: "Check status of async content generation job",
7942
8528
  module: "content",
7943
- scope: "mcp:write"
8529
+ scope: "mcp:read"
7944
8530
  },
7945
8531
  {
7946
8532
  name: "create_storyboard",
@@ -8373,12 +8959,34 @@ function getJWKS(supabaseUrl) {
8373
8959
  }
8374
8960
  return jwks;
8375
8961
  }
8962
+ var apiKeyCache = /* @__PURE__ */ new Map();
8963
+ var API_KEY_CACHE_TTL_MS = 6e4;
8376
8964
  function createTokenVerifier(options) {
8377
8965
  const { supabaseUrl, supabaseAnonKey } = options;
8378
8966
  return {
8379
8967
  async verifyAccessToken(token) {
8380
8968
  if (token.startsWith("snk_")) {
8381
- 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;
8382
8990
  }
8383
8991
  return verifySupabaseJwt(token, supabaseUrl);
8384
8992
  }
@@ -8458,6 +9066,12 @@ if (!SUPABASE_URL2 || !SUPABASE_ANON_KEY) {
8458
9066
  console.error("[MCP HTTP] Missing SUPABASE_URL or SUPABASE_ANON_KEY");
8459
9067
  process.exit(1);
8460
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
+ }
8461
9075
  process.on("uncaughtException", (err) => {
8462
9076
  console.error(`[MCP HTTP] Uncaught exception: ${err.message}`);
8463
9077
  process.exit(1);
@@ -8498,14 +9112,63 @@ var cleanupInterval = setInterval(
8498
9112
  5 * 60 * 1e3
8499
9113
  );
8500
9114
  var app = express();
9115
+ app.disable("x-powered-by");
8501
9116
  app.use(express.json());
8502
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
+ });
8503
9164
  app.use((_req, res, next) => {
8504
9165
  res.setHeader("Access-Control-Allow-Origin", "*");
8505
9166
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
8506
- 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
+ );
8507
9171
  res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
8508
- res.setHeader("Vary", "Origin");
8509
9172
  if (_req.method === "OPTIONS") {
8510
9173
  res.status(204).end();
8511
9174
  return;
@@ -8540,9 +9203,16 @@ async function authenticateRequest(req, res, next) {
8540
9203
  const token = authHeader.slice(7);
8541
9204
  try {
8542
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
+ }
8543
9213
  req.auth = {
8544
9214
  userId: authInfo.extra?.userId ?? authInfo.clientId,
8545
- scopes: authInfo.scopes,
9215
+ scopes,
8546
9216
  clientId: authInfo.clientId,
8547
9217
  token: authInfo.token
8548
9218
  };
@@ -8558,97 +9228,115 @@ async function authenticateRequest(req, res, next) {
8558
9228
  app.get("/health", (_req, res) => {
8559
9229
  res.json({ status: "ok", version: MCP_VERSION });
8560
9230
  });
8561
- app.get("/health/details", authenticateRequest, (_req, res) => {
8562
- res.json({
8563
- status: "ok",
8564
- version: MCP_VERSION,
8565
- transport: "streamable-http",
8566
- sessions: sessions.size,
8567
- sessionCap: MAX_SESSIONS,
8568
- uptime: Math.floor(process.uptime()),
8569
- memory: Math.round(process.memoryUsage().rss / 1024 / 1024),
8570
- env: NODE_ENV
8571
- });
8572
- });
8573
- app.post("/mcp", authenticateRequest, async (req, res) => {
8574
- const auth = req.auth;
8575
- const existingSessionId = req.headers["mcp-session-id"];
8576
- const rl = checkRateLimit("read", auth.userId);
8577
- if (!rl.allowed) {
8578
- res.setHeader("Retry-After", String(rl.retryAfter));
8579
- res.status(429).json({
8580
- error: "rate_limited",
8581
- error_description: "Too many requests. Please slow down.",
8582
- 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
8583
9244
  });
8584
- return;
8585
9245
  }
8586
- try {
8587
- if (existingSessionId && sessions.has(existingSessionId)) {
8588
- const entry = sessions.get(existingSessionId);
8589
- if (entry.userId !== auth.userId) {
8590
- res.status(403).json({
8591
- error: "forbidden",
8592
- error_description: "Session belongs to another user"
8593
- });
8594
- return;
8595
- }
8596
- entry.lastActivity = Date.now();
8597
- await requestContext.run(
8598
- { userId: auth.userId, scopes: auth.scopes, creditsUsed: 0, assetsGenerated: 0 },
8599
- () => entry.transport.handleRequest(req, res, req.body)
8600
- );
8601
- return;
8602
- }
8603
- if (sessions.size >= MAX_SESSIONS) {
8604
- res.status(429).json({
8605
- error: "too_many_sessions",
8606
- error_description: `Server session limit reached (${MAX_SESSIONS}). Try again later.`
8607
- });
8608
- return;
8609
- }
8610
- 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));
8611
9256
  res.status(429).json({
8612
- error: "too_many_sessions",
8613
- 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
8614
9260
  });
8615
9261
  return;
8616
9262
  }
8617
- const server = new McpServer({
8618
- name: "socialneuron",
8619
- version: MCP_VERSION
8620
- });
8621
- applyScopeEnforcement(server, () => getRequestScopes() ?? auth.scopes);
8622
- registerAllTools(server, { skipScreenshots: true });
8623
- const transport = new StreamableHTTPServerTransport({
8624
- sessionIdGenerator: () => randomUUID3(),
8625
- onsessioninitialized: (sessionId) => {
8626
- sessions.set(sessionId, {
8627
- transport,
8628
- server,
8629
- lastActivity: Date.now(),
8630
- 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.`
8631
9289
  });
9290
+ return;
8632
9291
  }
8633
- });
8634
- transport.onclose = () => {
8635
- if (transport.sessionId) {
8636
- 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 } });
8637
9336
  }
8638
- };
8639
- await server.connect(transport);
8640
- await requestContext.run(
8641
- { userId: auth.userId, scopes: auth.scopes, creditsUsed: 0, assetsGenerated: 0 },
8642
- () => transport.handleRequest(req, res, req.body)
8643
- );
8644
- } catch (err) {
8645
- const message = err instanceof Error ? err.message : "Internal server error";
8646
- console.error(`[MCP HTTP] POST /mcp error: ${message}`);
8647
- if (!res.headersSent) {
8648
- res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message } });
8649
9337
  }
8650
9338
  }
8651
- });
9339
+ );
8652
9340
  app.get("/mcp", authenticateRequest, async (req, res) => {
8653
9341
  const sessionId = req.headers["mcp-session-id"];
8654
9342
  if (!sessionId || !sessions.has(sessionId)) {
@@ -8664,28 +9352,39 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
8664
9352
  res.setHeader("X-Accel-Buffering", "no");
8665
9353
  res.setHeader("Cache-Control", "no-cache");
8666
9354
  await requestContext.run(
8667
- { 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
+ },
8668
9361
  () => entry.transport.handleRequest(req, res)
8669
9362
  );
8670
9363
  });
8671
- app.delete("/mcp", authenticateRequest, async (req, res) => {
8672
- const sessionId = req.headers["mcp-session-id"];
8673
- if (!sessionId || !sessions.has(sessionId)) {
8674
- res.status(400).json({ error: "Invalid or missing session ID" });
8675
- return;
8676
- }
8677
- const entry = sessions.get(sessionId);
8678
- if (entry.userId !== req.auth.userId) {
8679
- res.status(403).json({ error: "Session belongs to another user" });
8680
- 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" });
8681
9382
  }
8682
- await entry.transport.close();
8683
- await entry.server.close();
8684
- sessions.delete(sessionId);
8685
- res.status(200).json({ status: "session_closed" });
8686
- });
9383
+ );
8687
9384
  var httpServer = app.listen(PORT, "0.0.0.0", () => {
8688
- 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
+ );
8689
9388
  console.log(`[MCP HTTP] Health: http://localhost:${PORT}/health`);
8690
9389
  console.log(`[MCP HTTP] MCP endpoint: ${MCP_SERVER_URL}`);
8691
9390
  console.log(`[MCP HTTP] Environment: ${NODE_ENV}`);