@socialneuron/mcp-server 1.7.4 → 1.7.5

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
@@ -1383,7 +1383,7 @@ init_supabase();
1383
1383
  init_request_context();
1384
1384
 
1385
1385
  // src/lib/version.ts
1386
- var MCP_VERSION = "1.7.4";
1386
+ var MCP_VERSION = "1.7.5";
1387
1387
 
1388
1388
  // src/tools/content.ts
1389
1389
  var MAX_CREDITS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
@@ -2551,7 +2551,7 @@ var ERROR_PATTERNS = [
2551
2551
  // Generic sensitive patterns (API keys, URLs with secrets)
2552
2552
  [/[a-z0-9]{32,}.*key|Bearer [a-zA-Z0-9._-]+/i, "An internal error occurred. Please try again."]
2553
2553
  ];
2554
- function sanitizeError2(error) {
2554
+ function sanitizeError(error) {
2555
2555
  const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
2556
2556
  if (process.env.NODE_ENV !== "production") {
2557
2557
  console.error("[Error]", msg);
@@ -2564,6 +2564,171 @@ function sanitizeError2(error) {
2564
2564
  return "An unexpected error occurred. Please try again.";
2565
2565
  }
2566
2566
 
2567
+ // src/lib/ssrf.ts
2568
+ var BLOCKED_IP_PATTERNS = [
2569
+ // IPv4 localhost/loopback
2570
+ /^127\./,
2571
+ /^0\./,
2572
+ // IPv4 private ranges (RFC 1918)
2573
+ /^10\./,
2574
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
2575
+ /^192\.168\./,
2576
+ // IPv4 link-local
2577
+ /^169\.254\./,
2578
+ // Cloud metadata endpoint (AWS, GCP, Azure)
2579
+ /^169\.254\.169\.254$/,
2580
+ // IPv4 broadcast
2581
+ /^255\./,
2582
+ // Shared address space (RFC 6598)
2583
+ /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
2584
+ ];
2585
+ var BLOCKED_IPV6_PATTERNS = [
2586
+ /^::1$/i,
2587
+ // loopback
2588
+ /^::$/i,
2589
+ // unspecified
2590
+ /^fe[89ab][0-9a-f]:/i,
2591
+ // link-local fe80::/10
2592
+ /^fc[0-9a-f]:/i,
2593
+ // unique local fc00::/7
2594
+ /^fd[0-9a-f]:/i,
2595
+ // unique local fc00::/7
2596
+ /^::ffff:127\./i,
2597
+ // IPv4-mapped localhost
2598
+ /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
2599
+ // IPv4-mapped private
2600
+ ];
2601
+ var BLOCKED_HOSTNAMES = [
2602
+ "localhost",
2603
+ "localhost.localdomain",
2604
+ "local",
2605
+ "127.0.0.1",
2606
+ "0.0.0.0",
2607
+ "[::1]",
2608
+ "[::ffff:127.0.0.1]",
2609
+ // Cloud metadata endpoints
2610
+ "metadata.google.internal",
2611
+ "metadata.goog",
2612
+ "instance-data",
2613
+ "instance-data.ec2.internal"
2614
+ ];
2615
+ var ALLOWED_PROTOCOLS = ["http:", "https:"];
2616
+ var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
2617
+ function isBlockedIP(ip) {
2618
+ const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
2619
+ if (normalized.includes(":")) {
2620
+ return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
2621
+ }
2622
+ return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
2623
+ }
2624
+ function isBlockedHostname(hostname) {
2625
+ return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
2626
+ }
2627
+ function isIPAddress(hostname) {
2628
+ const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
2629
+ const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
2630
+ return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
2631
+ }
2632
+ async function validateUrlForSSRF(urlString) {
2633
+ try {
2634
+ const url = new URL(urlString);
2635
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
2636
+ return {
2637
+ isValid: false,
2638
+ error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
2639
+ };
2640
+ }
2641
+ if (url.username || url.password) {
2642
+ return {
2643
+ isValid: false,
2644
+ error: "URLs with embedded credentials are not allowed."
2645
+ };
2646
+ }
2647
+ const hostname = url.hostname.toLowerCase();
2648
+ if (isBlockedHostname(hostname)) {
2649
+ return {
2650
+ isValid: false,
2651
+ error: "Access to internal/localhost addresses is not allowed."
2652
+ };
2653
+ }
2654
+ if (isIPAddress(hostname) && isBlockedIP(hostname)) {
2655
+ return {
2656
+ isValid: false,
2657
+ error: "Access to private/internal IP addresses is not allowed."
2658
+ };
2659
+ }
2660
+ const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
2661
+ if (BLOCKED_PORTS.includes(port)) {
2662
+ return {
2663
+ isValid: false,
2664
+ error: `Access to port ${port} is not allowed.`
2665
+ };
2666
+ }
2667
+ let resolvedIP;
2668
+ if (!isIPAddress(hostname)) {
2669
+ try {
2670
+ const dns = await import("node:dns");
2671
+ const resolver = new dns.promises.Resolver();
2672
+ const resolvedIPs = [];
2673
+ try {
2674
+ const aRecords = await resolver.resolve4(hostname);
2675
+ resolvedIPs.push(...aRecords);
2676
+ } catch {
2677
+ }
2678
+ try {
2679
+ const aaaaRecords = await resolver.resolve6(hostname);
2680
+ resolvedIPs.push(...aaaaRecords);
2681
+ } catch {
2682
+ }
2683
+ if (resolvedIPs.length === 0) {
2684
+ return {
2685
+ isValid: false,
2686
+ error: "DNS resolution failed: hostname did not resolve to any address."
2687
+ };
2688
+ }
2689
+ for (const ip of resolvedIPs) {
2690
+ if (isBlockedIP(ip)) {
2691
+ return {
2692
+ isValid: false,
2693
+ error: "Hostname resolves to a private/internal IP address."
2694
+ };
2695
+ }
2696
+ }
2697
+ resolvedIP = resolvedIPs[0];
2698
+ } catch {
2699
+ return {
2700
+ isValid: false,
2701
+ error: "DNS resolution failed. Cannot verify hostname safety."
2702
+ };
2703
+ }
2704
+ }
2705
+ return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
2706
+ } catch (error) {
2707
+ return {
2708
+ isValid: false,
2709
+ error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
2710
+ };
2711
+ }
2712
+ }
2713
+ function quickSSRFCheck(urlString) {
2714
+ try {
2715
+ const url = new URL(urlString);
2716
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
2717
+ return { isValid: false, error: `Invalid protocol: ${url.protocol}` };
2718
+ }
2719
+ if (url.username || url.password) {
2720
+ return { isValid: false, error: "URLs with credentials not allowed" };
2721
+ }
2722
+ const hostname = url.hostname.toLowerCase();
2723
+ if (isBlockedHostname(hostname) || isIPAddress(hostname) && isBlockedIP(hostname)) {
2724
+ return { isValid: false, error: "Access to internal addresses not allowed" };
2725
+ }
2726
+ return { isValid: true, sanitizedUrl: url.toString() };
2727
+ } catch {
2728
+ return { isValid: false, error: "Invalid URL format" };
2729
+ }
2730
+ }
2731
+
2567
2732
  // src/tools/distribution.ts
2568
2733
  init_supabase();
2569
2734
 
@@ -2725,16 +2890,42 @@ function asEnvelope2(data) {
2725
2890
  data
2726
2891
  };
2727
2892
  }
