@socialneuron/mcp-server 1.7.4 → 1.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js CHANGED
@@ -647,7 +647,12 @@ var TOOL_SCOPES = {
647
647
  suggest_next_content: "mcp:read",
648
648
  // mcp:analytics (digest and anomalies are analytics-scoped)
649
649
  generate_performance_digest: "mcp:analytics",
650
- detect_anomalies: "mcp:analytics"
650
+ detect_anomalies: "mcp:analytics",
651
+ // mcp:read (Apps — entry tool for the Content Calendar MCP App; reads recent posts)
652
+ open_content_calendar: "mcp:read",
653
+ // platform connection deep-link flow
654
+ start_platform_connection: "mcp:distribute",
655
+ wait_for_connection: "mcp:read"
651
656
  };
652
657
  function hasScope(userScopes, required) {
653
658
  if (userScopes.includes(required)) return true;
@@ -1383,7 +1388,7 @@ init_supabase();
1383
1388
  init_request_context();
1384
1389
 
1385
1390
  // src/lib/version.ts
1386
- var MCP_VERSION = "1.7.4";
1391
+ var MCP_VERSION = "1.7.9";
1387
1392
 
1388
1393
  // src/tools/content.ts
1389
1394
  var MAX_CREDITS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
@@ -2551,7 +2556,7 @@ var ERROR_PATTERNS = [
2551
2556
  // Generic sensitive patterns (API keys, URLs with secrets)
2552
2557
  [/[a-z0-9]{32,}.*key|Bearer [a-zA-Z0-9._-]+/i, "An internal error occurred. Please try again."]
2553
2558
  ];
2554
- function sanitizeError2(error) {
2559
+ function sanitizeError(error) {
2555
2560
  const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
2556
2561
  if (process.env.NODE_ENV !== "production") {
2557
2562
  console.error("[Error]", msg);
@@ -2564,6 +2569,171 @@ function sanitizeError2(error) {
2564
2569
  return "An unexpected error occurred. Please try again.";
2565
2570
  }
2566
2571
 
2572
+ // src/lib/ssrf.ts
2573
+ var BLOCKED_IP_PATTERNS = [
2574
+ // IPv4 localhost/loopback
2575
+ /^127\./,
2576
+ /^0\./,
2577
+ // IPv4 private ranges (RFC 1918)
2578
+ /^10\./,
2579
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
2580
+ /^192\.168\./,
2581
+ // IPv4 link-local
2582
+ /^169\.254\./,
2583
+ // Cloud metadata endpoint (AWS, GCP, Azure)
2584
+ /^169\.254\.169\.254$/,
2585
+ // IPv4 broadcast
2586
+ /^255\./,
2587
+ // Shared address space (RFC 6598)
2588
+ /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
2589
+ ];
2590
+ var BLOCKED_IPV6_PATTERNS = [
2591
+ /^::1$/i,
2592
+ // loopback
2593
+ /^::$/i,
2594
+ // unspecified
2595
+ /^fe[89ab][0-9a-f]:/i,
2596
+ // link-local fe80::/10
2597
+ /^fc[0-9a-f]:/i,
2598
+ // unique local fc00::/7
2599
+ /^fd[0-9a-f]:/i,
2600
+ // unique local fc00::/7
2601
+ /^::ffff:127\./i,
2602
+ // IPv4-mapped localhost
2603
+ /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
2604
+ // IPv4-mapped private
2605
+ ];
2606
+ var BLOCKED_HOSTNAMES = [
2607
+ "localhost",
2608
+ "localhost.localdomain",
2609
+ "local",
2610
+ "127.0.0.1",
2611
+ "0.0.0.0",
2612
+ "[::1]",
2613
+ "[::ffff:127.0.0.1]",
2614
+ // Cloud metadata endpoints
2615
+ "metadata.google.internal",
2616
+ "metadata.goog",
2617
+ "instance-data",
2618
+ "instance-data.ec2.internal"
2619
+ ];
2620
+ var ALLOWED_PROTOCOLS = ["http:", "https:"];
2621
+ var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
2622
+ function isBlockedIP(ip) {
2623
+ const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
2624
+ if (normalized.includes(":")) {
2625
+ return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
2626
+ }
2627
+ return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
2628
+ }
2629
+ function isBlockedHostname(hostname) {
2630
+ return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
2631
+ }
2632
+ function isIPAddress(hostname) {
2633
+ const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
2634
+ const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
2635
+ return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
2636
+ }
2637
+ async function validateUrlForSSRF(urlString) {
2638
+ try {
2639
+ const url = new URL(urlString);
2640
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
2641
+ return {
2642
+ isValid: false,
2643
+ error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
2644
+ };
2645
+ }
2646
+ if (url.username || url.password) {
2647
+ return {
2648
+ isValid: false,
2649
+ error: "URLs with embedded credentials are not allowed."
2650
+ };
2651
+ }
2652
+ const hostname = url.hostname.toLowerCase();
2653
+ if (isBlockedHostname(hostname)) {
2654
+ return {
2655
+ isValid: false,
2656
+ error: "Access to internal/localhost addresses is not allowed."
2657
+ };
2658
+ }
2659
+ if (isIPAddress(hostname) && isBlockedIP(hostname)) {
2660
+ return {
2661
+ isValid: false,
2662
+ error: "Access to private/internal IP addresses is not allowed."
2663
+ };
2664
+ }
2665
+ const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
2666
+ if (BLOCKED_PORTS.includes(port)) {
2667
+ return {
2668
+ isValid: false,
2669
+ error: `Access to port ${port} is not allowed.`
2670
+ };
2671
+ }
2672
+ let resolvedIP;
2673
+ if (!isIPAddress(hostname)) {
2674
+ try {
2675
+ const dns = await import("node:dns");
2676
+ const resolver = new dns.promises.Resolver();
2677
+ const resolvedIPs = [];
2678
+ try {
2679
+ const aRecords = await resolver.resolve4(hostname);
2680
+ resolvedIPs.push(...aRecords);
2681
+ } catch {
2682
+ }
2683
+ try {
2684
+ const aaaaRecords = await resolver.resolve6(hostname);
2685
+ resolvedIPs.push(...aaaaRecords);
2686
+ } catch {
2687
+ }
2688
+ if (resolvedIPs.length === 0) {
2689
+ return {
2690
+ isValid: false,
2691
+ error: "DNS resolution failed: hostname did not resolve to any address."
2692
+ };
2693
+ }
2694
+ for (const ip of resolvedIPs) {
2695
+ if (isBlockedIP(ip)) {
2696
+ return {
2697
+ isValid: false,
2698
+ error: "Hostname resolves to a private/internal IP address."
2699
+ };
2700
+ }
2701
+ }
2702
+ resolvedIP = resolvedIPs[0];
2703
+ } catch {
2704
+ return {
2705
+ isValid: false,
2706
+ error: "DNS resolution failed. Cannot verify hostname safety."
2707
+ };
2708
+ }
2709
+ }
2710
+ return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
2711
+ } catch (error) {
2712
+ return {
2713
+ isValid: false,
2714
+ error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
2715
+ };
2716
+ }
2717
+ }
2718
+ function quickSSRFCheck(urlString) {
2719
+ try {
2720
+ const url = new URL(urlString);
2721
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
2722
+ return { isValid: false, error: `Invalid protocol: ${url.protocol}` };
2723
+ }
2724
+ if (url.username || url.password) {
2725
+ return { isValid: false, error: "URLs with credentials not allowed" };
2726
+ }
2727
+ const hostname = url.hostname.toLowerCase();
2728
+ if (isBlockedHostname(hostname) || isIPAddress(hostname) && isBlockedIP(hostname)) {
2729
+ return { isValid: false, error: "Access to internal addresses not allowed" };
2730
+ }
2731
+ return { isValid: true, sanitizedUrl: url.toString() };
2732
+ } catch {
2733
+ return { isValid: false, error: "Invalid URL format" };
2734
+ }
2735
+ }
2736
+
2567
2737
  // src/tools/distribution.ts
2568
2738
  init_supabase();
2569
2739
 
@@ -2725,16 +2895,42 @@ function asEnvelope2(data) {
2725
2895
  data
2726
2896
  };
2727
2897
  }
2898
+ function isAlreadyR2Signed(url) {
2899
+ try {
2900
+ const u = new URL(url);
2901
+ return u.searchParams.has("X-Amz-Signature");
2902
+ } catch {
2903
+ return false;
2904
+ }
2905
+ }
2906
+ async function rehostExternalUrl(mediaUrl, projectId) {
2907
+ if (isAlreadyR2Signed(mediaUrl)) {
2908
+ return { signedUrl: mediaUrl, r2Key: "" };
2909
+ }
2910
+ const ssrf = quickSSRFCheck(mediaUrl);
2911
+ if (!ssrf.isValid) {
2912
+ return { error: ssrf.error ?? "URL rejected by SSRF check" };
2913
+ }
2914
+ const { data, error } = await callEdgeFunction(
2915
+ "upload-to-r2",
2916
+ { url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
2917
+ { timeoutMs: 6e4 }
2918
+ );
2919
+ if (error || !data?.key || !data?.url) {
2920
+ return { error: error ?? "upload-to-r2 returned no key" };
2921
+ }
2922
+ return { signedUrl: data.url, r2Key: data.key };
2923
+ }
2728
2924
  function registerDistributionTools(server) {
2729
2925
  server.tool(
2730
2926
  "schedule_post",
2731
- 'Publish or schedule a post to connected social platforms. Check list_connected_accounts first to verify active OAuth for each target platform. For Instagram carousels: use media_type=CAROUSEL_ALBUM with 2-10 media_urls. For YouTube: title is required. schedule_at uses ISO 8601 (e.g. "2026-03-20T14:00:00Z") \u2014 omit to post immediately.',
2927
+ 'Publish or schedule a post to connected social platforms. ALWAYS call `list_connected_accounts` FIRST \u2014 if the target platform is not connected, call `start_platform_connection` to get a one-time browser deep link the user opens to complete the platform OAuth (this is a one-time setup on socialneuron.com, not another OAuth in Claude). After they approve, call `wait_for_connection` and only then call schedule_post. For Instagram carousels: use media_type=CAROUSEL_ALBUM with 2-10 media_urls. For YouTube: title is required. schedule_at uses ISO 8601 (e.g. "2026-03-20T14:00:00Z") \u2014 omit to post immediately.',
2732
2928
  {
2733
2929
  media_url: z3.string().optional().describe(
2734
- "URL of the media file to post. Public URL or R2 signed URL. Not needed if media_urls, r2_key, or job_id is provided."
2930
+ "URL of the media file to post. Any public HTTPS URL works \u2014 including ephemeral generator URLs (Replicate, OpenAI, DALL-E). The server persists non-R2 URLs into R2 before posting so scheduled posts and byte-upload platforms (X, LinkedIn, YouTube, Bluesky) do not 404 when the source URL expires. Set auto_rehost=false to skip. Not needed if media_urls, r2_key, or job_id is provided."
2735
2931
  ),
2736
2932
  media_urls: z3.array(z3.string()).optional().describe(
2737
- "Array of 2-10 image URLs for carousel posts. Each must be publicly accessible or R2 signed URL. Use with media_type=CAROUSEL_ALBUM."
2933
+ "Array of 2-10 image URLs for carousel posts. Same rehosting rules as media_url \u2014 ephemeral URLs are persisted automatically. Use with media_type=CAROUSEL_ALBUM."
2738
2934
  ),
2739
2935
  r2_key: z3.string().optional().describe(
2740
2936
  "R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
@@ -2823,7 +3019,10 @@ function registerDistributionTools(server) {
2823
3019
  ),
2824
3020
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
2825
3021
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
2826
- attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.')
3022
+ attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.'),
3023
+ auto_rehost: z3.boolean().optional().describe(
3024
+ "Whether to persist non-R2 media_url/media_urls into R2 before posting. Default: true. Set to false only if you know the source URL will outlive the scheduling window and every target platform supports URL ingest."
3025
+ )
2827
3026
  },
2828
3027
  async ({
2829
3028
  media_url,
@@ -2837,6 +3036,7 @@ function registerDistributionTools(server) {
2837
3036
  project_id,
2838
3037
  response_format,
2839
3038
  attribution,
3039
+ auto_rehost,
2840
3040
  r2_key,
2841
3041
  r2_keys,
2842
3042
  job_id,
@@ -2948,12 +3148,54 @@ function registerDistributionTools(server) {
2948
3148
  }
2949
3149
  resolvedMediaUrls = resolved;
2950
3150
  }
3151
+ const shouldRehost = auto_rehost !== false;
3152
+ if (shouldRehost && resolvedMediaUrl && !isAlreadyR2Signed(resolvedMediaUrl)) {
3153
+ const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
3154
+ if ("error" in rehost) {
3155
+ return {
3156
+ content: [
3157
+ {
3158
+ type: "text",
3159
+ text: `Failed to persist media URL into R2: ${rehost.error}. Try upload_media first and pass r2_key instead, or set auto_rehost=false if you're sure the URL is publicly durable and every target platform accepts URL ingest.`
3160
+ }
3161
+ ],
3162
+ isError: true
3163
+ };
3164
+ }
3165
+ resolvedMediaUrl = rehost.signedUrl;
3166
+ }
3167
+ if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
3168
+ const needsRehost = resolvedMediaUrls.map((u) => !isAlreadyR2Signed(u));
3169
+ if (needsRehost.some(Boolean)) {
3170
+ const rehosted = await Promise.all(
3171
+ resolvedMediaUrls.map(
3172
+ (u, i) => needsRehost[i] ? rehostExternalUrl(u, project_id) : Promise.resolve({ signedUrl: u, r2Key: "" })
3173
+ )
3174
+ );
3175
+ const failIdx = rehosted.findIndex((r) => "error" in r);
3176
+ if (failIdx !== -1) {
3177
+ const failed = rehosted[failIdx];
3178
+ return {
3179
+ content: [
3180
+ {
3181
+ type: "text",
3182
+ text: `Failed to persist media_urls[${failIdx}] into R2: ${failed.error}. Try upload_media first and pass r2_keys instead, or set auto_rehost=false.`
3183
+ }
3184
+ ],
3185
+ isError: true
3186
+ };
3187
+ }
3188
+ resolvedMediaUrls = rehosted.map(
3189
+ (r) => r.signedUrl
3190
+ );
3191
+ }
3192
+ }
2951
3193
  } catch (resolveErr) {
2952
3194
  return {
2953
3195
  content: [
2954
3196
  {
2955
3197
  type: "text",
2956
- text: `Failed to resolve media: ${sanitizeError2(resolveErr)}`
3198
+ text: `Failed to resolve media: ${sanitizeError(resolveErr)}`
2957
3199
  }
2958
3200
  ],
2959
3201
  isError: true
@@ -3318,7 +3560,7 @@ Created with Social Neuron`;
3318
3560
  return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
3319
3561
  } catch (err) {
3320
3562
  const durationMs = Date.now() - startedAt;
3321
- const message = sanitizeError2(err);
3563
+ const message = sanitizeError(err);
3322
3564
  logMcpToolInvocation({
3323
3565
  toolName: "find_next_slots",
3324
3566
  status: "error",
@@ -3838,7 +4080,7 @@ Created with Social Neuron`;
3838
4080
  };
3839
4081
  } catch (err) {
3840
4082
  const durationMs = Date.now() - startedAt;
3841
- const message = sanitizeError2(err);
4083
+ const message = sanitizeError(err);
3842
4084
  logMcpToolInvocation({
3843
4085
  toolName: "schedule_content_plan",
3844
4086
  status: "error",
@@ -3860,6 +4102,19 @@ import { readFile } from "node:fs/promises";
3860
4102
  import { basename, extname } from "node:path";
3861
4103
  init_supabase();
3862
4104
  var MAX_BASE64_SIZE = 10 * 1024 * 1024;
4105
+ var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
4106
+ "image/png",
4107
+ "image/jpeg",
4108
+ "image/gif",
4109
+ "image/webp",
4110
+ "image/svg+xml",
4111
+ "video/mp4",
4112
+ "video/quicktime",
4113
+ "video/x-msvideo",
4114
+ "video/webm"
4115
+ ]);
4116
+ var BASE64_CHARS = /^[A-Za-z0-9+/]+={0,2}$/;
4117
+ var DATA_URI_PREFIX = /^data:([^;,]+);base64,/;
3863
4118
  function maskR2Key(key) {
3864
4119
  const segments = key.split("/");
3865
4120
  return segments.length >= 3 ? `\u2026/${segments.slice(-2).join("/")}` : key;
@@ -3880,21 +4135,35 @@ function inferContentType(filePath) {
3880
4135
  };
3881
4136
  return map[ext] || "application/octet-stream";
3882
4137
  }
4138
+ function approxBase64Size(raw) {
4139
+ const len = raw.length;
4140
+ if (len === 0) return 0;
4141
+ let padding = 0;
4142
+ if (raw.endsWith("==")) padding = 2;
4143
+ else if (raw.endsWith("=")) padding = 1;
4144
+ return Math.floor(len * 3 / 4) - padding;
4145
+ }
3883
4146
  function registerMediaTools(server) {
3884
4147
  server.tool(
3885
4148
  "upload_media",
3886
- "Upload a local file or external URL to persistent R2 storage. Returns a durable r2_key that can be passed to schedule_post. Use for images, videos, or any media that needs to be posted to social platforms. Accepts local file paths (in MCP stdio mode) or public URLs. Max 10MB for base64 upload \u2014 larger files return an error with guidance.",
4149
+ "Upload media to persistent R2 storage. Returns a durable r2_key that can be passed to schedule_post. Three input modes: (1) local file path (stdio mode only), (2) public URL fetched by the server, (3) inline base64 via file_data (remote agents, \u226410MB decoded). AGENT ROUTING GUIDE: If the media was produced by another tool here (generate_image, generate_video, create_carousel, etc.), use the returned job_id or r2_key directly with schedule_post \u2014 do NOT download and re-upload. For user-authored files larger than ~1MB, prefer request_upload_session (returns a tokenized Dashboard URL the user uploads through in their browser) so bytes never flow through the agent context. Reserve file_data for small assets (thumbnails, logos, short clips).",
3887
4150
  {
3888
- source: z4.string().describe(
3889
- 'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg"). Local files are read and uploaded as base64. URLs are fetched by the server.'
4151
+ source: z4.string().optional().describe(
4152
+ 'Local file path (e.g. "/Users/me/image.png") or public URL (e.g. "https://example.com/photo.jpg"). Leave empty when passing file_data instead.'
4153
+ ),
4154
+ file_data: z4.string().optional().describe(
4155
+ 'Base64-encoded file bytes, with or without a "data:<mime>;base64," prefix. Use this from remote agents (Claude Web/Desktop) that cannot provide a filesystem path. Max 10MB decoded.'
4156
+ ),
4157
+ file_name: z4.string().optional().describe(
4158
+ 'Optional filename for the upload (e.g. "hero.png"). Path components are stripped \u2014 only the basename is used.'
3890
4159
  ),
3891
4160
  content_type: z4.string().optional().describe(
3892
- 'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension if omitted.'
4161
+ 'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension for paths/URLs, or from the data: prefix on file_data. Required when passing raw base64 with no prefix.'
3893
4162
  ),
3894
4163
  project_id: z4.string().optional().describe("Project ID for R2 path organization."),
3895
4164
  response_format: z4.enum(["text", "json"]).optional().describe("Response format. Default: text.")
3896
4165
  },
3897
- async ({ source, content_type, project_id, response_format }) => {
4166
+ async ({ source, file_data, file_name, content_type, project_id, response_format }) => {
3898
4167
  const format = response_format ?? "text";
3899
4168
  const startedAt = Date.now();
3900
4169
  const userId = await getDefaultUserId();
@@ -3916,46 +4185,187 @@ function registerMediaTools(server) {
3916
4185
  isError: true
3917
4186
  };
3918
4187
  }
3919
- const isUrl = source.startsWith("http://") || source.startsWith("https://");
3920
- const isLocalFile = !isUrl;
4188
+ if (!source && !file_data) {
4189
+ return {
4190
+ content: [
4191
+ {
4192
+ type: "text",
4193
+ text: "upload_media requires either `source` (path or URL) or `file_data` (base64)."
4194
+ }
4195
+ ],
4196
+ isError: true
4197
+ };
4198
+ }
4199
+ if (file_data) {
4200
+ let raw = file_data;
4201
+ let detectedType;
4202
+ const prefixMatch = raw.match(DATA_URI_PREFIX);
4203
+ if (prefixMatch) {
4204
+ detectedType = prefixMatch[1].trim().toLowerCase();
4205
+ raw = raw.slice(prefixMatch[0].length);
4206
+ }
4207
+ const ct = (content_type ?? detectedType ?? "").trim().toLowerCase();
4208
+ if (!ct) {
4209
+ return {
4210
+ content: [
4211
+ {
4212
+ type: "text",
4213
+ text: "content_type is required when file_data has no data: prefix."
4214
+ }
4215
+ ],
4216
+ isError: true
4217
+ };
4218
+ }
4219
+ if (!ALLOWED_UPLOAD_TYPES.has(ct)) {
4220
+ return {
4221
+ content: [
4222
+ {
4223
+ type: "text",
4224
+ text: `content_type "${ct}" is not supported. Allowed: ${[...ALLOWED_UPLOAD_TYPES].sort().join(", ")}.`
4225
+ }
4226
+ ],
4227
+ isError: true
4228
+ };
4229
+ }
4230
+ const stripped = raw.replace(/\s+/g, "");
4231
+ if (!BASE64_CHARS.test(stripped)) {
4232
+ return {
4233
+ content: [
4234
+ {
4235
+ type: "text",
4236
+ text: "file_data is not valid base64 \u2014 only A-Z, a-z, 0-9, +, /, = are allowed."
4237
+ }
4238
+ ],
4239
+ isError: true
4240
+ };
4241
+ }
4242
+ const approxSize = approxBase64Size(stripped);
4243
+ if (approxSize > MAX_BASE64_SIZE) {
4244
+ return {
4245
+ content: [
4246
+ {
4247
+ type: "text",
4248
+ text: `file_data exceeds the 10MB base64 cap (got ~${(approxSize / 1024 / 1024).toFixed(1)}MB). Alternatives, in order of preference: (1) if this media came from another tool here (generate_image/video, create_carousel), pass its job_id or r2_key directly to schedule_post \u2014 do not re-upload. (2) for user-authored files, call request_upload_session to get a tokenized Dashboard URL where the user uploads directly to R2 in their browser. (3) for stdio/local mode, pass a filesystem path via \`source\` so the server can stream and use presigned PUT.`
4249
+ }
4250
+ ],
4251
+ isError: true
4252
+ };
4253
+ }
4254
+ const safeName = basename(file_name ?? "upload");
4255
+ const uploadBody2 = {
4256
+ fileData: `data:${ct};base64,${stripped}`,
4257
+ contentType: ct,
4258
+ fileName: safeName,
4259
+ projectId: project_id
4260
+ };
4261
+ const { data: data2, error: error2 } = await callEdgeFunction("upload-to-r2", uploadBody2, { timeoutMs: 6e4 });
4262
+ if (error2) {
4263
+ await logMcpToolInvocation({
4264
+ toolName: "upload_media",
4265
+ status: "error",
4266
+ durationMs: Date.now() - startedAt,
4267
+ details: { error: error2, source: "base64", contentType: ct, size: approxSize }
4268
+ });
4269
+ return {
4270
+ content: [{ type: "text", text: `Upload failed: ${error2}` }],
4271
+ isError: true
4272
+ };
4273
+ }
4274
+ if (!data2?.key) {
4275
+ return {
4276
+ content: [{ type: "text", text: "Upload returned no R2 key." }],
4277
+ isError: true
4278
+ };
4279
+ }
4280
+ await logMcpToolInvocation({
4281
+ toolName: "upload_media",
4282
+ status: "success",
4283
+ durationMs: Date.now() - startedAt,
4284
+ details: {
4285
+ source: "base64",
4286
+ r2Key: data2.key,
4287
+ size: data2.size,
4288
+ contentType: data2.contentType
4289
+ }
4290
+ });
4291
+ if (format === "json") {
4292
+ return {
4293
+ content: [
4294
+ {
4295
+ type: "text",
4296
+ text: JSON.stringify(
4297
+ {
4298
+ r2_key: data2.key,
4299
+ signed_url: data2.url,
4300
+ size: data2.size,
4301
+ content_type: data2.contentType
4302
+ },
4303
+ null,
4304
+ 2
4305
+ )
4306
+ }
4307
+ ],
4308
+ isError: false
4309
+ };
4310
+ }
4311
+ return {
4312
+ content: [
4313
+ {
4314
+ type: "text",
4315
+ text: [
4316
+ "Media uploaded successfully.",
4317
+ `Media key: ${maskR2Key(data2.key)}`,
4318
+ `Signed URL: ${data2.url}`,
4319
+ `Size: ${(data2.size / 1024).toFixed(0)}KB`,
4320
+ `Type: ${data2.contentType}`,
4321
+ "",
4322
+ "Use job_id or response_format=json with schedule_post to post to any platform."
4323
+ ].join("\n")
4324
+ }
4325
+ ],
4326
+ isError: false
4327
+ };
4328
+ }
4329
+ const src = source;
4330
+ const isUrl = src.startsWith("http://") || src.startsWith("https://");
3921
4331
  let uploadBody;
3922
4332
  if (isUrl) {
3923
- const ct = content_type || inferContentType(source);
4333
+ const ct = content_type || inferContentType(src);
3924
4334
  uploadBody = {
3925
- url: source,
4335
+ url: src,
3926
4336
  contentType: ct,
3927
- fileName: basename(new URL(source).pathname) || "upload",
4337
+ fileName: basename(file_name ?? new URL(src).pathname) || "upload",
3928
4338
  projectId: project_id
3929
4339
  };
3930
4340
  } else {
3931
4341
  let fileBuffer;
3932
4342
  try {
3933
- fileBuffer = await readFile(source);
3934
- } catch (err) {
4343
+ fileBuffer = await readFile(src);
4344
+ } catch {
3935
4345
  await logMcpToolInvocation({
3936
4346
  toolName: "upload_media",
3937
4347
  status: "error",
3938
4348
  durationMs: Date.now() - startedAt,
3939
- details: { error: `File not found: ${source}` }
4349
+ details: { error: "File not found", source: "local" }
3940
4350
  });
3941
4351
  return {
3942
4352
  content: [
3943
4353
  {
3944
4354
  type: "text",
3945
- text: `File not found or not readable: ${source}`
4355
+ text: `File not found or not readable: ${src}. Remote agents (Claude Web/Desktop) cannot see the MCP server's filesystem \u2014 pass the bytes via the \`file_data\` parameter (base64, up to 10MB) instead.`
3946
4356
  }
3947
4357
  ],
3948
4358
  isError: true
3949
4359
  };
3950
4360
  }
3951
- const ct = content_type || inferContentType(source);
4361
+ const ct = content_type || inferContentType(src);
3952
4362
  if (fileBuffer.length > MAX_BASE64_SIZE) {
3953
4363
  const { data: putData, error: putError } = await callEdgeFunction(
3954
4364
  "get-signed-url",
3955
4365
  {
3956
4366
  operation: "put",
3957
4367
  contentType: ct,
3958
- filename: basename(source),
4368
+ filename: basename(file_name ?? src),
3959
4369
  projectId: project_id
3960
4370
  },
3961
4371
  { timeoutMs: 1e4 }
@@ -4057,7 +4467,7 @@ function registerMediaTools(server) {
4057
4467
  uploadBody = {
4058
4468
  fileData: base64,
4059
4469
  contentType: ct,
4060
- fileName: basename(source),
4470
+ fileName: basename(file_name ?? src),
4061
4471
  projectId: project_id
4062
4472
  };
4063
4473
  }
@@ -4429,172 +4839,23 @@ function formatAnalytics(summary, days, format) {
4429
4839
  lines.push("Top Posts:");
4430
4840
  const sorted = [...summary.posts].sort((a, b) => b.views - a.views);
4431
4841
  for (const post of sorted.slice(0, 10)) {
4432
- const title = post.title || "(untitled)";
4433
- let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
4434
- if (post.content_type || post.model_used) {
4435
- const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
4436
- line += ` (${meta})`;
4437
- }
4438
- lines.push(line);
4439
- }
4440
- }
4441
- return {
4442
- content: [{ type: "text", text: lines.join("\n") }]
4443
- };
4444
- }
4445
-
4446
- // src/tools/brand.ts
4447
- import { z as z6 } from "zod";
4448
- init_supabase();
4449
-
4450
- // src/lib/ssrf.ts
4451
- var BLOCKED_IP_PATTERNS = [
4452
- // IPv4 localhost/loopback
4453
- /^127\./,
4454
- /^0\./,
4455
- // IPv4 private ranges (RFC 1918)
4456
- /^10\./,
4457
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
4458
- /^192\.168\./,
4459
- // IPv4 link-local
4460
- /^169\.254\./,
4461
- // Cloud metadata endpoint (AWS, GCP, Azure)
4462
- /^169\.254\.169\.254$/,
4463
- // IPv4 broadcast
4464
- /^255\./,
4465
- // Shared address space (RFC 6598)
4466
- /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
4467
- ];
4468
- var BLOCKED_IPV6_PATTERNS = [
4469
- /^::1$/i,
4470
- // loopback
4471
- /^::$/i,
4472
- // unspecified
4473
- /^fe[89ab][0-9a-f]:/i,
4474
- // link-local fe80::/10
4475
- /^fc[0-9a-f]:/i,
4476
- // unique local fc00::/7
4477
- /^fd[0-9a-f]:/i,
4478
- // unique local fc00::/7
4479
- /^::ffff:127\./i,
4480
- // IPv4-mapped localhost
4481
- /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
4482
- // IPv4-mapped private
4483
- ];
4484
- var BLOCKED_HOSTNAMES = [
4485
- "localhost",
4486
- "localhost.localdomain",
4487
- "local",
4488
- "127.0.0.1",
4489
- "0.0.0.0",
4490
- "[::1]",
4491
- "[::ffff:127.0.0.1]",
4492
- // Cloud metadata endpoints
4493
- "metadata.google.internal",
4494
- "metadata.goog",
4495
- "instance-data",
4496
- "instance-data.ec2.internal"
4497
- ];
4498
- var ALLOWED_PROTOCOLS = ["http:", "https:"];
4499
- var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
4500
- function isBlockedIP(ip) {
4501
- const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
4502
- if (normalized.includes(":")) {
4503
- return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
4504
- }
4505
- return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
4506
- }
4507
- function isBlockedHostname(hostname) {
4508
- return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
4509
- }
4510
- function isIPAddress(hostname) {
4511
- const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
4512
- const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
4513
- return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
4514
- }
4515
- async function validateUrlForSSRF(urlString) {
4516
- try {
4517
- const url = new URL(urlString);
4518
- if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
4519
- return {
4520
- isValid: false,
4521
- error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
4522
- };
4523
- }
4524
- if (url.username || url.password) {
4525
- return {
4526
- isValid: false,
4527
- error: "URLs with embedded credentials are not allowed."
4528
- };
4529
- }
4530
- const hostname = url.hostname.toLowerCase();
4531
- if (isBlockedHostname(hostname)) {
4532
- return {
4533
- isValid: false,
4534
- error: "Access to internal/localhost addresses is not allowed."
4535
- };
4536
- }
4537
- if (isIPAddress(hostname) && isBlockedIP(hostname)) {
4538
- return {
4539
- isValid: false,
4540
- error: "Access to private/internal IP addresses is not allowed."
4541
- };
4542
- }
4543
- const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
4544
- if (BLOCKED_PORTS.includes(port)) {
4545
- return {
4546
- isValid: false,
4547
- error: `Access to port ${port} is not allowed.`
4548
- };
4549
- }
4550
- let resolvedIP;
4551
- if (!isIPAddress(hostname)) {
4552
- try {
4553
- const dns = await import("node:dns");
4554
- const resolver = new dns.promises.Resolver();
4555
- const resolvedIPs = [];
4556
- try {
4557
- const aRecords = await resolver.resolve4(hostname);
4558
- resolvedIPs.push(...aRecords);
4559
- } catch {
4560
- }
4561
- try {
4562
- const aaaaRecords = await resolver.resolve6(hostname);
4563
- resolvedIPs.push(...aaaaRecords);
4564
- } catch {
4565
- }
4566
- if (resolvedIPs.length === 0) {
4567
- return {
4568
- isValid: false,
4569
- error: "DNS resolution failed: hostname did not resolve to any address."
4570
- };
4571
- }
4572
- for (const ip of resolvedIPs) {
4573
- if (isBlockedIP(ip)) {
4574
- return {
4575
- isValid: false,
4576
- error: "Hostname resolves to a private/internal IP address."
4577
- };
4578
- }
4579
- }
4580
- resolvedIP = resolvedIPs[0];
4581
- } catch {
4582
- return {
4583
- isValid: false,
4584
- error: "DNS resolution failed. Cannot verify hostname safety."
4585
- };
4842
+ const title = post.title || "(untitled)";
4843
+ let line = ` [${post.platform}] ${title} - ${post.views.toLocaleString()} views, ${post.engagement} engagement`;
4844
+ if (post.content_type || post.model_used) {
4845
+ const meta = [post.content_type, post.model_used].filter(Boolean).join(", ");
4846
+ line += ` (${meta})`;
4586
4847
  }
4848
+ lines.push(line);
4587
4849
  }
4588
- return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
4589
- } catch (error) {
4590
- return {
4591
- isValid: false,
4592
- error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
4593
- };
4594
4850
  }
4851
+ return {
4852
+ content: [{ type: "text", text: lines.join("\n") }]
4853
+ };
4595
4854
  }
4596
4855
 
4597
4856
  // src/tools/brand.ts
4857
+ import { z as z6 } from "zod";
4858
+ init_supabase();
4598
4859
  function asEnvelope4(data) {
4599
4860
  return {
4600
4861
  _meta: {
@@ -4743,7 +5004,7 @@ function registerBrandTools(server) {
4743
5004
  );
4744
5005
  server.tool(
4745
5006
  "save_brand_profile",
4746
- "Persist a brand profile as the active profile for a project.",
5007
+ "Save (or replace) the active brand profile for a project \u2014 voice, target audience, content pillars, claims, etc. Use after extract_brand has produced a draft AND the user has reviewed it, or when the user explicitly edits the profile. brand_context is the full profile payload from extract_brand or get_brand_profile. project_id defaults to the active project context. Overwrites the previous active profile (one per project) \u2014 pass the complete profile, no merge semantics. Use change_summary to leave an audit trail.",
4747
5008
  {
4748
5009
  project_id: z6.string().uuid().optional().describe("Project ID. Defaults to active project context."),
4749
5010
  brand_context: z6.record(z6.string(), z6.unknown()).describe("Brand context payload to save to brand_profiles.brand_context."),
@@ -5117,7 +5378,7 @@ function registerScreenshotTools(server) {
5117
5378
  };
5118
5379
  } catch (err) {
5119
5380
  await closeBrowser();
5120
- const message = sanitizeError2(err);
5381
+ const message = sanitizeError(err);
5121
5382
  await logMcpToolInvocation({
5122
5383
  toolName: "capture_app_page",
5123
5384
  status: "error",
@@ -5273,7 +5534,7 @@ function registerScreenshotTools(server) {
5273
5534
  };
5274
5535
  } catch (err) {
5275
5536
  await closeBrowser();
5276
- const message = sanitizeError2(err);
5537
+ const message = sanitizeError(err);
5277
5538
  await logMcpToolInvocation({
5278
5539
  toolName: "capture_screenshot",
5279
5540
  status: "error",
@@ -5566,7 +5827,7 @@ function registerRemotionTools(server) {
5566
5827
  ]
5567
5828
  };
5568
5829
  } catch (err) {
5569
- const message = sanitizeError2(err);
5830
+ const message = sanitizeError(err);
5570
5831
  await logMcpToolInvocation({
5571
5832
  toolName: "render_demo_video",
5572
5833
  status: "error",
@@ -5694,7 +5955,7 @@ function registerRemotionTools(server) {
5694
5955
  ]
5695
5956
  };
5696
5957
  } catch (err) {
5697
- const message = sanitizeError2(err);
5958
+ const message = sanitizeError(err);
5698
5959
  await logMcpToolInvocation({
5699
5960
  toolName: "render_template_video",
5700
5961
  status: "error",
@@ -6321,7 +6582,7 @@ function registerCommentsTools(server) {
6321
6582
  );
6322
6583
  server.tool(
6323
6584
  "post_comment",
6324
- "Post a new top-level comment on a YouTube video.",
6585
+ "Post a new top-level comment on a YouTube video, authored as the connected channel. Use for proactive engagement on your own videos. For replies to existing comments use reply_to_comment instead \u2014 this tool only creates top-level comments. video_id comes from list_recent_posts (platform_post_id field) or any YouTube URL (the v= parameter, 11 chars). Subject to YouTube anti-spam rate limits; calls return rate_limited if exceeded.",
6325
6586
  {
6326
6587
  video_id: z11.string().describe("The YouTube video ID to comment on."),
6327
6588
  text: z11.string().min(1).describe("The comment text."),
@@ -6392,7 +6653,7 @@ function registerCommentsTools(server) {
6392
6653
  );
6393
6654
  server.tool(
6394
6655
  "moderate_comment",
6395
- "Moderate a YouTube comment by setting its status to published or rejected.",
6656
+ 'Moderate a YouTube comment on your channel \u2014 set status to "published" (approve) or "rejected" (hide from public view but kept in moderation queue). Use after list_comments surfaces a comment that needs action. For permanent removal use delete_comment instead. comment_id comes from list_comments results.',
6396
6657
  {
6397
6658
  comment_id: z11.string().describe("The comment ID to moderate."),
6398
6659
  moderation_status: z11.enum(["published", "rejected"]).describe('"published" to approve, "rejected" to hide.'),
@@ -7611,7 +7872,7 @@ function registerExtractionTools(server) {
7611
7872
  };
7612
7873
  } catch (err) {
7613
7874
  const durationMs = Date.now() - startedAt;
7614
- const message = sanitizeError2(err);
7875
+ const message = sanitizeError(err);
7615
7876
  logMcpToolInvocation({
7616
7877
  toolName: "extract_url_content",
7617
7878
  status: "error",
@@ -8167,7 +8428,7 @@ ${rawText.slice(0, 1e3)}`
8167
8428
  }
8168
8429
  } catch (persistErr) {
8169
8430
  const durationMs2 = Date.now() - startedAt;
8170
- const message = sanitizeError2(persistErr);
8431
+ const message = sanitizeError(persistErr);
8171
8432
  logMcpToolInvocation({
8172
8433
  toolName: "plan_content_week",
8173
8434
  status: "error",
@@ -8199,7 +8460,7 @@ ${rawText.slice(0, 1e3)}`
8199
8460
  };
8200
8461
  } catch (err) {
8201
8462
  const durationMs = Date.now() - startedAt;
8202
- const message = sanitizeError2(err);
8463
+ const message = sanitizeError(err);
8203
8464
  logMcpToolInvocation({
8204
8465
  toolName: "plan_content_week",
8205
8466
  status: "error",
@@ -8291,7 +8552,7 @@ ${rawText.slice(0, 1e3)}`
8291
8552
  };
8292
8553
  } catch (err) {
8293
8554
  const durationMs = Date.now() - startedAt;
8294
- const message = sanitizeError2(err);
8555
+ const message = sanitizeError(err);
8295
8556
  logMcpToolInvocation({
8296
8557
  toolName: "save_content_plan",
8297
8558
  status: "error",
@@ -8307,7 +8568,7 @@ ${rawText.slice(0, 1e3)}`
8307
8568
  );
8308
8569
  server.tool(
8309
8570
  "get_content_plan",
8310
- "Retrieve a persisted content plan by ID.",
8571
+ "Load a persisted content plan by its UUID \u2014 returns the full plan including all posts, scheduling status, and approval state. Use to inspect a plan before update_content_plan or schedule_content_plan. plan_id comes from save_content_plan, plan_content_week (when persisted), or list_plan_approvals. For just the approval state, list_plan_approvals is cheaper.",
8311
8572
  {
8312
8573
  plan_id: z20.string().uuid().describe("Persisted content plan ID"),
8313
8574
  response_format: z20.enum(["text", "json"]).default("json")
@@ -8361,7 +8622,7 @@ ${rawText.slice(0, 1e3)}`
8361
8622
  );
8362
8623
  server.tool(
8363
8624
  "update_content_plan",
8364
- "Update individual posts in a persisted content plan.",
8625
+ "Edit individual posts in a persisted content plan \u2014 change caption, title, hashtags, hook, or angle. Use after get_content_plan when the user wants to revise drafts before scheduling. Each post_updates entry must include post_id from the loaded plan; only the fields you pass get updated, others stay as-is. Does NOT trigger publishing \u2014 call schedule_content_plan separately when ready.",
8365
8626
  {
8366
8627
  plan_id: z20.string().uuid(),
8367
8628
  post_updates: z20.array(
@@ -8502,7 +8763,7 @@ function asEnvelope17(data) {
8502
8763
  function registerPlanApprovalTools(server) {
8503
8764
  server.tool(
8504
8765
  "create_plan_approvals",
8505
- "Create pending approval rows for each post in a content plan.",
8766
+ 'Create pending approval rows for each post in a content plan \u2014 one row per post, status="pending". Use after submit_content_plan_for_approval to materialize the approval queue. Each entry in posts becomes a row that respond_plan_approval can later approve, reject, or edit. Idempotent on (plan_id, post_id) \u2014 calling twice with the same posts is a no-op for already-existing rows. Returns IDs of created items for use with list_plan_approvals.',
8506
8767
  {
8507
8768
  plan_id: z21.string().uuid().describe("Content plan ID"),
8508
8769
  posts: z21.array(
@@ -8582,7 +8843,7 @@ function registerPlanApprovalTools(server) {
8582
8843
  );
8583
8844
  server.tool(
8584
8845
  "list_plan_approvals",
8585
- "List MCP-native approval items for a specific content plan.",
8846
+ "List approval items for a content plan, optionally filtered by status (pending / approved / rejected / edited). Use to check what needs review before scheduling, or to audit decisions after the fact. plan_id comes from get_content_plan or save_content_plan. For a single item's full state, get the plan via get_content_plan instead \u2014 that includes per-post approval data inline.",
8586
8847
  {
8587
8848
  plan_id: z21.string().uuid().describe("Content plan ID"),
8588
8849
  status: z21.enum(["pending", "approved", "rejected", "edited"]).optional(),
@@ -8642,7 +8903,7 @@ function registerPlanApprovalTools(server) {
8642
8903
  );
8643
8904
  server.tool(
8644
8905
  "respond_plan_approval",
8645
- "Approve, reject, or edit a pending plan approval item.",
8906
+ 'Approve, reject, or edit a single pending plan approval item. Use to act on items surfaced by list_plan_approvals. decision="edited" REQUIRES edited_post containing the modified post fields \u2014 passing "edited" without edited_post returns an error. Once decided, an item cannot be re-decided (immutable transition). reason is optional but recommended for "rejected" or "edited" to leave a paper trail. After all items are decided, schedule_content_plan publishes only the approved (and edited) ones.',
8646
8907
  {
8647
8908
  approval_id: z21.string().uuid().describe("Approval item ID"),
8648
8909
  decision: z21.enum(["approved", "rejected", "edited"]),
@@ -9158,6 +9419,13 @@ var TOOL_CATALOG = [
9158
9419
  scope: "mcp:read"
9159
9420
  },
9160
9421
  // carousel (already listed in content section above)
9422
+ // apps (MCP Apps — interactive UI inside the host)
9423
+ {
9424
+ name: "open_content_calendar",
9425
+ description: "Open an interactive drag-drop calendar of the user's scheduled posts inside the host (Claude Desktop / claude.ai). Renders an MCP App; backed by list_recent_posts, schedule_post, find_next_slots \u2014 no new tools needed.",
9426
+ module: "apps",
9427
+ scope: "mcp:read"
9428
+ },
9161
9429
  // recipes
9162
9430
  {
9163
9431
  name: "list_recipes",
@@ -9182,6 +9450,19 @@ var TOOL_CATALOG = [
9182
9450
  description: "Check the status and progress of a running recipe execution",
9183
9451
  module: "recipes",
9184
9452
  scope: "mcp:read"
9453
+ },
9454
+ // platform connection deep-link flow
9455
+ {
9456
+ name: "start_platform_connection",
9457
+ description: "Mint a single-use deep link for a user to complete platform OAuth in their browser",
9458
+ module: "distribution",
9459
+ scope: "mcp:distribute"
9460
+ },
9461
+ {
9462
+ name: "wait_for_connection",
9463
+ description: "Poll until a platform connection becomes active or timeout",
9464
+ module: "distribution",
9465
+ scope: "mcp:read"
9185
9466
  }
9186
9467
  ];
9187
9468
  function getToolsByModule(module) {
@@ -9381,7 +9662,7 @@ function registerPipelineTools(server) {
9381
9662
  return { content: [{ type: "text", text: lines.join("\n") }] };
9382
9663
  } catch (err) {
9383
9664
  const durationMs = Date.now() - startedAt;
9384
- const message = sanitizeError2(err);
9665
+ const message = sanitizeError(err);
9385
9666
  logMcpToolInvocation({
9386
9667
  toolName: "check_pipeline_readiness",
9387
9668
  status: "error",
@@ -9542,7 +9823,7 @@ function registerPipelineTools(server) {
9542
9823
  } catch (deductErr) {
9543
9824
  errors.push({
9544
9825
  stage: "planning",
9545
- message: `Credit deduction failed: ${sanitizeError2(deductErr)}`
9826
+ message: `Credit deduction failed: ${sanitizeError(deductErr)}`
9546
9827
  });
9547
9828
  }
9548
9829
  }
@@ -9713,7 +9994,7 @@ function registerPipelineTools(server) {
9713
9994
  } catch (schedErr) {
9714
9995
  errors.push({
9715
9996
  stage: "schedule",
9716
- message: `Failed to schedule ${post.id}: ${sanitizeError2(schedErr)}`
9997
+ message: `Failed to schedule ${post.id}: ${sanitizeError(schedErr)}`
9717
9998
  });
9718
9999
  }
9719
10000
  }
@@ -9802,7 +10083,7 @@ function registerPipelineTools(server) {
9802
10083
  return { content: [{ type: "text", text: lines.join("\n") }] };
9803
10084
  } catch (err) {
9804
10085
  const durationMs = Date.now() - startedAt;
9805
- const message = sanitizeError2(err);
10086
+ const message = sanitizeError(err);
9806
10087
  logMcpToolInvocation({
9807
10088
  toolName: "run_content_pipeline",
9808
10089
  status: "error",
@@ -10026,7 +10307,7 @@ function registerPipelineTools(server) {
10026
10307
  return { content: [{ type: "text", text: lines.join("\n") }] };
10027
10308
  } catch (err) {
10028
10309
  const durationMs = Date.now() - startedAt;
10029
- const message = sanitizeError2(err);
10310
+ const message = sanitizeError(err);
10030
10311
  logMcpToolInvocation({
10031
10312
  toolName: "auto_approve_plan",
10032
10313
  status: "error",
@@ -10204,7 +10485,7 @@ ${i + 1}. ${s.topic}`);
10204
10485
  return { content: [{ type: "text", text: lines.join("\n") }] };
10205
10486
  } catch (err) {
10206
10487
  const durationMs = Date.now() - startedAt;
10207
- const message = sanitizeError2(err);
10488
+ const message = sanitizeError(err);
10208
10489
  logMcpToolInvocation({
10209
10490
  toolName: "suggest_next_content",
10210
10491
  status: "error",
@@ -10543,7 +10824,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10543
10824
  return { content: [{ type: "text", text: lines.join("\n") }] };
10544
10825
  } catch (err) {
10545
10826
  const durationMs = Date.now() - startedAt;
10546
- const message = sanitizeError2(err);
10827
+ const message = sanitizeError(err);
10547
10828
  logMcpToolInvocation({
10548
10829
  toolName: "generate_performance_digest",
10549
10830
  status: "error",
@@ -10640,7 +10921,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10640
10921
  return { content: [{ type: "text", text: lines.join("\n") }] };
10641
10922
  } catch (err) {
10642
10923
  const durationMs = Date.now() - startedAt;
10643
- const message = sanitizeError2(err);
10924
+ const message = sanitizeError(err);
10644
10925
  logMcpToolInvocation({
10645
10926
  toolName: "detect_anomalies",
10646
10927
  status: "error",
@@ -10945,11 +11226,11 @@ function hexToLab(hex) {
10945
11226
  const lb = srgbToLinear(b);
10946
11227
  const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
10947
11228
  const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
10948
- const z29 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
11229
+ const z31 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
10949
11230
  const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
10950
11231
  const fx = f(x / 0.95047);
10951
11232
  const fy = f(y / 1);
10952
- const fz = f(z29 / 1.08883);
11233
+ const fz = f(z31 / 1.08883);
10953
11234
  return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
10954
11235
  }
10955
11236
  function deltaE2000(lab1, lab2) {
@@ -11669,7 +11950,7 @@ function registerCarouselTools(server) {
11669
11950
  slideNumber: slide.slideNumber,
11670
11951
  jobId: null,
11671
11952
  model: image_model,
11672
- error: sanitizeError2(err)
11953
+ error: sanitizeError(err)
11673
11954
  };
11674
11955
  }
11675
11956
  })
@@ -11774,6 +12055,343 @@ function registerCarouselTools(server) {
11774
12055
  );
11775
12056
  }
11776
12057
 
12058
+ // src/apps/content-calendar.ts
12059
+ import {
12060
+ registerAppTool,
12061
+ registerAppResource,
12062
+ RESOURCE_MIME_TYPE
12063
+ } from "@modelcontextprotocol/ext-apps/server";
12064
+ import { z as z28 } from "zod";
12065
+ import fs from "node:fs/promises";
12066
+ import path from "node:path";
12067
+ var CALENDAR_URI = "ui://content-calendar/mcp-app.html";
12068
+ function startOfCurrentWeekMonday() {
12069
+ const now = /* @__PURE__ */ new Date();
12070
+ const day = now.getDay();
12071
+ const monday = new Date(now);
12072
+ monday.setUTCDate(now.getUTCDate() - day + (day === 0 ? -6 : 1));
12073
+ return monday.toISOString().split("T")[0];
12074
+ }
12075
+ function registerContentCalendarApp(server) {
12076
+ registerAppTool(
12077
+ server,
12078
+ "open_content_calendar",
12079
+ {
12080
+ title: "Content Calendar",
12081
+ description: "Open an interactive drag-drop calendar showing the user's scheduled posts for the current week. Users can reschedule via drag, filter by platform, drill into any post, or quick-create a new post. Backed by list_recent_posts, schedule_post, and find_next_slots \u2014 no new tools needed.",
12082
+ inputSchema: {
12083
+ start_date: z28.string().optional().describe("ISO date for the week start (YYYY-MM-DD); defaults to the current week's Monday.")
12084
+ },
12085
+ _meta: {
12086
+ ui: {
12087
+ resourceUri: CALENDAR_URI,
12088
+ csp: {
12089
+ "img-src": ["'self'", "https://*.r2.cloudflarestorage.com", "data:"],
12090
+ "connect-src": ["'self'"]
12091
+ }
12092
+ }
12093
+ }
12094
+ },
12095
+ async ({ start_date }, extra) => {
12096
+ const userScopes = extra.authInfo?.scopes ?? [];
12097
+ const fromDate = start_date ?? startOfCurrentWeekMonday();
12098
+ const { data: result, error } = await callEdgeFunction(
12099
+ "mcp-data",
12100
+ {
12101
+ action: "recent-posts",
12102
+ days: 14,
12103
+ limit: 50
12104
+ },
12105
+ { timeoutMs: 15e3 }
12106
+ );
12107
+ if (error || !result?.success) {
12108
+ return {
12109
+ content: [
12110
+ {
12111
+ type: "text",
12112
+ text: `Failed to load posts: ${error || result?.error || "Unknown error"}`
12113
+ }
12114
+ ],
12115
+ isError: true
12116
+ };
12117
+ }
12118
+ const posts = (result.posts ?? []).filter((p) => {
12119
+ const ts = p.scheduled_at ?? p.published_at ?? p.created_at;
12120
+ if (!ts) return false;
12121
+ return ts.split("T")[0] >= fromDate;
12122
+ });
12123
+ return {
12124
+ content: [
12125
+ {
12126
+ type: "text",
12127
+ text: JSON.stringify({ posts, scopes: userScopes })
12128
+ }
12129
+ ]
12130
+ };
12131
+ }
12132
+ );
12133
+ registerAppResource(
12134
+ server,
12135
+ CALENDAR_URI,
12136
+ CALENDAR_URI,
12137
+ { mimeType: RESOURCE_MIME_TYPE },
12138
+ async () => {
12139
+ const htmlPath = path.join(process.cwd(), "apps/content-calendar/dist/mcp-app.html");
12140
+ try {
12141
+ const html = await fs.readFile(htmlPath, "utf-8");
12142
+ return {
12143
+ contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: html }]
12144
+ };
12145
+ } catch (err) {
12146
+ const errorHtml = `<!DOCTYPE html>
12147
+ <html><head><title>Content Calendar \u2014 unavailable</title></head>
12148
+ <body style="font-family:sans-serif;padding:24px;color:#444;">
12149
+ <h2>Content Calendar app bundle missing</h2>
12150
+ <p>The server registered <code>open_content_calendar</code> but
12151
+ <code>apps/content-calendar/dist/mcp-app.html</code> is not built.
12152
+ Run <code>npm run build:app</code> in the mcp-server directory and redeploy.</p>
12153
+ <p style="color:#999;font-size:12px;">${err.message}</p>
12154
+ </body></html>`;
12155
+ return {
12156
+ contents: [{ uri: CALENDAR_URI, mimeType: RESOURCE_MIME_TYPE, text: errorHtml }]
12157
+ };
12158
+ }
12159
+ }
12160
+ );
12161
+ }
12162
+
12163
+ // src/tools/connections.ts
12164
+ import { z as z29 } from "zod";
12165
+ init_supabase();
12166
+ var PLATFORM_ENUM4 = [
12167
+ "youtube",
12168
+ "tiktok",
12169
+ "instagram",
12170
+ "twitter",
12171
+ "linkedin",
12172
+ "facebook",
12173
+ "threads",
12174
+ "bluesky",
12175
+ "shopify",
12176
+ "etsy"
12177
+ ];
12178
+ function findActiveAccount(accounts, platform2) {
12179
+ const target = platform2.toLowerCase();
12180
+ return accounts.find((a) => a.platform.toLowerCase() === target && a.status === "active") ?? null;
12181
+ }
12182
+ function registerConnectionTools(server) {
12183
+ server.tool(
12184
+ "start_platform_connection",
12185
+ "Begin connecting a social platform (Instagram, TikTok, YouTube, etc.). Returns a single-use deep link the user opens in a browser to complete the one-time OAuth handshake on socialneuron.com. This is NOT another OAuth in Claude \u2014 platform connections require a browser session because the social platforms (Meta, Google, TikTok) only accept callbacks on socialneuron.com. After the user clicks the link and approves on the platform, call `wait_for_connection` to detect completion. Link expires in 2 minutes; mint a new one if needed. Use `list_connected_accounts` first to check whether the platform is already connected before calling this.",
12186
+ {
12187
+ platform: z29.enum(PLATFORM_ENUM4).describe("Platform to connect. Lower-case: instagram, tiktok, youtube, etc."),
12188
+ response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
12189
+ },
12190
+ async ({ platform: platform2, response_format }) => {
12191
+ const format = response_format ?? "text";
12192
+ const startedAt = Date.now();
12193
+ const rl = checkRateLimit("read", `start_platform_connection:${platform2}`);
12194
+ if (!rl.allowed) {
12195
+ await logMcpToolInvocation({
12196
+ toolName: "start_platform_connection",
12197
+ status: "rate_limited",
12198
+ durationMs: Date.now() - startedAt,
12199
+ details: { retryAfter: rl.retryAfter, platform: platform2 }
12200
+ });
12201
+ return {
12202
+ content: [
12203
+ {
12204
+ type: "text",
12205
+ text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
12206
+ }
12207
+ ],
12208
+ isError: true
12209
+ };
12210
+ }
12211
+ const { data, error } = await callEdgeFunction("mcp-data", { action: "mint-connection-nonce", platform: platform2 }, { timeoutMs: 1e4 });
12212
+ if (error || !data?.success || !data.deep_link) {
12213
+ const errMsg = error ?? data?.error ?? "Unknown error";
12214
+ await logMcpToolInvocation({
12215
+ toolName: "start_platform_connection",
12216
+ status: "error",
12217
+ durationMs: Date.now() - startedAt,
12218
+ details: { error: errMsg, platform: platform2 }
12219
+ });
12220
+ return {
12221
+ content: [
12222
+ {
12223
+ type: "text",
12224
+ text: `Failed to start ${platform2} connection: ${errMsg}`
12225
+ }
12226
+ ],
12227
+ isError: true
12228
+ };
12229
+ }
12230
+ await logMcpToolInvocation({
12231
+ toolName: "start_platform_connection",
12232
+ status: "success",
12233
+ durationMs: Date.now() - startedAt,
12234
+ details: { platform: data.platform, expires_at: data.expires_at }
12235
+ });
12236
+ if (format === "json") {
12237
+ return {
12238
+ content: [
12239
+ {
12240
+ type: "text",
12241
+ text: JSON.stringify(
12242
+ {
12243
+ platform: data.platform,
12244
+ deep_link: data.deep_link,
12245
+ expires_at: data.expires_at,
12246
+ next_step: "Open deep_link in a browser, approve on the platform, then call wait_for_connection."
12247
+ },
12248
+ null,
12249
+ 2
12250
+ )
12251
+ }
12252
+ ],
12253
+ isError: false
12254
+ };
12255
+ }
12256
+ return {
12257
+ content: [
12258
+ {
12259
+ type: "text",
12260
+ text: [
12261
+ `${data.platform} connection ready.`,
12262
+ "",
12263
+ 'Ask the user to open this link in a browser and click "Connect" on the platform:',
12264
+ ` ${data.deep_link}`,
12265
+ "",
12266
+ `Link expires at: ${data.expires_at} (~2 minutes).`,
12267
+ "",
12268
+ "After they approve, call `wait_for_connection` with the same platform to confirm.",
12269
+ "This is a one-time browser setup \u2014 not another OAuth flow inside Claude."
12270
+ ].join("\n")
12271
+ }
12272
+ ],
12273
+ isError: false
12274
+ };
12275
+ }
12276
+ );
12277
+ server.tool(
12278
+ "wait_for_connection",
12279
+ "Poll until a platform connection becomes active. Use after `start_platform_connection` while the user completes the browser OAuth flow. Returns when the account row appears with status=active, or when the timeout elapses. Default timeout 120s, max 600s.",
12280
+ {
12281
+ platform: z29.enum(PLATFORM_ENUM4).describe("Platform to wait for."),
12282
+ timeout_s: z29.number().min(5).max(600).optional().describe("How long to wait, in seconds. Default 120."),
12283
+ poll_interval_s: z29.number().min(2).max(30).optional().describe("Poll interval in seconds. Default 5."),
12284
+ response_format: z29.enum(["text", "json"]).optional().describe("Response format. Default: text.")
12285
+ },
12286
+ async ({ platform: platform2, timeout_s, poll_interval_s, response_format }) => {
12287
+ const format = response_format ?? "text";
12288
+ const startedAt = Date.now();
12289
+ const timeoutMs = (timeout_s ?? 120) * 1e3;
12290
+ const intervalMs = (poll_interval_s ?? 5) * 1e3;
12291
+ const deadline = startedAt + timeoutMs;
12292
+ const rl = checkRateLimit("read", `wait_for_connection:${platform2}`);
12293
+ if (!rl.allowed) {
12294
+ return {
12295
+ content: [
12296
+ {
12297
+ type: "text",
12298
+ text: `Rate limit exceeded. Retry in ~${rl.retryAfter}s.`
12299
+ }
12300
+ ],
12301
+ isError: true
12302
+ };
12303
+ }
12304
+ let attempts = 0;
12305
+ while (Date.now() < deadline) {
12306
+ attempts++;
12307
+ const { data, error } = await callEdgeFunction("mcp-data", { action: "connected-accounts" }, { timeoutMs: 1e4 });
12308
+ if (!error && data?.success) {
12309
+ const found = findActiveAccount(data.accounts ?? [], platform2);
12310
+ if (found) {
12311
+ await logMcpToolInvocation({
12312
+ toolName: "wait_for_connection",
12313
+ status: "success",
12314
+ durationMs: Date.now() - startedAt,
12315
+ details: { platform: platform2, attempts, found: true }
12316
+ });
12317
+ if (format === "json") {
12318
+ return {
12319
+ content: [
12320
+ {
12321
+ type: "text",
12322
+ text: JSON.stringify(
12323
+ {
12324
+ connected: true,
12325
+ platform: found.platform,
12326
+ account_id: found.id,
12327
+ username: found.username,
12328
+ connected_at: found.created_at,
12329
+ attempts
12330
+ },
12331
+ null,
12332
+ 2
12333
+ )
12334
+ }
12335
+ ],
12336
+ isError: false
12337
+ };
12338
+ }
12339
+ return {
12340
+ content: [
12341
+ {
12342
+ type: "text",
12343
+ text: [
12344
+ `${found.platform} is connected.`,
12345
+ `Account: ${found.username || "(unnamed)"} (id=${found.id})`,
12346
+ `Detected after ${attempts} poll(s) in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s.`,
12347
+ "Ready to call `schedule_post`."
12348
+ ].join("\n")
12349
+ }
12350
+ ],
12351
+ isError: false
12352
+ };
12353
+ }
12354
+ }
12355
+ const remaining = deadline - Date.now();
12356
+ if (remaining <= 0) break;
12357
+ await new Promise((resolve3) => setTimeout(resolve3, Math.min(intervalMs, remaining)));
12358
+ }
12359
+ await logMcpToolInvocation({
12360
+ toolName: "wait_for_connection",
12361
+ status: "error",
12362
+ durationMs: Date.now() - startedAt,
12363
+ details: { platform: platform2, attempts, found: false, reason: "timeout" }
12364
+ });
12365
+ const message = `${platform2} did not connect within ${timeout_s ?? 120}s (${attempts} polls). The user may not have completed the browser OAuth yet, or the link expired. Mint a new link with \`start_platform_connection\` and try again, or have the user go directly to socialneuron.com/settings/connections.`;
12366
+ if (format === "json") {
12367
+ return {
12368
+ content: [
12369
+ {
12370
+ type: "text",
12371
+ text: JSON.stringify(
12372
+ {
12373
+ connected: false,
12374
+ platform: platform2,
12375
+ attempts,
12376
+ timed_out: true,
12377
+ message
12378
+ },
12379
+ null,
12380
+ 2
12381
+ )
12382
+ }
12383
+ ],
12384
+ isError: true
12385
+ };
12386
+ }
12387
+ return {
12388
+ content: [{ type: "text", text: message }],
12389
+ isError: true
12390
+ };
12391
+ }
12392
+ );
12393
+ }
12394
+
11777
12395
  // src/lib/register-tools.ts
11778
12396
  function applyScopeEnforcement(server, scopeResolver) {
11779
12397
  const originalTool = server.tool.bind(server);
@@ -11803,7 +12421,7 @@ function applyScopeEnforcement(server, scopeResolver) {
11803
12421
  content: [
11804
12422
  {
11805
12423
  type: "text",
11806
- text: `Permission denied: '${name}' requires scope '${requiredScope}'. Generate a new key with the required scope at https://socialneuron.com/settings/developer`
12424
+ text: `Permission denied: '${name}' requires scope '${requiredScope}'. Your scopes: [${userScopes.join(", ")}]. API-key users: regenerate your key with this scope at https://socialneuron.com/settings/developer. OAuth users (Claude Custom Connector): this scope is not enabled for your plan tier.`
11807
12425
  }
11808
12426
  ],
11809
12427
  isError: true
@@ -11879,21 +12497,25 @@ function registerAllTools(server, options) {
11879
12497
  registerDigestTools(server);
11880
12498
  registerBrandRuntimeTools(server);
11881
12499
  registerCarouselTools(server);
12500
+ registerConnectionTools(server);
12501
+ if (!options?.skipApps) {
12502
+ registerContentCalendarApp(server);
12503
+ }
11882
12504
  applyAnnotations(server);
11883
12505
  }
11884
12506
 
11885
12507
  // src/prompts.ts
11886
- import { z as z28 } from "zod";
12508
+ import { z as z30 } from "zod";
11887
12509
  function registerPrompts(server) {
11888
12510
  server.prompt(
11889
12511
  "create_weekly_content_plan",
11890
12512
  "Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
11891
12513
  {
11892
- niche: z28.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
11893
- platforms: z28.string().optional().describe(
12514
+ niche: z30.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
12515
+ platforms: z30.string().optional().describe(
11894
12516
  'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
11895
12517
  ),
11896
- tone: z28.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
12518
+ tone: z30.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
11897
12519
  },
11898
12520
  ({ niche, platforms, tone }) => {
11899
12521
  const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
@@ -11935,8 +12557,8 @@ After building the plan, use \`save_content_plan\` to save it.`
11935
12557
  "analyze_top_content",
11936
12558
  "Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
11937
12559
  {
11938
- timeframe: z28.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
11939
- platform: z28.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
12560
+ timeframe: z30.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
12561
+ platform: z30.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
11940
12562
  },
11941
12563
  ({ timeframe, platform: platform2 }) => {
11942
12564
  const period = timeframe || "30 days";
@@ -11973,10 +12595,10 @@ Format as a clear, actionable performance report.`
11973
12595
  "repurpose_content",
11974
12596
  "Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
11975
12597
  {
11976
- source: z28.string().describe(
12598
+ source: z30.string().describe(
11977
12599
  "The source content to repurpose \u2014 a URL, transcript, or the content text itself"
11978
12600
  ),
11979
- target_platforms: z28.string().optional().describe(
12601
+ target_platforms: z30.string().optional().describe(
11980
12602
  'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
11981
12603
  )
11982
12604
  },
@@ -12018,9 +12640,9 @@ For each piece, include the platform, format, character count, and suggested pos
12018
12640
  "setup_brand_voice",
12019
12641
  "Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
12020
12642
  {
12021
- brand_name: z28.string().describe("Your brand or business name"),
12022
- industry: z28.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
12023
- website: z28.string().optional().describe("Your website URL for context")
12643
+ brand_name: z30.string().describe("Your brand or business name"),
12644
+ industry: z30.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
12645
+ website: z30.string().optional().describe("Your website URL for context")
12024
12646
  },
12025
12647
  ({ brand_name, industry, website }) => {
12026
12648
  const industryContext = industry ? ` in the ${industry} space` : "";
@@ -12479,7 +13101,7 @@ function isAllowedRedirectUri(uri) {
12479
13101
  if ((parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") && (parsed.pathname === "/oauth/callback" || parsed.pathname === "/oauth/callback/debug") && parsed.protocol === "http:") {
12480
13102
  return true;
12481
13103
  }
12482
- if (parsed.protocol === "https:") {
13104
+ if (process.env.MCP_ALLOW_ANY_HTTPS_REDIRECT === "true" && parsed.protocol === "https:") {
12483
13105
  return true;
12484
13106
  }
12485
13107
  } catch {
@@ -12598,7 +13220,7 @@ function createOAuthProvider(options) {
12598
13220
  const controller = new AbortController();
12599
13221
  const timer = setTimeout(() => controller.abort(), 1e4);
12600
13222
  try {
12601
- await fetch(`${supabaseUrl}/functions/v1/mcp-auth?action=revoke-by-token`, {
13223
+ const response = await fetch(`${supabaseUrl}/functions/v1/mcp-auth?action=revoke-by-token`, {
12602
13224
  method: "POST",
12603
13225
  headers: {
12604
13226
  Authorization: `Bearer ${supabaseAnonKey}`,
@@ -12607,9 +13229,17 @@ function createOAuthProvider(options) {
12607
13229
  body: JSON.stringify({ token: request.token }),
12608
13230
  signal: controller.signal
12609
13231
  });
13232
+ if (!response.ok) {
13233
+ throw new Error(`Token revocation failed: HTTP ${response.status}`);
13234
+ }
13235
+ const data = await response.json().catch(() => ({ success: true }));
13236
+ if (data.success === false) {
13237
+ throw new Error(data.error ?? "Token revocation failed");
13238
+ }
12610
13239
  } catch (err) {
12611
13240
  const msg = err instanceof Error ? err.message : "unknown";
12612
13241
  console.error(`[oauth] Token revocation call failed: ${msg}`);
13242
+ throw err;
12613
13243
  } finally {
12614
13244
  clearTimeout(timer);
12615
13245
  }
@@ -12698,7 +13328,7 @@ var cleanupInterval = setInterval(
12698
13328
  );
12699
13329
  var app = express();
12700
13330
  app.disable("x-powered-by");
12701
- app.use(express.json());
13331
+ app.use(express.json({ limit: "1mb" }));
12702
13332
  app.set("trust proxy", 1);
12703
13333
  var ipBuckets = /* @__PURE__ */ new Map();
12704
13334
  var IP_RATE_MAX = 60;
@@ -12779,9 +13409,13 @@ app.use((req, res, next) => {
12779
13409
  next(err);
12780
13410
  });
12781
13411
  });
13412
+ function setNoStore(res) {
13413
+ res.setHeader("Cache-Control", "no-store");
13414
+ }
12782
13415
  async function authenticateRequest(req, res, next) {
12783
13416
  const authHeader = req.headers.authorization;
12784
13417
  if (!authHeader?.startsWith("Bearer ")) {
13418
+ setNoStore(res);
12785
13419
  res.status(401).json({
12786
13420
  error: "unauthorized",
12787
13421
  error_description: "Bearer token required"
@@ -12806,10 +13440,12 @@ async function authenticateRequest(req, res, next) {
12806
13440
  };
12807
13441
  next();
12808
13442
  } catch (err) {
12809
- const message = err instanceof Error ? err.message : "Token verification failed";
13443
+ const message = err instanceof Error ? sanitizeError(err) : "Token verification failed";
13444
+ console.error(`[MCP HTTP] Token verification failed: ${message}`);
13445
+ setNoStore(res);
12810
13446
  res.status(401).json({
12811
13447
  error: "invalid_token",
12812
- error_description: message
13448
+ error_description: "Token verification failed"
12813
13449
  });
12814
13450
  }
12815
13451
  }
@@ -12823,124 +13459,13 @@ app.get("/.well-known/mcp/server-card.json", (_req, res) => {
12823
13459
  required: true,
12824
13460
  schemes: ["oauth2"]
12825
13461
  },
12826
- tools: [
12827
- {
12828
- name: "generate_content",
12829
- description: "Create a script, caption, hook, or blog post tailored to a specific platform.",
12830
- inputSchema: {
12831
- type: "object",
12832
- properties: { prompt: { type: "string" }, platform: { type: "string" } },
12833
- required: ["prompt"]
12834
- }
12835
- },
12836
- {
12837
- name: "schedule_post",
12838
- description: "Publish or schedule a post to connected social platforms.",
12839
- inputSchema: {
12840
- type: "object",
12841
- properties: { content: { type: "string" }, platform: { type: "string" } },
12842
- required: ["content", "platform"]
12843
- }
12844
- },
12845
- {
12846
- name: "fetch_analytics",
12847
- description: "Get post performance metrics for connected platforms.",
12848
- inputSchema: { type: "object", properties: { platform: { type: "string" } } }
12849
- },
12850
- {
12851
- name: "extract_brand",
12852
- description: "Analyze a website URL and extract brand identity data.",
12853
- inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] }
12854
- },
12855
- {
12856
- name: "plan_content_week",
12857
- description: "Generate a full week content plan with platform-specific drafts.",
12858
- inputSchema: {
12859
- type: "object",
12860
- properties: { niche: { type: "string" } },
12861
- required: ["niche"]
12862
- }
12863
- },
12864
- {
12865
- name: "generate_video",
12866
- description: "Start an async AI video generation job.",
12867
- inputSchema: {
12868
- type: "object",
12869
- properties: { prompt: { type: "string" } },
12870
- required: ["prompt"]
12871
- }
12872
- },
12873
- {
12874
- name: "generate_image",
12875
- description: "Start an async AI image generation job.",
12876
- inputSchema: {
12877
- type: "object",
12878
- properties: { prompt: { type: "string" } },
12879
- required: ["prompt"]
12880
- }
12881
- },
12882
- {
12883
- name: "adapt_content",
12884
- description: "Rewrite existing content for a different platform.",
12885
- inputSchema: {
12886
- type: "object",
12887
- properties: { content: { type: "string" }, target_platform: { type: "string" } },
12888
- required: ["content", "target_platform"]
12889
- }
12890
- },
12891
- {
12892
- name: "quality_check",
12893
- description: "Score post quality across 7 categories (0-100).",
12894
- inputSchema: {
12895
- type: "object",
12896
- properties: { content: { type: "string" } },
12897
- required: ["content"]
12898
- }
12899
- },
12900
- {
12901
- name: "run_content_pipeline",
12902
- description: "Full pipeline: trends \u2192 plan \u2192 quality check \u2192 schedule.",
12903
- inputSchema: {
12904
- type: "object",
12905
- properties: { niche: { type: "string" } },
12906
- required: ["niche"]
12907
- }
12908
- },
12909
- {
12910
- name: "fetch_trends",
12911
- description: "Get current trending topics for content inspiration.",
12912
- inputSchema: { type: "object", properties: { platform: { type: "string" } } }
12913
- },
12914
- {
12915
- name: "get_credit_balance",
12916
- description: "Check remaining credits and plan tier.",
12917
- inputSchema: { type: "object", properties: {} }
12918
- },
12919
- {
12920
- name: "list_connected_accounts",
12921
- description: "Check which social platforms have active OAuth connections.",
12922
- inputSchema: { type: "object", properties: {} }
12923
- },
12924
- {
12925
- name: "get_brand_profile",
12926
- description: "Load the active brand voice profile.",
12927
- inputSchema: { type: "object", properties: {} }
12928
- },
12929
- {
12930
- name: "list_comments",
12931
- description: "List YouTube comments for moderation.",
12932
- inputSchema: { type: "object", properties: {} }
12933
- },
12934
- {
12935
- name: "reply_to_comment",
12936
- description: "Reply to a YouTube comment.",
12937
- inputSchema: {
12938
- type: "object",
12939
- properties: { comment_id: { type: "string" }, text: { type: "string" } },
12940
- required: ["comment_id", "text"]
12941
- }
12942
- }
12943
- ],
13462
+ toolCount: TOOL_CATALOG.length,
13463
+ tools: TOOL_CATALOG.map((t) => ({
13464
+ name: t.name,
13465
+ description: t.description,
13466
+ module: t.module,
13467
+ scope: t.scope
13468
+ })),
12944
13469
  prompts: [
12945
13470
  {
12946
13471
  name: "create_weekly_content_plan",
@@ -12998,6 +13523,7 @@ app.get("/health", (_req, res) => {
12998
13523
  res.json({ status: "ok", version: MCP_VERSION });
12999
13524
  });
13000
13525
  app.get("/health/details", authenticateRequest, (_req, res) => {
13526
+ setNoStore(res);
13001
13527
  res.json({
13002
13528
  status: "ok",
13003
13529
  version: MCP_VERSION,
@@ -13012,6 +13538,7 @@ app.get("/health/details", authenticateRequest, (_req, res) => {
13012
13538
  app.post("/mcp", authenticateRequest, async (req, res) => {
13013
13539
  const auth = req.auth;
13014
13540
  const existingSessionId = req.headers["mcp-session-id"];
13541
+ setNoStore(res);
13015
13542
  const rl = checkRateLimit("read", auth.userId);
13016
13543
  if (!rl.allowed) {
13017
13544
  res.setHeader("Retry-After", String(rl.retryAfter));
@@ -13086,11 +13613,12 @@ app.post("/mcp", authenticateRequest, async (req, res) => {
13086
13613
  const rawMessage = err instanceof Error ? err.message : "Internal server error";
13087
13614
  console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
13088
13615
  if (!res.headersSent) {
13089
- res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: sanitizeError2(err) } });
13616
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: sanitizeError(err) } });
13090
13617
  }
13091
13618
  }
13092
13619
  });
13093
13620
  app.get("/mcp", authenticateRequest, async (req, res) => {
13621
+ setNoStore(res);
13094
13622
  const sessionId = req.headers["mcp-session-id"];
13095
13623
  if (!sessionId || !sessions.has(sessionId)) {
13096
13624
  res.status(400).json({ error: "Invalid or missing session ID" });
@@ -13103,13 +13631,14 @@ app.get("/mcp", authenticateRequest, async (req, res) => {
13103
13631
  }
13104
13632
  entry.lastActivity = Date.now();
13105
13633
  res.setHeader("X-Accel-Buffering", "no");
13106
- res.setHeader("Cache-Control", "no-cache");
13634
+ res.setHeader("Cache-Control", "no-store");
13107
13635
  await requestContext.run(
13108
13636
  { userId: req.auth.userId, scopes: req.auth.scopes, creditsUsed: 0, assetsGenerated: 0 },
13109
13637
  () => entry.transport.handleRequest(req, res)
13110
13638
  );
13111
13639
  });
13112
13640
  app.delete("/mcp", authenticateRequest, async (req, res) => {
13641
+ setNoStore(res);
13113
13642
  const sessionId = req.headers["mcp-session-id"];
13114
13643
  if (!sessionId || !sessions.has(sessionId)) {
13115
13644
  res.status(400).json({ error: "Invalid or missing session ID" });
@@ -13128,7 +13657,7 @@ app.delete("/mcp", authenticateRequest, async (req, res) => {
13128
13657
  app.use((err, _req, res, _next) => {
13129
13658
  console.error("[MCP HTTP] Unhandled Express error:", err.stack || err.message || err);
13130
13659
  if (!res.headersSent) {
13131
- res.status(500).json({ error: "internal_error", error_description: sanitizeError2(err) });
13660
+ res.status(500).json({ error: "internal_error", error_description: sanitizeError(err) });
13132
13661
  }
13133
13662
  });
13134
13663
  var httpServer = app.listen(PORT, "0.0.0.0", () => {