2893
+ function isAlreadyR2Signed(url) {
2894
+ try {
2895
+ const u = new URL(url);
2896
+ return u.searchParams.has("X-Amz-Signature");
2897
+ } catch {
2898
+ return false;
2899
+ }
2900
+ }
2901
+ async function rehostExternalUrl(mediaUrl, projectId) {
2902
+ if (isAlreadyR2Signed(mediaUrl)) {
2903
+ return { signedUrl: mediaUrl, r2Key: "" };
2904
+ }
2905
+ const ssrf = quickSSRFCheck(mediaUrl);
2906
+ if (!ssrf.isValid) {
2907
+ return { error: ssrf.error ?? "URL rejected by SSRF check" };
2908
+ }
2909
+ const { data, error } = await callEdgeFunction(
2910
+ "upload-to-r2",
2911
+ { url: ssrf.sanitizedUrl ?? mediaUrl, projectId },
2912
+ { timeoutMs: 6e4 }
2913
+ );
2914
+ if (error || !data?.key || !data?.url) {
2915
+ return { error: error ?? "upload-to-r2 returned no key" };
2916
+ }
2917
+ return { signedUrl: data.url, r2Key: data.key };
2918
+ }
2728
2919
  function registerDistributionTools(server) {
2729
2920
  server.tool(
2730
2921
  "schedule_post",
2731
2922
  '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.',
2732
2923
  {
2733
2924
  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."
2925
+ "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
2926
  ),
2736
2927
  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."
2928
+ "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
2929
  ),
2739
2930
  r2_key: z3.string().optional().describe(
2740
2931
  "R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
@@ -2823,7 +3014,10 @@ function registerDistributionTools(server) {
2823
3014
  ),
2824
3015
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
2825
3016
  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.')
3017
+ attribution: z3.boolean().optional().describe('If true, appends "Created with Social Neuron" to the caption. Default: false.'),
3018
+ auto_rehost: z3.boolean().optional().describe(
3019
+ "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."
3020
+ )
2827
3021
  },
2828
3022
  async ({
2829
3023
  media_url,
@@ -2837,6 +3031,7 @@ function registerDistributionTools(server) {
2837
3031
  project_id,
2838
3032
  response_format,
2839
3033
  attribution,
3034
+ auto_rehost,
2840
3035
  r2_key,
2841
3036
  r2_keys,
2842
3037
  job_id,
@@ -2948,12 +3143,54 @@ function registerDistributionTools(server) {
2948
3143
  }
2949
3144
  resolvedMediaUrls = resolved;
2950
3145
  }
3146
+ const shouldRehost = auto_rehost !== false;
3147
+ if (shouldRehost && resolvedMediaUrl && !isAlreadyR2Signed(resolvedMediaUrl)) {
3148
+ const rehost = await rehostExternalUrl(resolvedMediaUrl, project_id);
3149
+ if ("error" in rehost) {
3150
+ return {
3151
+ content: [
3152
+ {
3153
+ type: "text",
3154
+ 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.`
3155
+ }
3156
+ ],
3157
+ isError: true
3158
+ };
3159
+ }
3160
+ resolvedMediaUrl = rehost.signedUrl;
3161
+ }
3162
+ if (shouldRehost && resolvedMediaUrls && resolvedMediaUrls.length > 0) {
3163
+ const needsRehost = resolvedMediaUrls.map((u) => !isAlreadyR2Signed(u));
3164
+ if (needsRehost.some(Boolean)) {
3165
+ const rehosted = await Promise.all(
3166
+ resolvedMediaUrls.map(
3167
+ (u, i) => needsRehost[i] ? rehostExternalUrl(u, project_id) : Promise.resolve({ signedUrl: u, r2Key: "" })
3168
+ )
3169
+ );
3170
+ const failIdx = rehosted.findIndex((r) => "error" in r);
3171
+ if (failIdx !== -1) {
3172
+ const failed = rehosted[failIdx];
3173
+ return {
3174
+ content: [
3175
+ {
3176
+ type: "text",
3177
+ 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.`
3178
+ }
3179
+ ],
3180
+ isError: true
3181
+ };
3182
+ }
3183
+ resolvedMediaUrls = rehosted.map(
3184
+ (r) => r.signedUrl
3185
+ );
3186
+ }
3187
+ }
2951
3188
  } catch (resolveErr) {
2952
3189
  return {
2953
3190
  content: [
2954
3191
  {
2955
3192
  type: "text",
2956
- text: `Failed to resolve media: ${sanitizeError2(resolveErr)}`
3193
+ text: `Failed to resolve media: ${sanitizeError(resolveErr)}`
2957
3194
  }
2958
3195
  ],
2959
3196
  isError: true
@@ -3318,7 +3555,7 @@ Created with Social Neuron`;
3318
3555
  return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
3319
3556
  } catch (err) {
3320
3557
  const durationMs = Date.now() - startedAt;
3321
- const message = sanitizeError2(err);
3558
+ const message = sanitizeError(err);
3322
3559
  logMcpToolInvocation({
3323
3560
  toolName: "find_next_slots",
3324
3561
  status: "error",
@@ -3838,7 +4075,7 @@ Created with Social Neuron`;
3838
4075
  };
3839
4076
  } catch (err) {
3840
4077
  const durationMs = Date.now() - startedAt;
3841
- const message = sanitizeError2(err);
4078
+ const message = sanitizeError(err);
3842
4079
  logMcpToolInvocation({
3843
4080
  toolName: "schedule_content_plan",
3844
4081
  status: "error",
@@ -3860,6 +4097,19 @@ import { readFile } from "node:fs/promises";
3860
4097
  import { basename, extname } from "node:path";
3861
4098
  init_supabase();
3862
4099
  var MAX_BASE64_SIZE = 10 * 1024 * 1024;
4100
+ var ALLOWED_UPLOAD_TYPES = /* @__PURE__ */ new Set([
4101
+ "image/png",
4102
+ "image/jpeg",
4103
+ "image/gif",
4104
+ "image/webp",
4105
+ "image/svg+xml",
4106
+ "video/mp4",
4107
+ "video/quicktime",
4108
+ "video/x-msvideo",
4109
+ "video/webm"
4110
+ ]);
4111
+ var BASE64_CHARS = /^[A-Za-z0-9+/]+={0,2}$/;
4112
+ var DATA_URI_PREFIX = /^data:([^;,]+);base64,/;
3863
4113
  function maskR2Key(key) {
3864
4114
  const segments = key.split("/");
3865
4115
  return segments.length >= 3 ? `\u2026/${segments.slice(-2).join("/")}` : key;
@@ -3880,21 +4130,35 @@ function inferContentType(filePath) {
3880
4130
  };
3881
4131
  return map[ext] || "application/octet-stream";
3882
4132
  }
4133
+ function approxBase64Size(raw) {
4134
+ const len = raw.length;
4135
+ if (len === 0) return 0;
4136
+ let padding = 0;
4137
+ if (raw.endsWith("==")) padding = 2;
4138
+ else if (raw.endsWith("=")) padding = 1;
4139
+ return Math.floor(len * 3 / 4) - padding;
4140
+ }
3883
4141
  function registerMediaTools(server) {
3884
4142
  server.tool(
3885
4143
  "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.",
4144
+ "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 \u2014 use this from Claude Desktop, Claude Web, or any remote agent that cannot hand the server a filesystem path. Base64 uploads are capped at 10MB decoded; larger files still need stdio + presigned PUT.",
3887
4145
  {
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.'
4146
+ source: z4.string().optional().describe(
4147
+ '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.'
4148
+ ),
4149
+ file_data: z4.string().optional().describe(
4150
+ '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.'
4151
+ ),
4152
+ file_name: z4.string().optional().describe(
4153
+ 'Optional filename for the upload (e.g. "hero.png"). Path components are stripped \u2014 only the basename is used.'
3890
4154
  ),
3891
4155
  content_type: z4.string().optional().describe(
3892
- 'MIME type (e.g. "image/png", "video/mp4"). Auto-detected from file extension if omitted.'
4156
+ '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
4157
  ),
3894
4158
  project_id: z4.string().optional().describe("Project ID for R2 path organization."),
3895
4159
  response_format: z4.enum(["text", "json"]).optional().describe("Response format. Default: text.")
3896
4160
  },
3897
- async ({ source, content_type, project_id, response_format }) => {
4161
+ async ({ source, file_data, file_name, content_type, project_id, response_format }) => {
3898
4162
  const format = response_format ?? "text";
3899
4163
  const startedAt = Date.now();
3900
4164
  const userId = await getDefaultUserId();
@@ -3916,46 +4180,187 @@ function registerMediaTools(server) {
3916
4180
  isError: true
3917
4181
  };
3918
4182
  }
3919
- const isUrl = source.startsWith("http://") || source.startsWith("https://");
3920
- const isLocalFile = !isUrl;
4183
+ if (!source && !file_data) {
4184
+ return {
4185
+ content: [
4186
+ {
4187
+ type: "text",
4188
+ text: "upload_media requires either `source` (path or URL) or `file_data` (base64)."
4189
+ }
4190
+ ],
4191
+ isError: true
4192
+ };
4193
+ }
4194
+ if (file_data) {
4195
+ let raw = file_data;
4196
+ let detectedType;
4197
+ const prefixMatch = raw.match(DATA_URI_PREFIX);
4198
+ if (prefixMatch) {
4199
+ detectedType = prefixMatch[1].trim().toLowerCase();
4200
+ raw = raw.slice(prefixMatch[0].length);
4201
+ }
4202
+ const ct = (content_type ?? detectedType ?? "").trim().toLowerCase();
4203
+ if (!ct) {
4204
+ return {
4205
+ content: [
4206
+ {
4207
+ type: "text",
4208
+ text: "content_type is required when file_data has no data: prefix."
4209
+ }
4210
+ ],
4211
+ isError: true
4212
+ };
4213
+ }
4214
+ if (!ALLOWED_UPLOAD_TYPES.has(ct)) {
4215
+ return {
4216
+ content: [
4217
+ {
4218
+ type: "text",
4219
+ text: `content_type "${ct}" is not supported. Allowed: ${[...ALLOWED_UPLOAD_TYPES].sort().join(", ")}.`
4220
+ }
4221
+ ],
4222
+ isError: true
4223
+ };
4224
+ }
4225
+ const stripped = raw.replace(/\s+/g, "");
4226
+ if (!BASE64_CHARS.test(stripped)) {
4227
+ return {
4228
+ content: [
4229
+ {
4230
+ type: "text",
4231
+ text: "file_data is not valid base64 \u2014 only A-Z, a-z, 0-9, +, /, = are allowed."
4232
+ }
4233
+ ],
4234
+ isError: true
4235
+ };
4236
+ }
4237
+ const approxSize = approxBase64Size(stripped);
4238
+ if (approxSize > MAX_BASE64_SIZE) {
4239
+ return {
4240
+ content: [
4241
+ {
4242
+ type: "text",
4243
+ text: `file_data exceeds the 10MB base64 cap (got ~${(approxSize / 1024 / 1024).toFixed(1)}MB). For larger files, run the stdio MCP server locally and pass a file path so the server can use presigned PUT upload.`
4244
+ }
4245
+ ],
4246
+ isError: true
4247
+ };
4248
+ }
4249
+ const safeName = basename(file_name ?? "upload");
4250
+ const uploadBody2 = {
4251
+ fileData: `data:${ct};base64,${stripped}`,
4252
+ contentType: ct,
4253
+ fileName: safeName,
4254
+ projectId: project_id
4255
+ };
4256
+ const { data: data2, error: error2 } = await callEdgeFunction("upload-to-r2", uploadBody2, { timeoutMs: 6e4 });
4257
+ if (error2) {
4258
+ await logMcpToolInvocation({
4259
+ toolName: "upload_media",
4260
+ status: "error",
4261
+ durationMs: Date.now() - startedAt,
4262
+ details: { error: error2, source: "base64", contentType: ct, size: approxSize }
4263
+ });
4264
+ return {
4265
+ content: [{ type: "text", text: `Upload failed: ${error2}` }],
4266
+ isError: true
4267
+ };
4268
+ }
4269
+ if (!data2?.key) {
4270
+ return {
4271
+ content: [{ type: "text", text: "Upload returned no R2 key." }],
4272
+ isError: true
4273
+ };
4274
+ }
4275
+ await logMcpToolInvocation({
4276
+ toolName: "upload_media",
4277
+ status: "success",
4278
+ durationMs: Date.now() - startedAt,
4279
+ details: {
4280
+ source: "base64",
4281
+ r2Key: data2.key,
4282
+ size: data2.size,
4283
+ contentType: data2.contentType
4284
+ }
4285
+ });
4286
+ if (format === "json") {
4287
+ return {
4288
+ content: [
4289
+ {
4290
+ type: "text",
4291
+ text: JSON.stringify(
4292
+ {
4293
+ r2_key: data2.key,
4294
+ signed_url: data2.url,
4295
+ size: data2.size,
4296
+ content_type: data2.contentType
4297
+ },
4298
+ null,
4299
+ 2
4300
+ )
4301
+ }
4302
+ ],
4303
+ isError: false
4304
+ };
4305
+ }
4306
+ return {
4307
+ content: [
4308
+ {
4309
+ type: "text",
4310
+ text: [
4311
+ "Media uploaded successfully.",
4312
+ `Media key: ${maskR2Key(data2.key)}`,
4313
+ `Signed URL: ${data2.url}`,
4314
+ `Size: ${(data2.size / 1024).toFixed(0)}KB`,
4315
+ `Type: ${data2.contentType}`,
4316
+ "",
4317
+ "Use job_id or response_format=json with schedule_post to post to any platform."
4318
+ ].join("\n")
4319
+ }
4320
+ ],
4321
+ isError: false
4322
+ };
4323
+ }
4324
+ const src = source;
4325
+ const isUrl = src.startsWith("http://") || src.startsWith("https://");
3921
4326
  let uploadBody;
3922
4327
  if (isUrl) {
3923
- const ct = content_type || inferContentType(source);
4328
+ const ct = content_type || inferContentType(src);
3924
4329
  uploadBody = {
3925
- url: source,
4330
+ url: src,
3926
4331
  contentType: ct,
3927
- fileName: basename(new URL(source).pathname) || "upload",
4332
+ fileName: basename(file_name ?? new URL(src).pathname) || "upload",
3928
4333
  projectId: project_id
3929
4334
  };
3930
4335
  } else {
3931
4336
  let fileBuffer;
3932
4337
  try {
3933
- fileBuffer = await readFile(source);
3934
- } catch (err) {
4338
+ fileBuffer = await readFile(src);
4339
+ } catch {
3935
4340
  await logMcpToolInvocation({
3936
4341
  toolName: "upload_media",
3937
4342
  status: "error",
3938
4343
  durationMs: Date.now() - startedAt,
3939
- details: { error: `File not found: ${source}` }
4344
+ details: { error: "File not found", source: "local" }
3940
4345
  });
3941
4346
  return {
3942
4347
  content: [
3943
4348
  {
3944
4349
  type: "text",
3945
- text: `File not found or not readable: ${source}`
4350
+ 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
4351
  }
3947
4352
  ],
3948
4353
  isError: true
3949
4354
  };
3950
4355
  }
3951
- const ct = content_type || inferContentType(source);
4356
+ const ct = content_type || inferContentType(src);
3952
4357
  if (fileBuffer.length > MAX_BASE64_SIZE) {
3953
4358
  const { data: putData, error: putError } = await callEdgeFunction(
3954
4359
  "get-signed-url",
3955
4360
  {
3956
4361
  operation: "put",
3957
4362
  contentType: ct,
3958
- filename: basename(source),
4363
+ filename: basename(file_name ?? src),
3959
4364
  projectId: project_id
3960
4365
  },
3961
4366
  { timeoutMs: 1e4 }
@@ -4057,7 +4462,7 @@ function registerMediaTools(server) {
4057
4462
  uploadBody = {
4058
4463
  fileData: base64,
4059
4464
  contentType: ct,
4060
- fileName: basename(source),
4465
+ fileName: basename(file_name ?? src),
4061
4466
  projectId: project_id
4062
4467
  };
4063
4468
  }
@@ -4446,155 +4851,6 @@ function formatAnalytics(summary, days, format) {
4446
4851
  // src/tools/brand.ts
4447
4852
  import { z as z6 } from "zod";
4448
4853
  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
- };
4586
- }
4587
- }
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
- }
4595
- }
4596
-
4597
- // src/tools/brand.ts
4598
4854
  function asEnvelope4(data) {
4599
4855
  return {
4600
4856
  _meta: {
@@ -5117,7 +5373,7 @@ function registerScreenshotTools(server) {
5117
5373
  };
5118
5374
  } catch (err) {
5119
5375
  await closeBrowser();
5120
- const message = sanitizeError2(err);
5376
+ const message = sanitizeError(err);
5121
5377
  await logMcpToolInvocation({
5122
5378
  toolName: "capture_app_page",
5123
5379
  status: "error",
@@ -5273,7 +5529,7 @@ function registerScreenshotTools(server) {
5273
5529
  };
5274
5530
  } catch (err) {
5275
5531
  await closeBrowser();
5276
- const message = sanitizeError2(err);
5532
+ const message = sanitizeError(err);
5277
5533
  await logMcpToolInvocation({
5278
5534
  toolName: "capture_screenshot",
5279
5535
  status: "error",
@@ -5566,7 +5822,7 @@ function registerRemotionTools(server) {
5566
5822
  ]
5567
5823
  };
5568
5824
  } catch (err) {
5569
- const message = sanitizeError2(err);
5825
+ const message = sanitizeError(err);
5570
5826
  await logMcpToolInvocation({
5571
5827
  toolName: "render_demo_video",
5572
5828
  status: "error",
@@ -5694,7 +5950,7 @@ function registerRemotionTools(server) {
5694
5950
  ]
5695
5951
  };
5696
5952
  } catch (err) {
5697
- const message = sanitizeError2(err);
5953
+ const message = sanitizeError(err);
5698
5954
  await logMcpToolInvocation({
5699
5955
  toolName: "render_template_video",
5700
5956
  status: "error",
@@ -7611,7 +7867,7 @@ function registerExtractionTools(server) {
7611
7867
  };
7612
7868
  } catch (err) {
7613
7869
  const durationMs = Date.now() - startedAt;
7614
- const message = sanitizeError2(err);
7870
+ const message = sanitizeError(err);
7615
7871
  logMcpToolInvocation({
7616
7872
  toolName: "extract_url_content",
7617
7873
  status: "error",
@@ -8167,7 +8423,7 @@ ${rawText.slice(0, 1e3)}`
8167
8423
  }
8168
8424
  } catch (persistErr) {
8169
8425
  const durationMs2 = Date.now() - startedAt;
8170
- const message = sanitizeError2(persistErr);
8426
+ const message = sanitizeError(persistErr);
8171
8427
  logMcpToolInvocation({
8172
8428
  toolName: "plan_content_week",
8173
8429
  status: "error",
@@ -8199,7 +8455,7 @@ ${rawText.slice(0, 1e3)}`
8199
8455
  };
8200
8456
  } catch (err) {
8201
8457
  const durationMs = Date.now() - startedAt;
8202
- const message = sanitizeError2(err);
8458
+ const message = sanitizeError(err);
8203
8459
  logMcpToolInvocation({
8204
8460
  toolName: "plan_content_week",
8205
8461
  status: "error",
@@ -8291,7 +8547,7 @@ ${rawText.slice(0, 1e3)}`
8291
8547
  };
8292
8548
  } catch (err) {
8293
8549
  const durationMs = Date.now() - startedAt;
8294
- const message = sanitizeError2(err);
8550
+ const message = sanitizeError(err);
8295
8551
  logMcpToolInvocation({
8296
8552
  toolName: "save_content_plan",
8297
8553
  status: "error",
@@ -9381,7 +9637,7 @@ function registerPipelineTools(server) {
9381
9637
  return { content: [{ type: "text", text: lines.join("\n") }] };
9382
9638
  } catch (err) {
9383
9639
  const durationMs = Date.now() - startedAt;
9384
- const message = sanitizeError2(err);
9640
+ const message = sanitizeError(err);
9385
9641
  logMcpToolInvocation({
9386
9642
  toolName: "check_pipeline_readiness",
9387
9643
  status: "error",
@@ -9542,7 +9798,7 @@ function registerPipelineTools(server) {
9542
9798
  } catch (deductErr) {
9543
9799
  errors.push({
9544
9800
  stage: "planning",
9545
- message: `Credit deduction failed: ${sanitizeError2(deductErr)}`
9801
+ message: `Credit deduction failed: ${sanitizeError(deductErr)}`
9546
9802
  });
9547
9803
  }
9548
9804
  }
@@ -9713,7 +9969,7 @@ function registerPipelineTools(server) {
9713
9969
  } catch (schedErr) {
9714
9970
  errors.push({
9715
9971
  stage: "schedule",
9716
- message: `Failed to schedule ${post.id}: ${sanitizeError2(schedErr)}`
9972
+ message: `Failed to schedule ${post.id}: ${sanitizeError(schedErr)}`
9717
9973
  });
9718
9974
  }
9719
9975
  }
@@ -9802,7 +10058,7 @@ function registerPipelineTools(server) {
9802
10058
  return { content: [{ type: "text", text: lines.join("\n") }] };
9803
10059
  } catch (err) {
9804
10060
  const durationMs = Date.now() - startedAt;
9805
- const message = sanitizeError2(err);
10061
+ const message = sanitizeError(err);
9806
10062
  logMcpToolInvocation({
9807
10063
  toolName: "run_content_pipeline",
9808
10064
  status: "error",
@@ -10026,7 +10282,7 @@ function registerPipelineTools(server) {
10026
10282
  return { content: [{ type: "text", text: lines.join("\n") }] };
10027
10283
  } catch (err) {
10028
10284
  const durationMs = Date.now() - startedAt;
10029
- const message = sanitizeError2(err);
10285
+ const message = sanitizeError(err);
10030
10286
  logMcpToolInvocation({
10031
10287
  toolName: "auto_approve_plan",
10032
10288
  status: "error",
@@ -10204,7 +10460,7 @@ ${i + 1}. ${s.topic}`);
10204
10460
  return { content: [{ type: "text", text: lines.join("\n") }] };
10205
10461
  } catch (err) {
10206
10462
  const durationMs = Date.now() - startedAt;
10207
- const message = sanitizeError2(err);
10463
+ const message = sanitizeError(err);
10208
10464
  logMcpToolInvocation({
10209
10465
  toolName: "suggest_next_content",
10210
10466
  status: "error",
@@ -10543,7 +10799,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10543
10799
  return { content: [{ type: "text", text: lines.join("\n") }] };
10544
10800
  } catch (err) {
10545
10801
  const durationMs = Date.now() - startedAt;
10546
- const message = sanitizeError2(err);
10802
+ const message = sanitizeError(err);
10547
10803
  logMcpToolInvocation({
10548
10804
  toolName: "generate_performance_digest",
10549
10805
  status: "error",
@@ -10640,7 +10896,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10640
10896
  return { content: [{ type: "text", text: lines.join("\n") }] };
10641
10897
  } catch (err) {
10642
10898
  const durationMs = Date.now() - startedAt;
10643
- const message = sanitizeError2(err);
10899
+ const message = sanitizeError(err);
10644
10900
  logMcpToolInvocation({
10645
10901
  toolName: "detect_anomalies",
10646
10902
  status: "error",
@@ -11669,7 +11925,7 @@ function registerCarouselTools(server) {
11669
11925
  slideNumber: slide.slideNumber,
11670
11926
  jobId: null,
11671
11927
  model: image_model,
11672
- error: sanitizeError2(err)
11928
+ error: sanitizeError(err)
11673
11929
  };
11674
11930
  }
11675
11931
  })
@@ -13086,7 +13342,7 @@ app.post("/mcp", authenticateRequest, async (req, res) => {
13086
13342
  const rawMessage = err instanceof Error ? err.message : "Internal server error";
13087
13343
  console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
13088
13344
  if (!res.headersSent) {
13089
- res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: sanitizeError2(err) } });
13345
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: sanitizeError(err) } });
13090
13346
  }
13091
13347
  }
13092
13348
  });
@@ -13128,7 +13384,7 @@ app.delete("/mcp", authenticateRequest, async (req, res) => {
13128
13384
  app.use((err, _req, res, _next) => {
13129
13385
  console.error("[MCP HTTP] Unhandled Express error:", err.stack || err.message || err);
13130
13386
  if (!res.headersSent) {
13131
- res.status(500).json({ error: "internal_error", error_description: sanitizeError2(err) });
13387
+ res.status(500).json({ error: "internal_error", error_description: sanitizeError(err) });
13132
13388
  }
13133
13389
  });
13134
13390
  var httpServer = app.listen(PORT, "0.0.0.0", () => {