@socialneuron/mcp-server 1.7.3 → 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
@@ -570,6 +570,8 @@ var TOOL_SCOPES = {
570
570
  get_brand_runtime: "mcp:read",
571
571
  explain_brand_system: "mcp:read",
572
572
  check_brand_consistency: "mcp:read",
573
+ audit_brand_colors: "mcp:read",
574
+ export_design_tokens: "mcp:read",
573
575
  get_ideation_context: "mcp:read",
574
576
  get_credit_balance: "mcp:read",
575
577
  get_budget_status: "mcp:read",
@@ -591,6 +593,7 @@ var TOOL_SCOPES = {
591
593
  create_storyboard: "mcp:write",
592
594
  generate_voiceover: "mcp:write",
593
595
  generate_carousel: "mcp:write",
596
+ create_carousel: "mcp:write",
594
597
  upload_media: "mcp:write",
595
598
  // mcp:read (media)
596
599
  get_media_url: "mcp:read",
@@ -609,6 +612,11 @@ var TOOL_SCOPES = {
609
612
  list_autopilot_configs: "mcp:autopilot",
610
613
  update_autopilot_config: "mcp:autopilot",
611
614
  get_autopilot_status: "mcp:autopilot",
615
+ // Recipes
616
+ list_recipes: "mcp:read",
617
+ get_recipe_details: "mcp:read",
618
+ execute_recipe: "mcp:write",
619
+ get_recipe_run_status: "mcp:read",
612
620
  // mcp:read (content lifecycle — read-only tools)
613
621
  extract_url_content: "mcp:read",
614
622
  quality_check: "mcp:read",
@@ -1375,7 +1383,7 @@ init_supabase();
1375
1383
  init_request_context();
1376
1384
 
1377
1385
  // src/lib/version.ts
1378
- var MCP_VERSION = "1.7.2";
1386
+ var MCP_VERSION = "1.7.5";
1379
1387
 
1380
1388
  // src/tools/content.ts
1381
1389
  var MAX_CREDITS_PER_RUN = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
@@ -2543,7 +2551,7 @@ var ERROR_PATTERNS = [
2543
2551
  // Generic sensitive patterns (API keys, URLs with secrets)
2544
2552
  [/[a-z0-9]{32,}.*key|Bearer [a-zA-Z0-9._-]+/i, "An internal error occurred. Please try again."]
2545
2553
  ];
2546
- function sanitizeError2(error) {
2554
+ function sanitizeError(error) {
2547
2555
  const msg = error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown error";
2548
2556
  if (process.env.NODE_ENV !== "production") {
2549
2557
  console.error("[Error]", msg);
@@ -2556,6 +2564,171 @@ function sanitizeError2(error) {
2556
2564
  return "An unexpected error occurred. Please try again.";
2557
2565
  }
2558
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
+
2559
2732
  // src/tools/distribution.ts
2560
2733
  init_supabase();
2561
2734
 
@@ -2717,16 +2890,42 @@ function asEnvelope2(data) {
2717
2890
  data
2718
2891
  };
2719
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
+ }
2720
2919
  function registerDistributionTools(server) {
2721
2920
  server.tool(
2722
2921
  "schedule_post",
2723
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.',
2724
2923
  {
2725
2924
  media_url: z3.string().optional().describe(
2726
- "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."
2727
2926
  ),
2728
2927
  media_urls: z3.array(z3.string()).optional().describe(
2729
- "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."
2730
2929
  ),
2731
2930
  r2_key: z3.string().optional().describe(
2732
2931
  "R2 object key from upload_media. Signed on demand at post time \u2014 survives scheduling delays. Alternative to media_url."
@@ -2815,7 +3014,10 @@ function registerDistributionTools(server) {
2815
3014
  ),
2816
3015
  project_id: z3.string().optional().describe("Social Neuron project ID to associate this post with."),
2817
3016
  response_format: z3.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text."),
2818
- 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
+ )
2819
3021
  },
2820
3022
  async ({
2821
3023
  media_url,
@@ -2829,6 +3031,7 @@ function registerDistributionTools(server) {
2829
3031
  project_id,
2830
3032
  response_format,
2831
3033
  attribution,
3034
+ auto_rehost,
2832
3035
  r2_key,
2833
3036
  r2_keys,
2834
3037
  job_id,
@@ -2940,12 +3143,54 @@ function registerDistributionTools(server) {
2940
3143
  }
2941
3144
  resolvedMediaUrls = resolved;
2942
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
+ }
2943
3188
  } catch (resolveErr) {
2944
3189
  return {
2945
3190
  content: [
2946
3191
  {
2947
3192
  type: "text",
2948
- text: `Failed to resolve media: ${sanitizeError2(resolveErr)}`
3193
+ text: `Failed to resolve media: ${sanitizeError(resolveErr)}`
2949
3194
  }
2950
3195
  ],
2951
3196
  isError: true
@@ -3310,7 +3555,7 @@ Created with Social Neuron`;
3310
3555
  return { content: [{ type: "text", text: lines.join("\n") }], isError: false };
3311
3556
  } catch (err) {
3312
3557
  const durationMs = Date.now() - startedAt;
3313
- const message = sanitizeError2(err);
3558
+ const message = sanitizeError(err);
3314
3559
  logMcpToolInvocation({
3315
3560
  toolName: "find_next_slots",
3316
3561
  status: "error",
@@ -3830,7 +4075,7 @@ Created with Social Neuron`;
3830
4075
  };
3831
4076
  } catch (err) {
3832
4077
  const durationMs = Date.now() - startedAt;
3833
- const message = sanitizeError2(err);
4078
+ const message = sanitizeError(err);
3834
4079
  logMcpToolInvocation({
3835
4080
  toolName: "schedule_content_plan",
3836
4081
  status: "error",
@@ -3852,6 +4097,19 @@ import { readFile } from "node:fs/promises";
3852
4097
  import { basename, extname } from "node:path";
3853
4098
  init_supabase();
3854
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,/;
3855
4113
  function maskR2Key(key) {
3856
4114
  const segments = key.split("/");
3857
4115
  return segments.length >= 3 ? `\u2026/${segments.slice(-2).join("/")}` : key;
@@ -3872,21 +4130,35 @@ function inferContentType(filePath) {
3872
4130
  };
3873
4131
  return map[ext] || "application/octet-stream";
3874
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
+ }
3875
4141
  function registerMediaTools(server) {
3876
4142
  server.tool(
3877
4143
  "upload_media",
3878
- "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.",
3879
4145
  {
3880
- source: z4.string().describe(
3881
- '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.'
3882
4154
  ),
3883
4155
  content_type: z4.string().optional().describe(
3884
- '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.'
3885
4157
  ),
3886
4158
  project_id: z4.string().optional().describe("Project ID for R2 path organization."),
3887
4159
  response_format: z4.enum(["text", "json"]).optional().describe("Response format. Default: text.")
3888
4160
  },
3889
- async ({ source, content_type, project_id, response_format }) => {
4161
+ async ({ source, file_data, file_name, content_type, project_id, response_format }) => {
3890
4162
  const format = response_format ?? "text";
3891
4163
  const startedAt = Date.now();
3892
4164
  const userId = await getDefaultUserId();
@@ -3908,46 +4180,187 @@ function registerMediaTools(server) {
3908
4180
  isError: true
3909
4181
  };
3910
4182
  }
3911
- const isUrl = source.startsWith("http://") || source.startsWith("https://");
3912
- const isLocalFile = !isUrl;
3913
- let uploadBody;
3914
- if (isUrl) {
3915
- const ct = content_type || inferContentType(source);
3916
- uploadBody = {
3917
- url: source,
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}`,
3918
4252
  contentType: ct,
3919
- fileName: basename(new URL(source).pathname) || "upload",
4253
+ fileName: safeName,
3920
4254
  projectId: project_id
3921
4255
  };
3922
- } else {
3923
- let fileBuffer;
3924
- try {
3925
- fileBuffer = await readFile(source);
3926
- } catch (err) {
4256
+ const { data: data2, error: error2 } = await callEdgeFunction("upload-to-r2", uploadBody2, { timeoutMs: 6e4 });
4257
+ if (error2) {
3927
4258
  await logMcpToolInvocation({
3928
4259
  toolName: "upload_media",
3929
4260
  status: "error",
3930
4261
  durationMs: Date.now() - startedAt,
3931
- details: { error: `File not found: ${source}` }
4262
+ details: { error: error2, source: "base64", contentType: ct, size: approxSize }
3932
4263
  });
3933
4264
  return {
3934
- content: [
3935
- {
3936
- type: "text",
3937
- text: `File not found or not readable: ${source}`
3938
- }
3939
- ],
4265
+ content: [{ type: "text", text: `Upload failed: ${error2}` }],
3940
4266
  isError: true
3941
4267
  };
3942
4268
  }
3943
- const ct = content_type || inferContentType(source);
3944
- if (fileBuffer.length > MAX_BASE64_SIZE) {
3945
- const { data: putData, error: putError } = await callEdgeFunction(
3946
- "get-signed-url",
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://");
4326
+ let uploadBody;
4327
+ if (isUrl) {
4328
+ const ct = content_type || inferContentType(src);
4329
+ uploadBody = {
4330
+ url: src,
4331
+ contentType: ct,
4332
+ fileName: basename(file_name ?? new URL(src).pathname) || "upload",
4333
+ projectId: project_id
4334
+ };
4335
+ } else {
4336
+ let fileBuffer;
4337
+ try {
4338
+ fileBuffer = await readFile(src);
4339
+ } catch {
4340
+ await logMcpToolInvocation({
4341
+ toolName: "upload_media",
4342
+ status: "error",
4343
+ durationMs: Date.now() - startedAt,
4344
+ details: { error: "File not found", source: "local" }
4345
+ });
4346
+ return {
4347
+ content: [
4348
+ {
4349
+ type: "text",
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.`
4351
+ }
4352
+ ],
4353
+ isError: true
4354
+ };
4355
+ }
4356
+ const ct = content_type || inferContentType(src);
4357
+ if (fileBuffer.length > MAX_BASE64_SIZE) {
4358
+ const { data: putData, error: putError } = await callEdgeFunction(
4359
+ "get-signed-url",
3947
4360
  {
3948
4361
  operation: "put",
3949
4362
  contentType: ct,
3950
- filename: basename(source),
4363
+ filename: basename(file_name ?? src),
3951
4364
  projectId: project_id
3952
4365
  },
3953
4366
  { timeoutMs: 1e4 }
@@ -4049,7 +4462,7 @@ function registerMediaTools(server) {
4049
4462
  uploadBody = {
4050
4463
  fileData: base64,
4051
4464
  contentType: ct,
4052
- fileName: basename(source),
4465
+ fileName: basename(file_name ?? src),
4053
4466
  projectId: project_id
4054
4467
  };
4055
4468
  }
@@ -4438,155 +4851,6 @@ function formatAnalytics(summary, days, format) {
4438
4851
  // src/tools/brand.ts
4439
4852
  import { z as z6 } from "zod";
4440
4853
  init_supabase();
4441
-
4442
- // src/lib/ssrf.ts
4443
- var BLOCKED_IP_PATTERNS = [
4444
- // IPv4 localhost/loopback
4445
- /^127\./,
4446
- /^0\./,
4447
- // IPv4 private ranges (RFC 1918)
4448
- /^10\./,
4449
- /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
4450
- /^192\.168\./,
4451
- // IPv4 link-local
4452
- /^169\.254\./,
4453
- // Cloud metadata endpoint (AWS, GCP, Azure)
4454
- /^169\.254\.169\.254$/,
4455
- // IPv4 broadcast
4456
- /^255\./,
4457
- // Shared address space (RFC 6598)
4458
- /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
4459
- ];
4460
- var BLOCKED_IPV6_PATTERNS = [
4461
- /^::1$/i,
4462
- // loopback
4463
- /^::$/i,
4464
- // unspecified
4465
- /^fe[89ab][0-9a-f]:/i,
4466
- // link-local fe80::/10
4467
- /^fc[0-9a-f]:/i,
4468
- // unique local fc00::/7
4469
- /^fd[0-9a-f]:/i,
4470
- // unique local fc00::/7
4471
- /^::ffff:127\./i,
4472
- // IPv4-mapped localhost
4473
- /^::ffff:(0|10|127|169\.254|172\.(1[6-9]|2[0-9]|3[0-1])|192\.168)\./i
4474
- // IPv4-mapped private
4475
- ];
4476
- var BLOCKED_HOSTNAMES = [
4477
- "localhost",
4478
- "localhost.localdomain",
4479
- "local",
4480
- "127.0.0.1",
4481
- "0.0.0.0",
4482
- "[::1]",
4483
- "[::ffff:127.0.0.1]",
4484
- // Cloud metadata endpoints
4485
- "metadata.google.internal",
4486
- "metadata.goog",
4487
- "instance-data",
4488
- "instance-data.ec2.internal"
4489
- ];
4490
- var ALLOWED_PROTOCOLS = ["http:", "https:"];
4491
- var BLOCKED_PORTS = [22, 23, 25, 110, 143, 445, 3306, 5432, 6379, 27017, 11211];
4492
- function isBlockedIP(ip) {
4493
- const normalized = ip.replace(/^\[/, "").replace(/\]$/, "");
4494
- if (normalized.includes(":")) {
4495
- return BLOCKED_IPV6_PATTERNS.some((pattern) => pattern.test(normalized));
4496
- }
4497
- return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(normalized));
4498
- }
4499
- function isBlockedHostname(hostname) {
4500
- return BLOCKED_HOSTNAMES.includes(hostname.toLowerCase());
4501
- }
4502
- function isIPAddress(hostname) {
4503
- const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
4504
- const ipv6Pattern = /^\[?[a-fA-F0-9:]+\]?$/;
4505
- return ipv4Pattern.test(hostname) || ipv6Pattern.test(hostname);
4506
- }
4507
- async function validateUrlForSSRF(urlString) {
4508
- try {
4509
- const url = new URL(urlString);
4510
- if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
4511
- return {
4512
- isValid: false,
4513
- error: `Invalid protocol: ${url.protocol}. Only HTTP and HTTPS are allowed.`
4514
- };
4515
- }
4516
- if (url.username || url.password) {
4517
- return {
4518
- isValid: false,
4519
- error: "URLs with embedded credentials are not allowed."
4520
- };
4521
- }
4522
- const hostname = url.hostname.toLowerCase();
4523
- if (isBlockedHostname(hostname)) {
4524
- return {
4525
- isValid: false,
4526
- error: "Access to internal/localhost addresses is not allowed."
4527
- };
4528
- }
4529
- if (isIPAddress(hostname) && isBlockedIP(hostname)) {
4530
- return {
4531
- isValid: false,
4532
- error: "Access to private/internal IP addresses is not allowed."
4533
- };
4534
- }
4535
- const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80;
4536
- if (BLOCKED_PORTS.includes(port)) {
4537
- return {
4538
- isValid: false,
4539
- error: `Access to port ${port} is not allowed.`
4540
- };
4541
- }
4542
- let resolvedIP;
4543
- if (!isIPAddress(hostname)) {
4544
- try {
4545
- const dns = await import("node:dns");
4546
- const resolver = new dns.promises.Resolver();
4547
- const resolvedIPs = [];
4548
- try {
4549
- const aRecords = await resolver.resolve4(hostname);
4550
- resolvedIPs.push(...aRecords);
4551
- } catch {
4552
- }
4553
- try {
4554
- const aaaaRecords = await resolver.resolve6(hostname);
4555
- resolvedIPs.push(...aaaaRecords);
4556
- } catch {
4557
- }
4558
- if (resolvedIPs.length === 0) {
4559
- return {
4560
- isValid: false,
4561
- error: "DNS resolution failed: hostname did not resolve to any address."
4562
- };
4563
- }
4564
- for (const ip of resolvedIPs) {
4565
- if (isBlockedIP(ip)) {
4566
- return {
4567
- isValid: false,
4568
- error: "Hostname resolves to a private/internal IP address."
4569
- };
4570
- }
4571
- }
4572
- resolvedIP = resolvedIPs[0];
4573
- } catch {
4574
- return {
4575
- isValid: false,
4576
- error: "DNS resolution failed. Cannot verify hostname safety."
4577
- };
4578
- }
4579
- }
4580
- return { isValid: true, sanitizedUrl: url.toString(), resolvedIP };
4581
- } catch (error) {
4582
- return {
4583
- isValid: false,
4584
- error: `Invalid URL format: ${error instanceof Error ? error.message : "Unknown error"}`
4585
- };
4586
- }
4587
- }
4588
-
4589
- // src/tools/brand.ts
4590
4854
  function asEnvelope4(data) {
4591
4855
  return {
4592
4856
  _meta: {
@@ -5109,7 +5373,7 @@ function registerScreenshotTools(server) {
5109
5373
  };
5110
5374
  } catch (err) {
5111
5375
  await closeBrowser();
5112
- const message = sanitizeError2(err);
5376
+ const message = sanitizeError(err);
5113
5377
  await logMcpToolInvocation({
5114
5378
  toolName: "capture_app_page",
5115
5379
  status: "error",
@@ -5265,7 +5529,7 @@ function registerScreenshotTools(server) {
5265
5529
  };
5266
5530
  } catch (err) {
5267
5531
  await closeBrowser();
5268
- const message = sanitizeError2(err);
5532
+ const message = sanitizeError(err);
5269
5533
  await logMcpToolInvocation({
5270
5534
  toolName: "capture_screenshot",
5271
5535
  status: "error",
@@ -5558,7 +5822,7 @@ function registerRemotionTools(server) {
5558
5822
  ]
5559
5823
  };
5560
5824
  } catch (err) {
5561
- const message = sanitizeError2(err);
5825
+ const message = sanitizeError(err);
5562
5826
  await logMcpToolInvocation({
5563
5827
  toolName: "render_demo_video",
5564
5828
  status: "error",
@@ -5686,7 +5950,7 @@ function registerRemotionTools(server) {
5686
5950
  ]
5687
5951
  };
5688
5952
  } catch (err) {
5689
- const message = sanitizeError2(err);
5953
+ const message = sanitizeError(err);
5690
5954
  await logMcpToolInvocation({
5691
5955
  toolName: "render_template_video",
5692
5956
  status: "error",
@@ -7130,44 +7394,310 @@ Active: ${is_active}`
7130
7394
  );
7131
7395
  }
7132
7396
 
7133
- // src/tools/extraction.ts
7397
+ // src/tools/recipes.ts
7134
7398
  import { z as z17 } from "zod";
7135
- init_supabase();
7136
7399
  function asEnvelope13(data) {
7137
- return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
7138
- }
7139
- function isYouTubeUrl(url) {
7140
- if (/youtube\.com\/watch|youtu\.be\//.test(url)) return "video";
7141
- if (/youtube\.com\/@/.test(url)) return "channel";
7142
- return false;
7400
+ return {
7401
+ _meta: {
7402
+ version: MCP_VERSION,
7403
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
7404
+ },
7405
+ data
7406
+ };
7143
7407
  }
7144
- function formatExtractedContentAsText(content) {
7145
- const lines = [];
7146
- lines.push(`Source: ${content.source_type} (${content.url})`);
7147
- lines.push(`Title: ${content.title}`);
7148
- if (content.description) lines.push(`
7149
- Description:
7150
- ${content.description}`);
7151
- if (content.transcript)
7152
- lines.push(
7153
- `
7154
- Transcript:
7155
- ${content.transcript.slice(0, 3e3)}${content.transcript.length > 3e3 ? "\n... (truncated)" : ""}`
7156
- );
7157
- if (content.video_metadata) {
7158
- const m = content.video_metadata;
7159
- lines.push(`
7160
- Metadata:`);
7161
- lines.push(` Channel: ${m.channel_name}`);
7162
- lines.push(` Views: ${m.views?.toLocaleString() ?? "N/A"}`);
7163
- lines.push(` Likes: ${m.likes?.toLocaleString() ?? "N/A"}`);
7164
- lines.push(` Duration: ${m.duration ?? "N/A"}s`);
7165
- if (m.tags?.length) lines.push(` Tags: ${m.tags.join(", ")}`);
7166
- }
7167
- if (content.features?.length)
7168
- lines.push(`
7169
- Features:
7170
- ${content.features.map((f) => ` - ${f}`).join("\n")}`);
7408
+ function registerRecipeTools(server) {
7409
+ server.tool(
7410
+ "list_recipes",
7411
+ 'List available recipe templates. Recipes are pre-built multi-step workflows like "Weekly Instagram Calendar" or "Product Launch Sequence" that automate common content operations. Use this to discover what recipes are available before running one.',
7412
+ {
7413
+ category: z17.enum([
7414
+ "content_creation",
7415
+ "distribution",
7416
+ "repurposing",
7417
+ "analytics",
7418
+ "engagement",
7419
+ "general"
7420
+ ]).optional().describe("Filter by category. Omit to list all."),
7421
+ featured_only: z17.boolean().optional().describe("If true, only return featured recipes. Defaults to false."),
7422
+ response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7423
+ },
7424
+ async ({ category, featured_only, response_format }) => {
7425
+ const format = response_format ?? "text";
7426
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
7427
+ action: "list-recipes",
7428
+ category: category ?? null,
7429
+ featured_only: featured_only ?? false
7430
+ });
7431
+ if (efError) {
7432
+ return {
7433
+ content: [
7434
+ {
7435
+ type: "text",
7436
+ text: `Error fetching recipes: ${efError}`
7437
+ }
7438
+ ],
7439
+ isError: true
7440
+ };
7441
+ }
7442
+ const recipes = result?.recipes ?? [];
7443
+ if (format === "json") {
7444
+ return {
7445
+ content: [
7446
+ {
7447
+ type: "text",
7448
+ text: JSON.stringify(asEnvelope13(recipes))
7449
+ }
7450
+ ]
7451
+ };
7452
+ }
7453
+ if (recipes.length === 0) {
7454
+ return {
7455
+ content: [
7456
+ {
7457
+ type: "text",
7458
+ text: "No recipes found. Recipes are pre-built automation templates \u2014 check back after setup."
7459
+ }
7460
+ ]
7461
+ };
7462
+ }
7463
+ const lines = recipes.map(
7464
+ (r) => `**${r.name}** (${r.slug})
7465
+ ${r.description}
7466
+ Category: ${r.category} | Credits: ~${r.estimated_credits} | Steps: ${r.steps.length}${r.is_featured ? " | \u2B50 Featured" : ""}
7467
+ Inputs: ${r.inputs_schema.map((i) => `${i.label}${i.required ? "*" : ""}`).join(", ")}`
7468
+ );
7469
+ return {
7470
+ content: [
7471
+ {
7472
+ type: "text",
7473
+ text: `## Available Recipes (${recipes.length})
7474
+
7475
+ ${lines.join("\n\n")}`
7476
+ }
7477
+ ]
7478
+ };
7479
+ }
7480
+ );
7481
+ server.tool(
7482
+ "get_recipe_details",
7483
+ "Get full details of a recipe template including all steps, input schema, and estimated costs. Use this before execute_recipe to understand what inputs are required.",
7484
+ {
7485
+ slug: z17.string().describe('Recipe slug (e.g., "weekly-instagram-calendar")'),
7486
+ response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7487
+ },
7488
+ async ({ slug, response_format }) => {
7489
+ const format = response_format ?? "text";
7490
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
7491
+ action: "get-recipe-details",
7492
+ slug
7493
+ });
7494
+ if (efError) {
7495
+ return {
7496
+ content: [{ type: "text", text: `Error: ${efError}` }],
7497
+ isError: true
7498
+ };
7499
+ }
7500
+ const recipe = result?.recipe;
7501
+ if (!recipe) {
7502
+ return {
7503
+ content: [
7504
+ {
7505
+ type: "text",
7506
+ text: `Recipe "${slug}" not found. Use list_recipes to see available recipes.`
7507
+ }
7508
+ ],
7509
+ isError: true
7510
+ };
7511
+ }
7512
+ if (format === "json") {
7513
+ return {
7514
+ content: [
7515
+ {
7516
+ type: "text",
7517
+ text: JSON.stringify(asEnvelope13(recipe))
7518
+ }
7519
+ ]
7520
+ };
7521
+ }
7522
+ const stepsText = recipe.steps.map((s, i) => ` ${i + 1}. **${s.name}** (${s.type})`).join("\n");
7523
+ const inputsText = recipe.inputs_schema.map(
7524
+ (i) => ` - **${i.label}**${i.required ? " (required)" : ""}: ${i.type}${i.placeholder ? ` \u2014 e.g., "${i.placeholder}"` : ""}`
7525
+ ).join("\n");
7526
+ return {
7527
+ content: [
7528
+ {
7529
+ type: "text",
7530
+ text: [
7531
+ `## ${recipe.name}`,
7532
+ recipe.description,
7533
+ "",
7534
+ `**Category:** ${recipe.category}`,
7535
+ `**Estimated credits:** ~${recipe.estimated_credits}`,
7536
+ `**Estimated time:** ~${Math.round(recipe.estimated_duration_seconds / 60)} minutes`,
7537
+ "",
7538
+ "### Steps",
7539
+ stepsText,
7540
+ "",
7541
+ "### Required Inputs",
7542
+ inputsText
7543
+ ].join("\n")
7544
+ }
7545
+ ]
7546
+ };
7547
+ }
7548
+ );
7549
+ server.tool(
7550
+ "execute_recipe",
7551
+ "Execute a recipe template with the provided inputs. This creates a recipe run that processes each step sequentially. Long-running recipes will return a run_id you can check with get_recipe_run_status.",
7552
+ {
7553
+ slug: z17.string().describe('Recipe slug (e.g., "weekly-instagram-calendar")'),
7554
+ inputs: z17.record(z17.unknown()).describe(
7555
+ "Input values matching the recipe input schema. Use get_recipe_details to see required inputs."
7556
+ ),
7557
+ response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7558
+ },
7559
+ async ({ slug, inputs, response_format }) => {
7560
+ const format = response_format ?? "text";
7561
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
7562
+ action: "execute-recipe",
7563
+ slug,
7564
+ inputs
7565
+ });
7566
+ if (efError) {
7567
+ return {
7568
+ content: [{ type: "text", text: `Error: ${efError}` }],
7569
+ isError: true
7570
+ };
7571
+ }
7572
+ if (format === "json") {
7573
+ return {
7574
+ content: [
7575
+ {
7576
+ type: "text",
7577
+ text: JSON.stringify(asEnvelope13(result))
7578
+ }
7579
+ ]
7580
+ };
7581
+ }
7582
+ return {
7583
+ content: [
7584
+ {
7585
+ type: "text",
7586
+ text: `Recipe "${slug}" started.
7587
+
7588
+ **Run ID:** ${result?.run_id}
7589
+ **Status:** ${result?.status}
7590
+
7591
+ ${result?.message || "Use get_recipe_run_status to check progress."}`
7592
+ }
7593
+ ]
7594
+ };
7595
+ }
7596
+ );
7597
+ server.tool(
7598
+ "get_recipe_run_status",
7599
+ "Check the status of a running recipe execution. Shows progress, current step, credits used, and outputs when complete.",
7600
+ {
7601
+ run_id: z17.string().describe("The recipe run ID returned by execute_recipe"),
7602
+ response_format: z17.enum(["text", "json"]).optional().describe("Optional response format. Defaults to text.")
7603
+ },
7604
+ async ({ run_id, response_format }) => {
7605
+ const format = response_format ?? "text";
7606
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", {
7607
+ action: "get-recipe-run-status",
7608
+ run_id
7609
+ });
7610
+ if (efError) {
7611
+ return {
7612
+ content: [{ type: "text", text: `Error: ${efError}` }],
7613
+ isError: true
7614
+ };
7615
+ }
7616
+ const run = result?.run;
7617
+ if (!run) {
7618
+ return {
7619
+ content: [
7620
+ {
7621
+ type: "text",
7622
+ text: `Run "${run_id}" not found.`
7623
+ }
7624
+ ],
7625
+ isError: true
7626
+ };
7627
+ }
7628
+ if (format === "json") {
7629
+ return {
7630
+ content: [
7631
+ {
7632
+ type: "text",
7633
+ text: JSON.stringify(asEnvelope13(run))
7634
+ }
7635
+ ]
7636
+ };
7637
+ }
7638
+ const statusEmoji = run.status === "completed" ? "Done" : run.status === "failed" ? "Failed" : run.status === "running" ? "Running" : run.status;
7639
+ return {
7640
+ content: [
7641
+ {
7642
+ type: "text",
7643
+ text: [
7644
+ `**Recipe Run:** ${run.id}`,
7645
+ `**Status:** ${statusEmoji}`,
7646
+ `**Progress:** ${run.progress}%`,
7647
+ run.current_step ? `**Current step:** ${run.current_step}` : "",
7648
+ `**Credits used:** ${run.credits_used}`,
7649
+ run.completed_at ? `**Completed:** ${run.completed_at}` : "",
7650
+ run.outputs ? `
7651
+ **Outputs:**
7652
+ \`\`\`json
7653
+ ${JSON.stringify(run.outputs, null, 2)}
7654
+ \`\`\`` : ""
7655
+ ].filter(Boolean).join("\n")
7656
+ }
7657
+ ]
7658
+ };
7659
+ }
7660
+ );
7661
+ }
7662
+
7663
+ // src/tools/extraction.ts
7664
+ import { z as z18 } from "zod";
7665
+ init_supabase();
7666
+ function asEnvelope14(data) {
7667
+ return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
7668
+ }
7669
+ function isYouTubeUrl(url) {
7670
+ if (/youtube\.com\/watch|youtu\.be\//.test(url)) return "video";
7671
+ if (/youtube\.com\/@/.test(url)) return "channel";
7672
+ return false;
7673
+ }
7674
+ function formatExtractedContentAsText(content) {
7675
+ const lines = [];
7676
+ lines.push(`Source: ${content.source_type} (${content.url})`);
7677
+ lines.push(`Title: ${content.title}`);
7678
+ if (content.description) lines.push(`
7679
+ Description:
7680
+ ${content.description}`);
7681
+ if (content.transcript)
7682
+ lines.push(
7683
+ `
7684
+ Transcript:
7685
+ ${content.transcript.slice(0, 3e3)}${content.transcript.length > 3e3 ? "\n... (truncated)" : ""}`
7686
+ );
7687
+ if (content.video_metadata) {
7688
+ const m = content.video_metadata;
7689
+ lines.push(`
7690
+ Metadata:`);
7691
+ lines.push(` Channel: ${m.channel_name}`);
7692
+ lines.push(` Views: ${m.views?.toLocaleString() ?? "N/A"}`);
7693
+ lines.push(` Likes: ${m.likes?.toLocaleString() ?? "N/A"}`);
7694
+ lines.push(` Duration: ${m.duration ?? "N/A"}s`);
7695
+ if (m.tags?.length) lines.push(` Tags: ${m.tags.join(", ")}`);
7696
+ }
7697
+ if (content.features?.length)
7698
+ lines.push(`
7699
+ Features:
7700
+ ${content.features.map((f) => ` - ${f}`).join("\n")}`);
7171
7701
  if (content.benefits?.length)
7172
7702
  lines.push(`
7173
7703
  Benefits:
@@ -7187,11 +7717,11 @@ function registerExtractionTools(server) {
7187
7717
  "extract_url_content",
7188
7718
  "Extract text content from any URL \u2014 YouTube video transcripts, article text, or product page features/benefits/USP. YouTube URLs auto-route to transcript extraction with optional comments. Use before generate_content to repurpose existing content, or before plan_content_week to base a content plan on a source URL.",
7189
7719
  {
7190
- url: z17.string().url().describe("URL to extract content from"),
7191
- extract_type: z17.enum(["auto", "transcript", "article", "product"]).default("auto").describe("Type of extraction"),
7192
- include_comments: z17.boolean().default(false).describe("Include top comments (YouTube only)"),
7193
- max_results: z17.number().min(1).max(100).default(10).describe("Max comments to include"),
7194
- response_format: z17.enum(["text", "json"]).default("text")
7720
+ url: z18.string().url().describe("URL to extract content from"),
7721
+ extract_type: z18.enum(["auto", "transcript", "article", "product"]).default("auto").describe("Type of extraction"),
7722
+ include_comments: z18.boolean().default(false).describe("Include top comments (YouTube only)"),
7723
+ max_results: z18.number().min(1).max(100).default(10).describe("Max comments to include"),
7724
+ response_format: z18.enum(["text", "json"]).default("text")
7195
7725
  },
7196
7726
  async ({ url, extract_type, include_comments, max_results, response_format }) => {
7197
7727
  const startedAt = Date.now();
@@ -7326,7 +7856,7 @@ function registerExtractionTools(server) {
7326
7856
  if (response_format === "json") {
7327
7857
  return {
7328
7858
  content: [
7329
- { type: "text", text: JSON.stringify(asEnvelope13(extracted), null, 2) }
7859
+ { type: "text", text: JSON.stringify(asEnvelope14(extracted), null, 2) }
7330
7860
  ],
7331
7861
  isError: false
7332
7862
  };
@@ -7337,7 +7867,7 @@ function registerExtractionTools(server) {
7337
7867
  };
7338
7868
  } catch (err) {
7339
7869
  const durationMs = Date.now() - startedAt;
7340
- const message = sanitizeError2(err);
7870
+ const message = sanitizeError(err);
7341
7871
  logMcpToolInvocation({
7342
7872
  toolName: "extract_url_content",
7343
7873
  status: "error",
@@ -7354,9 +7884,9 @@ function registerExtractionTools(server) {
7354
7884
  }
7355
7885
 
7356
7886
  // src/tools/quality.ts
7357
- import { z as z18 } from "zod";
7887
+ import { z as z19 } from "zod";
7358
7888
  init_supabase();
7359
- function asEnvelope14(data) {
7889
+ function asEnvelope15(data) {
7360
7890
  return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
7361
7891
  }
7362
7892
  function registerQualityTools(server) {
@@ -7364,12 +7894,12 @@ function registerQualityTools(server) {
7364
7894
  "quality_check",
7365
7895
  "Score post quality across 7 categories: Hook Strength, Message Clarity, Platform Fit, Brand Alignment, Novelty, CTA Strength, and Safety/Claims. Each scored 0-5, total 35. Default pass threshold is 26 (~75%). Run after generate_content and before schedule_post. Include hashtags in caption if they will be published \u2014 they affect Platform Fit and Safety scores.",
7366
7896
  {
7367
- caption: z18.string().describe(
7897
+ caption: z19.string().describe(
7368
7898
  "The post text to score. Include hashtags if they will be published \u2014 they affect Platform Fit and Safety/Claims scores."
7369
7899
  ),
7370
- title: z18.string().optional().describe("Post title (important for YouTube)"),
7371
- platforms: z18.array(
7372
- z18.enum([
7900
+ title: z19.string().optional().describe("Post title (important for YouTube)"),
7901
+ platforms: z19.array(
7902
+ z19.enum([
7373
7903
  "youtube",
7374
7904
  "tiktok",
7375
7905
  "instagram",
@@ -7380,13 +7910,13 @@ function registerQualityTools(server) {
7380
7910
  "bluesky"
7381
7911
  ])
7382
7912
  ).min(1).describe("Target platforms"),
7383
- threshold: z18.number().min(0).max(35).default(26).describe(
7913
+ threshold: z19.number().min(0).max(35).default(26).describe(
7384
7914
  "Minimum total score to pass (max 35, scored across 7 categories at 0-5 each). Default 26 (~75%). Use 20 for rough drafts, 28+ for final posts going to large audiences."
7385
7915
  ),
7386
- brand_keyword: z18.string().optional().describe("Brand keyword for alignment check"),
7387
- brand_avoid_patterns: z18.array(z18.string()).optional(),
7388
- custom_banned_terms: z18.array(z18.string()).optional(),
7389
- response_format: z18.enum(["text", "json"]).default("text")
7916
+ brand_keyword: z19.string().optional().describe("Brand keyword for alignment check"),
7917
+ brand_avoid_patterns: z19.array(z19.string()).optional(),
7918
+ custom_banned_terms: z19.array(z19.string()).optional(),
7919
+ response_format: z19.enum(["text", "json"]).default("text")
7390
7920
  },
7391
7921
  async ({
7392
7922
  caption,
@@ -7417,7 +7947,7 @@ function registerQualityTools(server) {
7417
7947
  });
7418
7948
  if (response_format === "json") {
7419
7949
  return {
7420
- content: [{ type: "text", text: JSON.stringify(asEnvelope14(result), null, 2) }],
7950
+ content: [{ type: "text", text: JSON.stringify(asEnvelope15(result), null, 2) }],
7421
7951
  isError: false
7422
7952
  };
7423
7953
  }
@@ -7445,20 +7975,20 @@ function registerQualityTools(server) {
7445
7975
  "quality_check_plan",
7446
7976
  "Batch quality check all posts in a content plan. Returns per-post scores and aggregate pass/fail summary. Use after plan_content_week and before schedule_content_plan to catch low-quality posts before publishing.",
7447
7977
  {
7448
- plan: z18.object({
7449
- posts: z18.array(
7450
- z18.object({
7451
- id: z18.string(),
7452
- caption: z18.string(),
7453
- title: z18.string().optional(),
7454
- platform: z18.string()
7978
+ plan: z19.object({
7979
+ posts: z19.array(
7980
+ z19.object({
7981
+ id: z19.string(),
7982
+ caption: z19.string(),
7983
+ title: z19.string().optional(),
7984
+ platform: z19.string()
7455
7985
  })
7456
7986
  )
7457
7987
  }).passthrough().describe("Content plan with posts array"),
7458
- threshold: z18.number().min(0).max(35).default(26).describe(
7988
+ threshold: z19.number().min(0).max(35).default(26).describe(
7459
7989
  "Minimum total score to pass (max 35, scored across 7 categories at 0-5 each). Default 26 (~75%). Use 20 for rough drafts, 28+ for final posts going to large audiences."
7460
7990
  ),
7461
- response_format: z18.enum(["text", "json"]).default("text")
7991
+ response_format: z19.enum(["text", "json"]).default("text")
7462
7992
  },
7463
7993
  async ({ plan, threshold, response_format }) => {
7464
7994
  const startedAt = Date.now();
@@ -7500,7 +8030,7 @@ function registerQualityTools(server) {
7500
8030
  content: [
7501
8031
  {
7502
8032
  type: "text",
7503
- text: JSON.stringify(asEnvelope14({ posts: postsWithQuality, summary }), null, 2)
8033
+ text: JSON.stringify(asEnvelope15({ posts: postsWithQuality, summary }), null, 2)
7504
8034
  }
7505
8035
  ],
7506
8036
  isError: false
@@ -7524,7 +8054,7 @@ function registerQualityTools(server) {
7524
8054
  }
7525
8055
 
7526
8056
  // src/tools/planning.ts
7527
- import { z as z19 } from "zod";
8057
+ import { z as z20 } from "zod";
7528
8058
  import { randomUUID as randomUUID2 } from "node:crypto";
7529
8059
  init_supabase();
7530
8060
 
@@ -7552,7 +8082,7 @@ function extractJsonArray(text) {
7552
8082
  function toRecord(value) {
7553
8083
  return value && typeof value === "object" ? value : void 0;
7554
8084
  }
7555
- function asEnvelope15(data) {
8085
+ function asEnvelope16(data) {
7556
8086
  return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
7557
8087
  }
7558
8088
  function tomorrowIsoDate() {
@@ -7622,10 +8152,10 @@ function registerPlanningTools(server) {
7622
8152
  "plan_content_week",
7623
8153
  "Generate a full content plan with platform-specific drafts, hooks, angles, and optimal schedule times. Pass a topic or source_url \u2014 brand context and performance insights auto-load via project_id. Output feeds directly into quality_check_plan then schedule_content_plan. Costs ~5-15 credits depending on post count.",
7624
8154
  {
7625
- topic: z19.string().describe("Main topic or content theme"),
7626
- source_url: z19.string().optional().describe("URL to extract content from (YouTube, article)"),
7627
- platforms: z19.array(
7628
- z19.enum([
8155
+ topic: z20.string().describe("Main topic or content theme"),
8156
+ source_url: z20.string().optional().describe("URL to extract content from (YouTube, article)"),
8157
+ platforms: z20.array(
8158
+ z20.enum([
7629
8159
  "youtube",
7630
8160
  "tiktok",
7631
8161
  "instagram",
@@ -7636,12 +8166,12 @@ function registerPlanningTools(server) {
7636
8166
  "bluesky"
7637
8167
  ])
7638
8168
  ).min(1).describe("Target platforms"),
7639
- posts_per_day: z19.number().min(1).max(5).default(1).describe("Posts per platform per day"),
7640
- days: z19.number().min(1).max(7).default(5).describe("Number of days to plan"),
7641
- start_date: z19.string().optional().describe("ISO date, defaults to tomorrow"),
7642
- brand_voice: z19.string().optional().describe("Override brand voice description"),
7643
- project_id: z19.string().optional().describe("Project ID for brand/insights context"),
7644
- response_format: z19.enum(["text", "json"]).default("json")
8169
+ posts_per_day: z20.number().min(1).max(5).default(1).describe("Posts per platform per day"),
8170
+ days: z20.number().min(1).max(7).default(5).describe("Number of days to plan"),
8171
+ start_date: z20.string().optional().describe("ISO date, defaults to tomorrow"),
8172
+ brand_voice: z20.string().optional().describe("Override brand voice description"),
8173
+ project_id: z20.string().optional().describe("Project ID for brand/insights context"),
8174
+ response_format: z20.enum(["text", "json"]).default("json")
7645
8175
  },
7646
8176
  async ({
7647
8177
  topic,
@@ -7893,7 +8423,7 @@ ${rawText.slice(0, 1e3)}`
7893
8423
  }
7894
8424
  } catch (persistErr) {
7895
8425
  const durationMs2 = Date.now() - startedAt;
7896
- const message = sanitizeError2(persistErr);
8426
+ const message = sanitizeError(persistErr);
7897
8427
  logMcpToolInvocation({
7898
8428
  toolName: "plan_content_week",
7899
8429
  status: "error",
@@ -7915,7 +8445,7 @@ ${rawText.slice(0, 1e3)}`
7915
8445
  });
7916
8446
  if (response_format === "json") {
7917
8447
  return {
7918
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(plan), null, 2) }],
8448
+ content: [{ type: "text", text: JSON.stringify(asEnvelope16(plan), null, 2) }],
7919
8449
  isError: false
7920
8450
  };
7921
8451
  }
@@ -7925,7 +8455,7 @@ ${rawText.slice(0, 1e3)}`
7925
8455
  };
7926
8456
  } catch (err) {
7927
8457
  const durationMs = Date.now() - startedAt;
7928
- const message = sanitizeError2(err);
8458
+ const message = sanitizeError(err);
7929
8459
  logMcpToolInvocation({
7930
8460
  toolName: "plan_content_week",
7931
8461
  status: "error",
@@ -7943,13 +8473,13 @@ ${rawText.slice(0, 1e3)}`
7943
8473
  "save_content_plan",
7944
8474
  "Save a content plan to the database for team review, approval workflows, and scheduled publishing. Creates a plan_id you can reference in get_content_plan, update_content_plan, and schedule_content_plan.",
7945
8475
  {
7946
- plan: z19.object({
7947
- topic: z19.string(),
7948
- posts: z19.array(z19.record(z19.string(), z19.unknown()))
8476
+ plan: z20.object({
8477
+ topic: z20.string(),
8478
+ posts: z20.array(z20.record(z20.string(), z20.unknown()))
7949
8479
  }).passthrough(),
7950
- project_id: z19.string().uuid().optional(),
7951
- status: z19.enum(["draft", "in_review", "approved", "scheduled", "completed"]).default("draft"),
7952
- response_format: z19.enum(["text", "json"]).default("json")
8480
+ project_id: z20.string().uuid().optional(),
8481
+ status: z20.enum(["draft", "in_review", "approved", "scheduled", "completed"]).default("draft"),
8482
+ response_format: z20.enum(["text", "json"]).default("json")
7953
8483
  },
7954
8484
  async ({ plan, project_id, status, response_format }) => {
7955
8485
  const startedAt = Date.now();
@@ -8005,7 +8535,7 @@ ${rawText.slice(0, 1e3)}`
8005
8535
  };
8006
8536
  if (response_format === "json") {
8007
8537
  return {
8008
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(result), null, 2) }],
8538
+ content: [{ type: "text", text: JSON.stringify(asEnvelope16(result), null, 2) }],
8009
8539
  isError: false
8010
8540
  };
8011
8541
  }
@@ -8017,7 +8547,7 @@ ${rawText.slice(0, 1e3)}`
8017
8547
  };
8018
8548
  } catch (err) {
8019
8549
  const durationMs = Date.now() - startedAt;
8020
- const message = sanitizeError2(err);
8550
+ const message = sanitizeError(err);
8021
8551
  logMcpToolInvocation({
8022
8552
  toolName: "save_content_plan",
8023
8553
  status: "error",
@@ -8035,8 +8565,8 @@ ${rawText.slice(0, 1e3)}`
8035
8565
  "get_content_plan",
8036
8566
  "Retrieve a persisted content plan by ID.",
8037
8567
  {
8038
- plan_id: z19.string().uuid().describe("Persisted content plan ID"),
8039
- response_format: z19.enum(["text", "json"]).default("json")
8568
+ plan_id: z20.string().uuid().describe("Persisted content plan ID"),
8569
+ response_format: z20.enum(["text", "json"]).default("json")
8040
8570
  },
8041
8571
  async ({ plan_id, response_format }) => {
8042
8572
  const { data: result, error } = await callEdgeFunction("mcp-data", { action: "get-content-plan", plan_id }, { timeoutMs: 1e4 });
@@ -8071,7 +8601,7 @@ ${rawText.slice(0, 1e3)}`
8071
8601
  };
8072
8602
  if (response_format === "json") {
8073
8603
  return {
8074
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(payload), null, 2) }],
8604
+ content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
8075
8605
  isError: false
8076
8606
  };
8077
8607
  }
@@ -8089,23 +8619,23 @@ ${rawText.slice(0, 1e3)}`
8089
8619
  "update_content_plan",
8090
8620
  "Update individual posts in a persisted content plan.",
8091
8621
  {
8092
- plan_id: z19.string().uuid(),
8093
- post_updates: z19.array(
8094
- z19.object({
8095
- post_id: z19.string(),
8096
- caption: z19.string().optional(),
8097
- title: z19.string().optional(),
8098
- hashtags: z19.array(z19.string()).optional(),
8099
- hook: z19.string().optional(),
8100
- angle: z19.string().optional(),
8101
- visual_direction: z19.string().optional(),
8102
- media_url: z19.string().optional(),
8103
- schedule_at: z19.string().optional(),
8104
- platform: z19.string().optional(),
8105
- status: z19.enum(["approved", "rejected", "needs_edit"]).optional()
8622
+ plan_id: z20.string().uuid(),
8623
+ post_updates: z20.array(
8624
+ z20.object({
8625
+ post_id: z20.string(),
8626
+ caption: z20.string().optional(),
8627
+ title: z20.string().optional(),
8628
+ hashtags: z20.array(z20.string()).optional(),
8629
+ hook: z20.string().optional(),
8630
+ angle: z20.string().optional(),
8631
+ visual_direction: z20.string().optional(),
8632
+ media_url: z20.string().optional(),
8633
+ schedule_at: z20.string().optional(),
8634
+ platform: z20.string().optional(),
8635
+ status: z20.enum(["approved", "rejected", "needs_edit"]).optional()
8106
8636
  })
8107
8637
  ).min(1),
8108
- response_format: z19.enum(["text", "json"]).default("json")
8638
+ response_format: z20.enum(["text", "json"]).default("json")
8109
8639
  },
8110
8640
  async ({ plan_id, post_updates, response_format }) => {
8111
8641
  const { data: result, error } = await callEdgeFunction(
@@ -8143,7 +8673,7 @@ ${rawText.slice(0, 1e3)}`
8143
8673
  };
8144
8674
  if (response_format === "json") {
8145
8675
  return {
8146
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(payload), null, 2) }],
8676
+ content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
8147
8677
  isError: false
8148
8678
  };
8149
8679
  }
@@ -8162,8 +8692,8 @@ ${rawText.slice(0, 1e3)}`
8162
8692
  "submit_content_plan_for_approval",
8163
8693
  "Create pending approval items for each post in a plan and mark plan status as in_review.",
8164
8694
  {
8165
- plan_id: z19.string().uuid(),
8166
- response_format: z19.enum(["text", "json"]).default("json")
8695
+ plan_id: z20.string().uuid(),
8696
+ response_format: z20.enum(["text", "json"]).default("json")
8167
8697
  },
8168
8698
  async ({ plan_id, response_format }) => {
8169
8699
  const { data: result, error } = await callEdgeFunction("mcp-data", { action: "submit-plan-approval", plan_id }, { timeoutMs: 15e3 });
@@ -8196,7 +8726,7 @@ ${rawText.slice(0, 1e3)}`
8196
8726
  };
8197
8727
  if (response_format === "json") {
8198
8728
  return {
8199
- content: [{ type: "text", text: JSON.stringify(asEnvelope15(payload), null, 2) }],
8729
+ content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
8200
8730
  isError: false
8201
8731
  };
8202
8732
  }
@@ -8214,9 +8744,9 @@ ${rawText.slice(0, 1e3)}`
8214
8744
  }
8215
8745
 
8216
8746
  // src/tools/plan-approvals.ts
8217
- import { z as z20 } from "zod";
8747
+ import { z as z21 } from "zod";
8218
8748
  init_supabase();
8219
- function asEnvelope16(data) {
8749
+ function asEnvelope17(data) {
8220
8750
  return {
8221
8751
  _meta: {
8222
8752
  version: MCP_VERSION,
@@ -8230,19 +8760,19 @@ function registerPlanApprovalTools(server) {
8230
8760
  "create_plan_approvals",
8231
8761
  "Create pending approval rows for each post in a content plan.",
8232
8762
  {
8233
- plan_id: z20.string().uuid().describe("Content plan ID"),
8234
- posts: z20.array(
8235
- z20.object({
8236
- id: z20.string(),
8237
- platform: z20.string().optional(),
8238
- caption: z20.string().optional(),
8239
- title: z20.string().optional(),
8240
- media_url: z20.string().optional(),
8241
- schedule_at: z20.string().optional()
8763
+ plan_id: z21.string().uuid().describe("Content plan ID"),
8764
+ posts: z21.array(
8765
+ z21.object({
8766
+ id: z21.string(),
8767
+ platform: z21.string().optional(),
8768
+ caption: z21.string().optional(),
8769
+ title: z21.string().optional(),
8770
+ media_url: z21.string().optional(),
8771
+ schedule_at: z21.string().optional()
8242
8772
  }).passthrough()
8243
8773
  ).min(1).describe("Posts to create approval entries for."),
8244
- project_id: z20.string().uuid().optional().describe("Project ID. Defaults to active project context."),
8245
- response_format: z20.enum(["text", "json"]).optional()
8774
+ project_id: z21.string().uuid().optional().describe("Project ID. Defaults to active project context."),
8775
+ response_format: z21.enum(["text", "json"]).optional()
8246
8776
  },
8247
8777
  async ({ plan_id, posts, project_id, response_format }) => {
8248
8778
  const projectId = project_id || await getDefaultProjectId();
@@ -8291,7 +8821,7 @@ function registerPlanApprovalTools(server) {
8291
8821
  };
8292
8822
  if ((response_format || "text") === "json") {
8293
8823
  return {
8294
- content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
8824
+ content: [{ type: "text", text: JSON.stringify(asEnvelope17(payload), null, 2) }],
8295
8825
  isError: false
8296
8826
  };
8297
8827
  }
@@ -8310,9 +8840,9 @@ function registerPlanApprovalTools(server) {
8310
8840
  "list_plan_approvals",
8311
8841
  "List MCP-native approval items for a specific content plan.",
8312
8842
  {
8313
- plan_id: z20.string().uuid().describe("Content plan ID"),
8314
- status: z20.enum(["pending", "approved", "rejected", "edited"]).optional(),
8315
- response_format: z20.enum(["text", "json"]).optional()
8843
+ plan_id: z21.string().uuid().describe("Content plan ID"),
8844
+ status: z21.enum(["pending", "approved", "rejected", "edited"]).optional(),
8845
+ response_format: z21.enum(["text", "json"]).optional()
8316
8846
  },
8317
8847
  async ({ plan_id, status, response_format }) => {
8318
8848
  const { data: result, error } = await callEdgeFunction(
@@ -8343,7 +8873,7 @@ function registerPlanApprovalTools(server) {
8343
8873
  };
8344
8874
  if ((response_format || "text") === "json") {
8345
8875
  return {
8346
- content: [{ type: "text", text: JSON.stringify(asEnvelope16(payload), null, 2) }],
8876
+ content: [{ type: "text", text: JSON.stringify(asEnvelope17(payload), null, 2) }],
8347
8877
  isError: false
8348
8878
  };
8349
8879
  }
@@ -8370,11 +8900,11 @@ function registerPlanApprovalTools(server) {
8370
8900
  "respond_plan_approval",
8371
8901
  "Approve, reject, or edit a pending plan approval item.",
8372
8902
  {
8373
- approval_id: z20.string().uuid().describe("Approval item ID"),
8374
- decision: z20.enum(["approved", "rejected", "edited"]),
8375
- edited_post: z20.record(z20.string(), z20.unknown()).optional(),
8376
- reason: z20.string().max(1e3).optional(),
8377
- response_format: z20.enum(["text", "json"]).optional()
8903
+ approval_id: z21.string().uuid().describe("Approval item ID"),
8904
+ decision: z21.enum(["approved", "rejected", "edited"]),
8905
+ edited_post: z21.record(z21.string(), z21.unknown()).optional(),
8906
+ reason: z21.string().max(1e3).optional(),
8907
+ response_format: z21.enum(["text", "json"]).optional()
8378
8908
  },
8379
8909
  async ({ approval_id, decision, edited_post, reason, response_format }) => {
8380
8910
  if (decision === "edited" && !edited_post) {
@@ -8421,7 +8951,7 @@ function registerPlanApprovalTools(server) {
8421
8951
  }
8422
8952
  if ((response_format || "text") === "json") {
8423
8953
  return {
8424
- content: [{ type: "text", text: JSON.stringify(asEnvelope16(data), null, 2) }],
8954
+ content: [{ type: "text", text: JSON.stringify(asEnvelope17(data), null, 2) }],
8425
8955
  isError: false
8426
8956
  };
8427
8957
  }
@@ -8439,7 +8969,7 @@ function registerPlanApprovalTools(server) {
8439
8969
  }
8440
8970
 
8441
8971
  // src/tools/discovery.ts
8442
- import { z as z21 } from "zod";
8972
+ import { z as z22 } from "zod";
8443
8973
 
8444
8974
  // src/lib/tool-catalog.ts
8445
8975
  var TOOL_CATALOG = [
@@ -8506,6 +9036,12 @@ var TOOL_CATALOG = [
8506
9036
  module: "content",
8507
9037
  scope: "mcp:write"
8508
9038
  },
9039
+ {
9040
+ name: "create_carousel",
9041
+ description: "End-to-end carousel: generate text + kick off image jobs for each slide",
9042
+ module: "carousel",
9043
+ scope: "mcp:write"
9044
+ },
8509
9045
  // media
8510
9046
  {
8511
9047
  name: "upload_media",
@@ -8863,20 +9399,59 @@ var TOOL_CATALOG = [
8863
9399
  description: "Create a new autopilot configuration",
8864
9400
  module: "autopilot",
8865
9401
  scope: "mcp:autopilot"
8866
- }
8867
- ];
8868
- function getToolsByModule(module) {
8869
- return TOOL_CATALOG.filter((t) => t.module === module);
8870
- }
8871
- function getToolsByScope(scope) {
8872
- return TOOL_CATALOG.filter((t) => t.scope === scope);
8873
- }
8874
- function searchTools(query) {
8875
- const q = query.toLowerCase();
8876
- return TOOL_CATALOG.filter(
8877
- (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)
8878
- );
8879
- }
9402
+ },
9403
+ // brand runtime (additions)
9404
+ {
9405
+ name: "audit_brand_colors",
9406
+ description: "Audit brand color palette for accessibility, contrast, and harmony",
9407
+ module: "brandRuntime",
9408
+ scope: "mcp:read"
9409
+ },
9410
+ {
9411
+ name: "export_design_tokens",
9412
+ description: "Export brand design tokens in CSS/Tailwind/JSON formats",
9413
+ module: "brandRuntime",
9414
+ scope: "mcp:read"
9415
+ },
9416
+ // carousel (already listed in content section above)
9417
+ // recipes
9418
+ {
9419
+ name: "list_recipes",
9420
+ description: "List available recipe templates for automated content workflows",
9421
+ module: "recipes",
9422
+ scope: "mcp:read"
9423
+ },
9424
+ {
9425
+ name: "get_recipe_details",
9426
+ description: "Get full details of a recipe template including steps and required inputs",
9427
+ module: "recipes",
9428
+ scope: "mcp:read"
9429
+ },
9430
+ {
9431
+ name: "execute_recipe",
9432
+ description: "Execute a recipe template with provided inputs to run a multi-step workflow",
9433
+ module: "recipes",
9434
+ scope: "mcp:write"
9435
+ },
9436
+ {
9437
+ name: "get_recipe_run_status",
9438
+ description: "Check the status and progress of a running recipe execution",
9439
+ module: "recipes",
9440
+ scope: "mcp:read"
9441
+ }
9442
+ ];
9443
+ function getToolsByModule(module) {
9444
+ return TOOL_CATALOG.filter((t) => t.module === module);
9445
+ }
9446
+ function getToolsByScope(scope) {
9447
+ return TOOL_CATALOG.filter((t) => t.scope === scope);
9448
+ }
9449
+ function searchTools(query) {
9450
+ const q = query.toLowerCase();
9451
+ return TOOL_CATALOG.filter(
9452
+ (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)
9453
+ );
9454
+ }
8880
9455
 
8881
9456
  // src/tools/discovery.ts
8882
9457
  function registerDiscoveryTools(server) {
@@ -8884,10 +9459,10 @@ function registerDiscoveryTools(server) {
8884
9459
  "search_tools",
8885
9460
  'Search available tools by name, description, module, or scope. Use "name" detail (~50 tokens) for quick lookup, "summary" (~500 tokens) for descriptions, "full" for complete input schemas. Start here if unsure which tool to call \u2014 filter by module (e.g. "planning", "content", "analytics") to narrow results.',
8886
9461
  {
8887
- query: z21.string().optional().describe("Search query to filter tools by name or description"),
8888
- module: z21.string().optional().describe('Filter by module name (e.g. "planning", "content", "analytics")'),
8889
- scope: z21.string().optional().describe('Filter by required scope (e.g. "mcp:read", "mcp:write")'),
8890
- detail: z21.enum(["name", "summary", "full"]).default("summary").describe(
9462
+ query: z22.string().optional().describe("Search query to filter tools by name or description"),
9463
+ module: z22.string().optional().describe('Filter by module name (e.g. "planning", "content", "analytics")'),
9464
+ scope: z22.string().optional().describe('Filter by required scope (e.g. "mcp:read", "mcp:write")'),
9465
+ detail: z22.enum(["name", "summary", "full"]).default("summary").describe(
8891
9466
  'Detail level: "name" for just tool names, "summary" for names + descriptions, "full" for complete info including scope and module'
8892
9467
  )
8893
9468
  },
@@ -8930,13 +9505,13 @@ function registerDiscoveryTools(server) {
8930
9505
  }
8931
9506
 
8932
9507
  // src/tools/pipeline.ts
8933
- import { z as z22 } from "zod";
9508
+ import { z as z23 } from "zod";
8934
9509
  import { randomUUID as randomUUID3 } from "node:crypto";
8935
9510
  init_supabase();
8936
- function asEnvelope17(data) {
9511
+ function asEnvelope18(data) {
8937
9512
  return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
8938
9513
  }
8939
- var PLATFORM_ENUM2 = z22.enum([
9514
+ var PLATFORM_ENUM2 = z23.enum([
8940
9515
  "youtube",
8941
9516
  "tiktok",
8942
9517
  "instagram",
@@ -8953,10 +9528,10 @@ function registerPipelineTools(server) {
8953
9528
  "check_pipeline_readiness",
8954
9529
  "Pre-flight check before run_content_pipeline. Verifies: sufficient credits for estimated_posts, active OAuth on target platforms, brand profile exists, no stale insights. Returns pass/fail with specific issues to fix before running the pipeline.",
8955
9530
  {
8956
- project_id: z22.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
8957
- platforms: z22.array(PLATFORM_ENUM2).min(1).describe("Target platforms to check"),
8958
- estimated_posts: z22.number().min(1).max(50).default(5).describe("Estimated posts to generate"),
8959
- response_format: z22.enum(["text", "json"]).optional().describe("Response format")
9531
+ project_id: z23.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
9532
+ platforms: z23.array(PLATFORM_ENUM2).min(1).describe("Target platforms to check"),
9533
+ estimated_posts: z23.number().min(1).max(50).default(5).describe("Estimated posts to generate"),
9534
+ response_format: z23.enum(["text", "json"]).optional().describe("Response format")
8960
9535
  },
8961
9536
  async ({ project_id, platforms, estimated_posts, response_format }) => {
8962
9537
  const format = response_format ?? "text";
@@ -9032,7 +9607,7 @@ function registerPipelineTools(server) {
9032
9607
  });
9033
9608
  if (format === "json") {
9034
9609
  return {
9035
- content: [{ type: "text", text: JSON.stringify(asEnvelope17(result), null, 2) }]
9610
+ content: [{ type: "text", text: JSON.stringify(asEnvelope18(result), null, 2) }]
9036
9611
  };
9037
9612
  }
9038
9613
  const lines = [];
@@ -9062,7 +9637,7 @@ function registerPipelineTools(server) {
9062
9637
  return { content: [{ type: "text", text: lines.join("\n") }] };
9063
9638
  } catch (err) {
9064
9639
  const durationMs = Date.now() - startedAt;
9065
- const message = sanitizeError2(err);
9640
+ const message = sanitizeError(err);
9066
9641
  logMcpToolInvocation({
9067
9642
  toolName: "check_pipeline_readiness",
9068
9643
  status: "error",
@@ -9080,22 +9655,22 @@ function registerPipelineTools(server) {
9080
9655
  "run_content_pipeline",
9081
9656
  "Run the full content pipeline: research trends \u2192 generate plan \u2192 quality check \u2192 auto-approve \u2192 schedule posts. Chains all stages in one call for maximum efficiency. Set dry_run=true to preview the plan without publishing. Check check_pipeline_readiness first to verify credits, OAuth, and brand profile are ready.",
9082
9657
  {
9083
- project_id: z22.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
9084
- topic: z22.string().optional().describe("Content topic (required if no source_url)"),
9085
- source_url: z22.string().optional().describe("URL to extract content from"),
9086
- platforms: z22.array(PLATFORM_ENUM2).min(1).describe("Target platforms"),
9087
- days: z22.number().min(1).max(7).default(5).describe("Days to plan"),
9088
- posts_per_day: z22.number().min(1).max(3).default(1).describe("Posts per platform per day"),
9089
- approval_mode: z22.enum(["auto", "review_all", "review_low_confidence"]).default("review_low_confidence").describe(
9658
+ project_id: z23.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
9659
+ topic: z23.string().optional().describe("Content topic (required if no source_url)"),
9660
+ source_url: z23.string().optional().describe("URL to extract content from"),
9661
+ platforms: z23.array(PLATFORM_ENUM2).min(1).describe("Target platforms"),
9662
+ days: z23.number().min(1).max(7).default(5).describe("Days to plan"),
9663
+ posts_per_day: z23.number().min(1).max(3).default(1).describe("Posts per platform per day"),
9664
+ approval_mode: z23.enum(["auto", "review_all", "review_low_confidence"]).default("review_low_confidence").describe(
9090
9665
  "auto: approve all passing quality. review_all: flag everything. review_low_confidence: auto-approve high scorers."
9091
9666
  ),
9092
- auto_approve_threshold: z22.number().min(0).max(35).default(28).describe(
9667
+ auto_approve_threshold: z23.number().min(0).max(35).default(28).describe(
9093
9668
  "Quality score threshold for auto-approval (used in auto/review_low_confidence modes)"
9094
9669
  ),
9095
- max_credits: z22.number().optional().describe("Credit budget cap"),
9096
- dry_run: z22.boolean().default(false).describe("If true, skip scheduling and return plan only"),
9097
- skip_stages: z22.array(z22.enum(["research", "quality", "schedule"])).optional().describe("Stages to skip"),
9098
- response_format: z22.enum(["text", "json"]).default("json")
9670
+ max_credits: z23.number().optional().describe("Credit budget cap"),
9671
+ dry_run: z23.boolean().default(false).describe("If true, skip scheduling and return plan only"),
9672
+ skip_stages: z23.array(z23.enum(["research", "quality", "schedule"])).optional().describe("Stages to skip"),
9673
+ response_format: z23.enum(["text", "json"]).default("json")
9099
9674
  },
9100
9675
  async ({
9101
9676
  project_id,
@@ -9223,7 +9798,7 @@ function registerPipelineTools(server) {
9223
9798
  } catch (deductErr) {
9224
9799
  errors.push({
9225
9800
  stage: "planning",
9226
- message: `Credit deduction failed: ${sanitizeError2(deductErr)}`
9801
+ message: `Credit deduction failed: ${sanitizeError(deductErr)}`
9227
9802
  });
9228
9803
  }
9229
9804
  }
@@ -9394,7 +9969,7 @@ function registerPipelineTools(server) {
9394
9969
  } catch (schedErr) {
9395
9970
  errors.push({
9396
9971
  stage: "schedule",
9397
- message: `Failed to schedule ${post.id}: ${sanitizeError2(schedErr)}`
9972
+ message: `Failed to schedule ${post.id}: ${sanitizeError(schedErr)}`
9398
9973
  });
9399
9974
  }
9400
9975
  }
@@ -9457,7 +10032,7 @@ function registerPipelineTools(server) {
9457
10032
  if (response_format === "json") {
9458
10033
  return {
9459
10034
  content: [
9460
- { type: "text", text: JSON.stringify(asEnvelope17(resultPayload), null, 2) }
10035
+ { type: "text", text: JSON.stringify(asEnvelope18(resultPayload), null, 2) }
9461
10036
  ]
9462
10037
  };
9463
10038
  }
@@ -9483,7 +10058,7 @@ function registerPipelineTools(server) {
9483
10058
  return { content: [{ type: "text", text: lines.join("\n") }] };
9484
10059
  } catch (err) {
9485
10060
  const durationMs = Date.now() - startedAt;
9486
- const message = sanitizeError2(err);
10061
+ const message = sanitizeError(err);
9487
10062
  logMcpToolInvocation({
9488
10063
  toolName: "run_content_pipeline",
9489
10064
  status: "error",
@@ -9518,8 +10093,8 @@ function registerPipelineTools(server) {
9518
10093
  "get_pipeline_status",
9519
10094
  "Check status of a pipeline run, including stages completed, pending approvals, and scheduled posts.",
9520
10095
  {
9521
- pipeline_id: z22.string().uuid().optional().describe("Pipeline run ID (omit for latest)"),
9522
- response_format: z22.enum(["text", "json"]).optional()
10096
+ pipeline_id: z23.string().uuid().optional().describe("Pipeline run ID (omit for latest)"),
10097
+ response_format: z23.enum(["text", "json"]).optional()
9523
10098
  },
9524
10099
  async ({ pipeline_id, response_format }) => {
9525
10100
  const format = response_format ?? "text";
@@ -9545,7 +10120,7 @@ function registerPipelineTools(server) {
9545
10120
  }
9546
10121
  if (format === "json") {
9547
10122
  return {
9548
- content: [{ type: "text", text: JSON.stringify(asEnvelope17(data), null, 2) }]
10123
+ content: [{ type: "text", text: JSON.stringify(asEnvelope18(data), null, 2) }]
9549
10124
  };
9550
10125
  }
9551
10126
  const lines = [];
@@ -9579,9 +10154,9 @@ function registerPipelineTools(server) {
9579
10154
  "auto_approve_plan",
9580
10155
  "Batch auto-approve posts in a content plan that meet quality thresholds. Posts below the threshold are flagged for manual review.",
9581
10156
  {
9582
- plan_id: z22.string().uuid().describe("Content plan ID"),
9583
- quality_threshold: z22.number().min(0).max(35).default(26).describe("Minimum quality score to auto-approve"),
9584
- response_format: z22.enum(["text", "json"]).default("json")
10157
+ plan_id: z23.string().uuid().describe("Content plan ID"),
10158
+ quality_threshold: z23.number().min(0).max(35).default(26).describe("Minimum quality score to auto-approve"),
10159
+ response_format: z23.enum(["text", "json"]).default("json")
9585
10160
  },
9586
10161
  async ({ plan_id, quality_threshold, response_format }) => {
9587
10162
  const startedAt = Date.now();
@@ -9687,7 +10262,7 @@ function registerPipelineTools(server) {
9687
10262
  if (response_format === "json") {
9688
10263
  return {
9689
10264
  content: [
9690
- { type: "text", text: JSON.stringify(asEnvelope17(resultPayload), null, 2) }
10265
+ { type: "text", text: JSON.stringify(asEnvelope18(resultPayload), null, 2) }
9691
10266
  ]
9692
10267
  };
9693
10268
  }
@@ -9707,7 +10282,7 @@ function registerPipelineTools(server) {
9707
10282
  return { content: [{ type: "text", text: lines.join("\n") }] };
9708
10283
  } catch (err) {
9709
10284
  const durationMs = Date.now() - startedAt;
9710
- const message = sanitizeError2(err);
10285
+ const message = sanitizeError(err);
9711
10286
  logMcpToolInvocation({
9712
10287
  toolName: "auto_approve_plan",
9713
10288
  status: "error",
@@ -9741,9 +10316,9 @@ function buildPlanPrompt(topic, platforms, days, postsPerDay, sourceUrl) {
9741
10316
  }
9742
10317
 
9743
10318
  // src/tools/suggest.ts
9744
- import { z as z23 } from "zod";
10319
+ import { z as z24 } from "zod";
9745
10320
  init_supabase();
9746
- function asEnvelope18(data) {
10321
+ function asEnvelope19(data) {
9747
10322
  return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
9748
10323
  }
9749
10324
  function registerSuggestTools(server) {
@@ -9751,9 +10326,9 @@ function registerSuggestTools(server) {
9751
10326
  "suggest_next_content",
9752
10327
  "Suggest next content topics based on performance insights, past content, and competitor patterns. No AI call, no credit cost \u2014 purely data-driven recommendations.",
9753
10328
  {
9754
- project_id: z23.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
9755
- count: z23.number().min(1).max(10).default(3).describe("Number of suggestions to return"),
9756
- response_format: z23.enum(["text", "json"]).optional()
10329
+ project_id: z24.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
10330
+ count: z24.number().min(1).max(10).default(3).describe("Number of suggestions to return"),
10331
+ response_format: z24.enum(["text", "json"]).optional()
9757
10332
  },
9758
10333
  async ({ project_id, count, response_format }) => {
9759
10334
  const format = response_format ?? "text";
@@ -9863,7 +10438,7 @@ function registerSuggestTools(server) {
9863
10438
  if (format === "json") {
9864
10439
  return {
9865
10440
  content: [
9866
- { type: "text", text: JSON.stringify(asEnvelope18(resultPayload), null, 2) }
10441
+ { type: "text", text: JSON.stringify(asEnvelope19(resultPayload), null, 2) }
9867
10442
  ]
9868
10443
  };
9869
10444
  }
@@ -9885,7 +10460,7 @@ ${i + 1}. ${s.topic}`);
9885
10460
  return { content: [{ type: "text", text: lines.join("\n") }] };
9886
10461
  } catch (err) {
9887
10462
  const durationMs = Date.now() - startedAt;
9888
- const message = sanitizeError2(err);
10463
+ const message = sanitizeError(err);
9889
10464
  logMcpToolInvocation({
9890
10465
  toolName: "suggest_next_content",
9891
10466
  status: "error",
@@ -9902,7 +10477,7 @@ ${i + 1}. ${s.topic}`);
9902
10477
  }
9903
10478
 
9904
10479
  // src/tools/digest.ts
9905
- import { z as z24 } from "zod";
10480
+ import { z as z25 } from "zod";
9906
10481
  init_supabase();
9907
10482
 
9908
10483
  // src/lib/anomaly-detector.ts
@@ -10006,10 +10581,10 @@ function detectAnomalies(currentData, previousData, sensitivity = "medium", aver
10006
10581
  }
10007
10582
 
10008
10583
  // src/tools/digest.ts
10009
- function asEnvelope19(data) {
10584
+ function asEnvelope20(data) {
10010
10585
  return { _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, data };
10011
10586
  }
10012
- var PLATFORM_ENUM3 = z24.enum([
10587
+ var PLATFORM_ENUM3 = z25.enum([
10013
10588
  "youtube",
10014
10589
  "tiktok",
10015
10590
  "instagram",
@@ -10024,10 +10599,10 @@ function registerDigestTools(server) {
10024
10599
  "generate_performance_digest",
10025
10600
  "Generate a performance summary for a time period. Includes metrics, trends vs previous period, top/bottom performers, platform breakdown, and actionable recommendations. No AI call, no credit cost.",
10026
10601
  {
10027
- project_id: z24.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
10028
- period: z24.enum(["7d", "14d", "30d"]).default("7d").describe("Time period to analyze"),
10029
- include_recommendations: z24.boolean().default(true),
10030
- response_format: z24.enum(["text", "json"]).optional()
10602
+ project_id: z25.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
10603
+ period: z25.enum(["7d", "14d", "30d"]).default("7d").describe("Time period to analyze"),
10604
+ include_recommendations: z25.boolean().default(true),
10605
+ response_format: z25.enum(["text", "json"]).optional()
10031
10606
  },
10032
10607
  async ({ project_id, period, include_recommendations, response_format }) => {
10033
10608
  const format = response_format ?? "text";
@@ -10183,7 +10758,7 @@ function registerDigestTools(server) {
10183
10758
  });
10184
10759
  if (format === "json") {
10185
10760
  return {
10186
- content: [{ type: "text", text: JSON.stringify(asEnvelope19(digest), null, 2) }]
10761
+ content: [{ type: "text", text: JSON.stringify(asEnvelope20(digest), null, 2) }]
10187
10762
  };
10188
10763
  }
10189
10764
  const lines = [];
@@ -10224,7 +10799,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10224
10799
  return { content: [{ type: "text", text: lines.join("\n") }] };
10225
10800
  } catch (err) {
10226
10801
  const durationMs = Date.now() - startedAt;
10227
- const message = sanitizeError2(err);
10802
+ const message = sanitizeError(err);
10228
10803
  logMcpToolInvocation({
10229
10804
  toolName: "generate_performance_digest",
10230
10805
  status: "error",
@@ -10242,11 +10817,11 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10242
10817
  "detect_anomalies",
10243
10818
  "Detect significant performance changes: spikes, drops, viral content, trend shifts. Compares current period against previous equal-length period. No AI call, no credit cost.",
10244
10819
  {
10245
- project_id: z24.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
10246
- days: z24.number().min(7).max(90).default(14).describe("Days to analyze"),
10247
- sensitivity: z24.enum(["low", "medium", "high"]).default("medium").describe("Detection sensitivity: low=50%+, medium=30%+, high=15%+ changes"),
10248
- platforms: z24.array(PLATFORM_ENUM3).optional().describe("Filter to specific platforms"),
10249
- response_format: z24.enum(["text", "json"]).optional()
10820
+ project_id: z25.string().uuid().optional().describe("Project ID (auto-detected if omitted)"),
10821
+ days: z25.number().min(7).max(90).default(14).describe("Days to analyze"),
10822
+ sensitivity: z25.enum(["low", "medium", "high"]).default("medium").describe("Detection sensitivity: low=50%+, medium=30%+, high=15%+ changes"),
10823
+ platforms: z25.array(PLATFORM_ENUM3).optional().describe("Filter to specific platforms"),
10824
+ response_format: z25.enum(["text", "json"]).optional()
10250
10825
  },
10251
10826
  async ({ project_id, days, sensitivity, platforms, response_format }) => {
10252
10827
  const format = response_format ?? "text";
@@ -10297,7 +10872,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10297
10872
  if (format === "json") {
10298
10873
  return {
10299
10874
  content: [
10300
- { type: "text", text: JSON.stringify(asEnvelope19(resultPayload), null, 2) }
10875
+ { type: "text", text: JSON.stringify(asEnvelope20(resultPayload), null, 2) }
10301
10876
  ]
10302
10877
  };
10303
10878
  }
@@ -10321,7 +10896,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10321
10896
  return { content: [{ type: "text", text: lines.join("\n") }] };
10322
10897
  } catch (err) {
10323
10898
  const durationMs = Date.now() - startedAt;
10324
- const message = sanitizeError2(err);
10899
+ const message = sanitizeError(err);
10325
10900
  logMcpToolInvocation({
10326
10901
  toolName: "detect_anomalies",
10327
10902
  status: "error",
@@ -10338,7 +10913,7 @@ Best: ${best.id.slice(0, 8)}... (${best.platform}) \u2014 ${best.views.toLocaleS
10338
10913
  }
10339
10914
 
10340
10915
  // src/tools/brandRuntime.ts
10341
- import { z as z25 } from "zod";
10916
+ import { z as z26 } from "zod";
10342
10917
  init_supabase();
10343
10918
 
10344
10919
  // src/lib/brandScoring.ts
@@ -10608,8 +11183,181 @@ function computeBrandConsistency(content, profile, threshold = 60) {
10608
11183
  };
10609
11184
  }
10610
11185
 
11186
+ // src/lib/colorAudit.ts
11187
+ function hexToRgb(hex) {
11188
+ const h = hex.replace("#", "");
11189
+ const full = h.length === 3 ? h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : h;
11190
+ const n = parseInt(full, 16);
11191
+ return [n >> 16 & 255, n >> 8 & 255, n & 255];
11192
+ }
11193
+ function srgbToLinear(c) {
11194
+ const s = c / 255;
11195
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
11196
+ }
11197
+ function hexToLab(hex) {
11198
+ const [r, g, b] = hexToRgb(hex);
11199
+ const lr = srgbToLinear(r);
11200
+ const lg = srgbToLinear(g);
11201
+ const lb = srgbToLinear(b);
11202
+ const x = lr * 0.4124564 + lg * 0.3575761 + lb * 0.1804375;
11203
+ const y = lr * 0.2126729 + lg * 0.7151522 + lb * 0.072175;
11204
+ const z29 = lr * 0.0193339 + lg * 0.119192 + lb * 0.9503041;
11205
+ const f = (t) => t > 8856e-6 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
11206
+ const fx = f(x / 0.95047);
11207
+ const fy = f(y / 1);
11208
+ const fz = f(z29 / 1.08883);
11209
+ return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
11210
+ }
11211
+ function deltaE2000(lab1, lab2) {
11212
+ const { L: L1, a: a1, b: b1 } = lab1;
11213
+ const { L: L2, a: a2, b: b2 } = lab2;
11214
+ const C1 = Math.sqrt(a1 * a1 + b1 * b1);
11215
+ const C2 = Math.sqrt(a2 * a2 + b2 * b2);
11216
+ const Cab = (C1 + C2) / 2;
11217
+ const Cab7 = Math.pow(Cab, 7);
11218
+ const G = 0.5 * (1 - Math.sqrt(Cab7 / (Cab7 + Math.pow(25, 7))));
11219
+ const a1p = a1 * (1 + G);
11220
+ const a2p = a2 * (1 + G);
11221
+ const C1p = Math.sqrt(a1p * a1p + b1 * b1);
11222
+ const C2p = Math.sqrt(a2p * a2p + b2 * b2);
11223
+ let h1p = Math.atan2(b1, a1p) * (180 / Math.PI);
11224
+ if (h1p < 0) h1p += 360;
11225
+ let h2p = Math.atan2(b2, a2p) * (180 / Math.PI);
11226
+ if (h2p < 0) h2p += 360;
11227
+ const dLp = L2 - L1;
11228
+ const dCp = C2p - C1p;
11229
+ let dhp;
11230
+ if (C1p * C2p === 0) dhp = 0;
11231
+ else if (Math.abs(h2p - h1p) <= 180) dhp = h2p - h1p;
11232
+ else if (h2p - h1p > 180) dhp = h2p - h1p - 360;
11233
+ else dhp = h2p - h1p + 360;
11234
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(dhp * Math.PI / 360);
11235
+ const Lp = (L1 + L2) / 2;
11236
+ const Cp = (C1p + C2p) / 2;
11237
+ let hp;
11238
+ if (C1p * C2p === 0) hp = h1p + h2p;
11239
+ else if (Math.abs(h1p - h2p) <= 180) hp = (h1p + h2p) / 2;
11240
+ else if (h1p + h2p < 360) hp = (h1p + h2p + 360) / 2;
11241
+ else hp = (h1p + h2p - 360) / 2;
11242
+ const T = 1 - 0.17 * Math.cos((hp - 30) * Math.PI / 180) + 0.24 * Math.cos(2 * hp * Math.PI / 180) + 0.32 * Math.cos((3 * hp + 6) * Math.PI / 180) - 0.2 * Math.cos((4 * hp - 63) * Math.PI / 180);
11243
+ const SL = 1 + 0.015 * (Lp - 50) * (Lp - 50) / Math.sqrt(20 + (Lp - 50) * (Lp - 50));
11244
+ const SC = 1 + 0.045 * Cp;
11245
+ const SH = 1 + 0.015 * Cp * T;
11246
+ const Cp7 = Math.pow(Cp, 7);
11247
+ const RT = -2 * Math.sqrt(Cp7 / (Cp7 + Math.pow(25, 7))) * Math.sin(60 * Math.exp(-Math.pow((hp - 275) / 25, 2)) * Math.PI / 180);
11248
+ return Math.sqrt(
11249
+ Math.pow(dLp / SL, 2) + Math.pow(dCp / SC, 2) + Math.pow(dHp / SH, 2) + RT * (dCp / SC) * (dHp / SH)
11250
+ );
11251
+ }
11252
+ var COLOR_SLOTS = [
11253
+ "primary",
11254
+ "secondary",
11255
+ "accent",
11256
+ "background",
11257
+ "success",
11258
+ "warning",
11259
+ "error",
11260
+ "text",
11261
+ "textSecondary"
11262
+ ];
11263
+ function auditBrandColors(palette, contentColors, threshold = 10) {
11264
+ if (!contentColors.length) return { entries: [], overallScore: 100, passed: true };
11265
+ const brandColors = [];
11266
+ for (const slot of COLOR_SLOTS) {
11267
+ const hex = palette[slot];
11268
+ if (typeof hex === "string" && hex.startsWith("#")) {
11269
+ brandColors.push({ slot, hex, lab: hexToLab(hex) });
11270
+ }
11271
+ }
11272
+ if (!brandColors.length) return { entries: [], overallScore: 50, passed: false };
11273
+ const entries = contentColors.map((color) => {
11274
+ const colorLab = hexToLab(color);
11275
+ let minDE = Infinity;
11276
+ let closest = brandColors[0];
11277
+ for (const bc of brandColors) {
11278
+ const de = deltaE2000(colorLab, bc.lab);
11279
+ if (de < minDE) {
11280
+ minDE = de;
11281
+ closest = bc;
11282
+ }
11283
+ }
11284
+ return {
11285
+ color,
11286
+ closestBrandColor: closest.hex,
11287
+ closestBrandSlot: closest.slot,
11288
+ deltaE: Math.round(minDE * 100) / 100,
11289
+ passed: minDE <= threshold
11290
+ };
11291
+ });
11292
+ const passedCount = entries.filter((e) => e.passed).length;
11293
+ const overallScore = Math.round(passedCount / entries.length * 100);
11294
+ return { entries, overallScore, passed: overallScore >= 80 };
11295
+ }
11296
+ function exportDesignTokens(palette, typography, format) {
11297
+ if (format === "css") return exportCSS(palette, typography);
11298
+ if (format === "tailwind") return JSON.stringify(exportTailwind(palette), null, 2);
11299
+ return JSON.stringify(exportFigma(palette, typography), null, 2);
11300
+ }
11301
+ function exportCSS(palette, typography) {
11302
+ const lines = [":root {"];
11303
+ const slots = [
11304
+ ["--brand-primary", "primary"],
11305
+ ["--brand-secondary", "secondary"],
11306
+ ["--brand-accent", "accent"],
11307
+ ["--brand-background", "background"],
11308
+ ["--brand-success", "success"],
11309
+ ["--brand-warning", "warning"],
11310
+ ["--brand-error", "error"],
11311
+ ["--brand-text", "text"],
11312
+ ["--brand-text-secondary", "textSecondary"]
11313
+ ];
11314
+ for (const [varName, key] of slots) {
11315
+ const v = palette[key];
11316
+ if (typeof v === "string" && v) lines.push(` ${varName}: ${v};`);
11317
+ }
11318
+ if (typography) {
11319
+ const hf = typography.headingFont;
11320
+ const bf = typography.bodyFont;
11321
+ if (hf) lines.push(` --brand-font-heading: ${hf};`);
11322
+ if (bf) lines.push(` --brand-font-body: ${bf};`);
11323
+ }
11324
+ lines.push("}");
11325
+ return lines.join("\n");
11326
+ }
11327
+ function exportTailwind(palette) {
11328
+ const colors = {};
11329
+ const map = [
11330
+ ["brand-primary", "primary"],
11331
+ ["brand-secondary", "secondary"],
11332
+ ["brand-accent", "accent"],
11333
+ ["brand-bg", "background"]
11334
+ ];
11335
+ for (const [tw, key] of map) {
11336
+ const v = palette[key];
11337
+ if (typeof v === "string" && v) colors[tw] = v;
11338
+ }
11339
+ return colors;
11340
+ }
11341
+ function exportFigma(palette, typography) {
11342
+ const tokens = { color: {} };
11343
+ for (const slot of ["primary", "secondary", "accent", "background"]) {
11344
+ const v = palette[slot];
11345
+ if (typeof v === "string") tokens.color[slot] = { value: v, type: "color" };
11346
+ }
11347
+ if (typography) {
11348
+ const hf = typography.headingFont;
11349
+ const bf = typography.bodyFont;
11350
+ if (hf || bf) {
11351
+ tokens.fontFamily = {};
11352
+ if (hf) tokens.fontFamily.heading = { value: String(hf), type: "fontFamilies" };
11353
+ if (bf) tokens.fontFamily.body = { value: String(bf), type: "fontFamilies" };
11354
+ }
11355
+ }
11356
+ return tokens;
11357
+ }
11358
+
10611
11359
  // src/tools/brandRuntime.ts
10612
- function asEnvelope20(data) {
11360
+ function asEnvelope21(data) {
10613
11361
  return {
10614
11362
  _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
10615
11363
  data
@@ -10620,7 +11368,7 @@ function registerBrandRuntimeTools(server) {
10620
11368
  "get_brand_runtime",
10621
11369
  "Get the full brand runtime for a project. Returns the 4-layer brand system: messaging (value props, pillars, proof points), voice (tone, vocabulary, avoid patterns), visual (palette, typography, composition), and operating constraints (audience, archetype). Also returns extraction confidence metadata.",
10622
11370
  {
10623
- project_id: z25.string().optional().describe("Project ID. Defaults to current project.")
11371
+ project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
10624
11372
  },
10625
11373
  async ({ project_id }) => {
10626
11374
  const projectId = project_id || await getDefaultProjectId();
@@ -10680,7 +11428,7 @@ function registerBrandRuntimeTools(server) {
10680
11428
  pagesScraped: meta.pagesScraped || 0
10681
11429
  }
10682
11430
  };
10683
- const envelope = asEnvelope20(runtime);
11431
+ const envelope = asEnvelope21(runtime);
10684
11432
  return {
10685
11433
  content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
10686
11434
  };
@@ -10690,7 +11438,7 @@ function registerBrandRuntimeTools(server) {
10690
11438
  "explain_brand_system",
10691
11439
  "Explains what brand data is available vs missing for a project. Returns a human-readable summary of completeness, confidence levels, and recommendations for improving the brand profile.",
10692
11440
  {
10693
- project_id: z25.string().optional().describe("Project ID. Defaults to current project.")
11441
+ project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
10694
11442
  },
10695
11443
  async ({ project_id }) => {
10696
11444
  const projectId = project_id || await getDefaultProjectId();
@@ -10802,8 +11550,8 @@ function registerBrandRuntimeTools(server) {
10802
11550
  "check_brand_consistency",
10803
11551
  "Check if content text is consistent with the brand voice, vocabulary, messaging, and factual claims. Returns per-dimension scores (0-100) and specific issues found. Use this to validate scripts, captions, or post copy before publishing.",
10804
11552
  {
10805
- content: z25.string().describe("The content text to check for brand consistency."),
10806
- project_id: z25.string().optional().describe("Project ID. Defaults to current project.")
11553
+ content: z26.string().describe("The content text to check for brand consistency."),
11554
+ project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
10807
11555
  },
10808
11556
  async ({ content, project_id }) => {
10809
11557
  const projectId = project_id || await getDefaultProjectId();
@@ -10822,7 +11570,76 @@ function registerBrandRuntimeTools(server) {
10822
11570
  }
10823
11571
  const profile = row.profile_data;
10824
11572
  const checkResult = computeBrandConsistency(content, profile);
10825
- const envelope = asEnvelope20(checkResult);
11573
+ const envelope = asEnvelope21(checkResult);
11574
+ return {
11575
+ content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
11576
+ };
11577
+ }
11578
+ );
11579
+ server.tool(
11580
+ "audit_brand_colors",
11581
+ "Audit content colors against the brand palette using perceptual color distance (Delta E 2000). Returns per-color compliance scores and identifies the closest brand color for each input.",
11582
+ {
11583
+ content_colors: z26.array(z26.string()).describe('Hex color strings used in the content (e.g., ["#FF0000", "#00FF00"])'),
11584
+ project_id: z26.string().optional().describe("Project ID. Defaults to current project."),
11585
+ threshold: z26.number().optional().describe("Max Delta E for on-brand (default 10). Lower = stricter.")
11586
+ },
11587
+ async ({ content_colors, project_id, threshold }) => {
11588
+ const projectId = project_id || await getDefaultProjectId();
11589
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", { action: "brand-profile", projectId });
11590
+ const row = !efError && result?.success ? result.profile : null;
11591
+ if (!row?.profile_data?.colorPalette) {
11592
+ return {
11593
+ content: [
11594
+ {
11595
+ type: "text",
11596
+ text: "No brand color palette found. Extract a brand profile first."
11597
+ }
11598
+ ],
11599
+ isError: true
11600
+ };
11601
+ }
11602
+ const auditResult = auditBrandColors(
11603
+ row.profile_data.colorPalette,
11604
+ content_colors,
11605
+ threshold ?? 10
11606
+ );
11607
+ const envelope = asEnvelope21(auditResult);
11608
+ return {
11609
+ content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
11610
+ };
11611
+ }
11612
+ );
11613
+ server.tool(
11614
+ "export_design_tokens",
11615
+ "Export brand palette and typography as design tokens. Supports CSS custom properties, Tailwind config, and Figma Tokens JSON formats.",
11616
+ {
11617
+ format: z26.enum(["css", "tailwind", "figma"]).describe(
11618
+ "Output format: css (CSS variables), tailwind (theme.extend.colors), figma (Figma Tokens JSON)"
11619
+ ),
11620
+ project_id: z26.string().optional().describe("Project ID. Defaults to current project.")
11621
+ },
11622
+ async ({ format, project_id }) => {
11623
+ const projectId = project_id || await getDefaultProjectId();
11624
+ const { data: result, error: efError } = await callEdgeFunction("mcp-data", { action: "brand-profile", projectId });
11625
+ const row = !efError && result?.success ? result.profile : null;
11626
+ if (!row?.profile_data?.colorPalette) {
11627
+ return {
11628
+ content: [
11629
+ {
11630
+ type: "text",
11631
+ text: "No brand color palette found. Extract a brand profile first."
11632
+ }
11633
+ ],
11634
+ isError: true
11635
+ };
11636
+ }
11637
+ const output = exportDesignTokens(
11638
+ row.profile_data.colorPalette,
11639
+ row.profile_data.typography,
11640
+ format
11641
+ );
11642
+ const envelope = asEnvelope21({ format, tokens: output });
10826
11643
  return {
10827
11644
  content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }]
10828
11645
  };
@@ -10830,6 +11647,389 @@ function registerBrandRuntimeTools(server) {
10830
11647
  );
10831
11648
  }
10832
11649
 
11650
+ // src/tools/carousel.ts
11651
+ import { z as z27 } from "zod";
11652
+ init_supabase();
11653
+ init_request_context();
11654
+ var MAX_CREDITS_PER_RUN2 = Math.max(0, Number(process.env.SOCIALNEURON_MAX_CREDITS_PER_RUN || 0));
11655
+ var MAX_ASSETS_PER_RUN2 = Math.max(0, Number(process.env.SOCIALNEURON_MAX_ASSETS_PER_RUN || 0));
11656
+ var _globalCreditsUsed2 = 0;
11657
+ var _globalAssetsGenerated2 = 0;
11658
+ function getCreditsUsed2() {
11659
+ const ctx = requestContext.getStore();
11660
+ return ctx ? ctx.creditsUsed : _globalCreditsUsed2;
11661
+ }
11662
+ function addCreditsUsed2(amount) {
11663
+ const ctx = requestContext.getStore();
11664
+ if (ctx) {
11665
+ ctx.creditsUsed += amount;
11666
+ } else {
11667
+ _globalCreditsUsed2 += amount;
11668
+ }
11669
+ }
11670
+ function getAssetsGenerated2() {
11671
+ const ctx = requestContext.getStore();
11672
+ return ctx ? ctx.assetsGenerated : _globalAssetsGenerated2;
11673
+ }
11674
+ function addAssetsGenerated2(count) {
11675
+ const ctx = requestContext.getStore();
11676
+ if (ctx) {
11677
+ ctx.assetsGenerated += count;
11678
+ } else {
11679
+ _globalAssetsGenerated2 += count;
11680
+ }
11681
+ }
11682
+ function checkCreditBudget2(estimatedCost) {
11683
+ if (MAX_CREDITS_PER_RUN2 <= 0) return { ok: true };
11684
+ const used = getCreditsUsed2();
11685
+ if (used + estimatedCost > MAX_CREDITS_PER_RUN2) {
11686
+ return {
11687
+ ok: false,
11688
+ message: `Credit budget exceeded: ${used} used + ${estimatedCost} estimated > ${MAX_CREDITS_PER_RUN2} limit. Use a smaller slide count or cheaper image model.`
11689
+ };
11690
+ }
11691
+ return { ok: true };
11692
+ }
11693
+ function checkAssetBudget2() {
11694
+ if (MAX_ASSETS_PER_RUN2 <= 0) return { ok: true };
11695
+ const gen = getAssetsGenerated2();
11696
+ if (gen >= MAX_ASSETS_PER_RUN2) {
11697
+ return {
11698
+ ok: false,
11699
+ message: `Asset limit reached: ${gen}/${MAX_ASSETS_PER_RUN2} assets generated this run.`
11700
+ };
11701
+ }
11702
+ return { ok: true };
11703
+ }
11704
+ var IMAGE_CREDIT_ESTIMATES2 = {
11705
+ midjourney: 20,
11706
+ "nano-banana": 15,
11707
+ "nano-banana-pro": 25,
11708
+ "flux-pro": 30,
11709
+ "flux-max": 50,
11710
+ "gpt4o-image": 40,
11711
+ imagen4: 35,
11712
+ "imagen4-fast": 25,
11713
+ seedream: 20
11714
+ };
11715
+ async function fetchBrandVisualContext(projectId) {
11716
+ const { data, error } = await callEdgeFunction("mcp-data", { action: "brand-profile", projectId });
11717
+ if (error || !data?.success || !data.profile?.profile_data) return null;
11718
+ const profile = data.profile.profile_data;
11719
+ const parts = [];
11720
+ const palette = profile.colorPalette;
11721
+ if (palette) {
11722
+ const colors = Object.entries(palette).filter(([, v]) => typeof v === "string" && v.startsWith("#")).map(([k, v]) => `${k}: ${v}`).slice(0, 5);
11723
+ if (colors.length > 0) {
11724
+ parts.push(`Brand color palette: ${colors.join(", ")}`);
11725
+ }
11726
+ }
11727
+ const logoUrl = profile.logoUrl;
11728
+ let logoDesc = null;
11729
+ if (logoUrl) {
11730
+ const brandName = profile.name || "brand";
11731
+ logoDesc = `Include a small "${brandName}" logo watermark in the bottom-right corner`;
11732
+ parts.push(logoDesc);
11733
+ }
11734
+ const voice = profile.voiceProfile;
11735
+ if (voice?.tone && Array.isArray(voice.tone) && voice.tone.length > 0) {
11736
+ parts.push(`Visual mood: ${voice.tone.slice(0, 3).join(", ")}`);
11737
+ }
11738
+ if (parts.length === 0) return null;
11739
+ return {
11740
+ stylePrefix: parts.join(". "),
11741
+ brandName: profile.name || null,
11742
+ logoDescription: logoDesc
11743
+ };
11744
+ }
11745
+ function registerCarouselTools(server) {
11746
+ server.tool(
11747
+ "create_carousel",
11748
+ "End-to-end carousel creation: generates slide text + kicks off image generation for each slide in parallel. When brand_id is provided, auto-injects brand colors, logo watermark, and visual mood into every image prompt. Returns carousel data + image job_ids. Poll each job_id with check_status until complete, then call schedule_post with job_ids to publish as Instagram carousel (media_type=CAROUSEL_ALBUM).",
11749
+ {
11750
+ topic: z27.string().max(200).describe(
11751
+ 'Carousel topic/hook \u2014 be specific. Example: "5 pricing mistakes that kill SaaS startups" beats "SaaS tips".'
11752
+ ),
11753
+ image_model: z27.enum([
11754
+ "midjourney",
11755
+ "nano-banana",
11756
+ "nano-banana-pro",
11757
+ "flux-pro",
11758
+ "flux-max",
11759
+ "gpt4o-image",
11760
+ "imagen4",
11761
+ "imagen4-fast",
11762
+ "seedream"
11763
+ ]).describe(
11764
+ "Image model for slide visuals. flux-pro for general purpose, imagen4 for photorealistic, midjourney for artistic."
11765
+ ),
11766
+ template_id: z27.enum([
11767
+ "educational-series",
11768
+ "product-showcase",
11769
+ "story-arc",
11770
+ "before-after",
11771
+ "step-by-step",
11772
+ "quote-collection",
11773
+ "data-stats",
11774
+ "myth-vs-reality",
11775
+ "hormozi-authority"
11776
+ ]).optional().describe("Carousel template. Default: hormozi-authority."),
11777
+ slide_count: z27.number().min(3).max(10).optional().describe("Number of slides (3-10). Default: 7."),
11778
+ aspect_ratio: z27.enum(["1:1", "4:5", "9:16"]).optional().describe("Aspect ratio for both carousel and images. Default: 1:1."),
11779
+ style: z27.enum(["minimal", "bold", "professional", "playful", "hormozi"]).optional().describe("Visual style. Default: hormozi for hormozi-authority template."),
11780
+ image_style_suffix: z27.string().max(500).optional().describe(
11781
+ 'Style suffix appended to every image prompt for visual consistency across slides. Example: "dark moody lighting, cinematic, 35mm film grain".'
11782
+ ),
11783
+ project_id: z27.string().optional().describe("Project ID to associate the carousel with."),
11784
+ response_format: z27.enum(["text", "json"]).optional().describe("Response format. Default: text.")
11785
+ },
11786
+ async ({
11787
+ topic,
11788
+ image_model,
11789
+ template_id,
11790
+ slide_count,
11791
+ aspect_ratio,
11792
+ style,
11793
+ image_style_suffix,
11794
+ brand_id,
11795
+ project_id,
11796
+ response_format
11797
+ }) => {
11798
+ const format = response_format ?? "text";
11799
+ const startedAt = Date.now();
11800
+ const templateId = template_id ?? "hormozi-authority";
11801
+ const resolvedStyle = style ?? (templateId === "hormozi-authority" ? "hormozi" : "professional");
11802
+ const slideCount = slide_count ?? 7;
11803
+ const ratio = aspect_ratio ?? "1:1";
11804
+ let brandContext = null;
11805
+ const brandProjectId = brand_id || project_id || await getDefaultProjectId();
11806
+ if (brandProjectId) {
11807
+ brandContext = await fetchBrandVisualContext(brandProjectId);
11808
+ }
11809
+ const carouselTextCost = 10 + slideCount * 2;
11810
+ const perImageCost = IMAGE_CREDIT_ESTIMATES2[image_model] ?? 30;
11811
+ const totalEstimatedCost = carouselTextCost + slideCount * perImageCost;
11812
+ const budgetCheck = checkCreditBudget2(totalEstimatedCost);
11813
+ if (!budgetCheck.ok) {
11814
+ await logMcpToolInvocation({
11815
+ toolName: "create_carousel",
11816
+ status: "error",
11817
+ durationMs: Date.now() - startedAt,
11818
+ details: { error: budgetCheck.message, totalEstimatedCost }
11819
+ });
11820
+ return {
11821
+ content: [{ type: "text", text: budgetCheck.message }],
11822
+ isError: true
11823
+ };
11824
+ }
11825
+ const assetBudget = checkAssetBudget2();
11826
+ if (!assetBudget.ok) {
11827
+ await logMcpToolInvocation({
11828
+ toolName: "create_carousel",
11829
+ status: "error",
11830
+ durationMs: Date.now() - startedAt,
11831
+ details: { error: assetBudget.message }
11832
+ });
11833
+ return {
11834
+ content: [{ type: "text", text: assetBudget.message }],
11835
+ isError: true
11836
+ };
11837
+ }
11838
+ const userId = await getDefaultUserId();
11839
+ const rateLimit = checkRateLimit("posting", `create_carousel:${userId}`);
11840
+ if (!rateLimit.allowed) {
11841
+ await logMcpToolInvocation({
11842
+ toolName: "create_carousel",
11843
+ status: "rate_limited",
11844
+ durationMs: Date.now() - startedAt,
11845
+ details: { retryAfter: rateLimit.retryAfter }
11846
+ });
11847
+ return {
11848
+ content: [
11849
+ {
11850
+ type: "text",
11851
+ text: `Rate limit exceeded. Retry in ~${rateLimit.retryAfter}s.`
11852
+ }
11853
+ ],
11854
+ isError: true
11855
+ };
11856
+ }
11857
+ const { data: carouselData, error: carouselError } = await callEdgeFunction(
11858
+ "generate-carousel",
11859
+ {
11860
+ topic,
11861
+ templateId,
11862
+ slideCount,
11863
+ aspectRatio: ratio,
11864
+ style: resolvedStyle,
11865
+ projectId: project_id
11866
+ },
11867
+ { timeoutMs: 6e4 }
11868
+ );
11869
+ if (carouselError || !carouselData?.carousel) {
11870
+ const errMsg = carouselError ?? "No carousel data returned";
11871
+ await logMcpToolInvocation({
11872
+ toolName: "create_carousel",
11873
+ status: "error",
11874
+ durationMs: Date.now() - startedAt,
11875
+ details: { phase: "text_generation", error: errMsg }
11876
+ });
11877
+ return {
11878
+ content: [{ type: "text", text: `Carousel text generation failed: ${errMsg}` }],
11879
+ isError: true
11880
+ };
11881
+ }
11882
+ const carousel = carouselData.carousel;
11883
+ const textCredits = carousel.credits?.used ?? carouselTextCost;
11884
+ addCreditsUsed2(textCredits);
11885
+ const imageJobs = await Promise.all(
11886
+ carousel.slides.map(async (slide) => {
11887
+ const promptParts = [];
11888
+ if (brandContext) promptParts.push(brandContext.stylePrefix);
11889
+ if (slide.headline) promptParts.push(slide.headline);
11890
+ if (slide.body) promptParts.push(slide.body);
11891
+ if (promptParts.length === 0) promptParts.push(topic);
11892
+ if (image_style_suffix) promptParts.push(image_style_suffix);
11893
+ const imagePrompt = promptParts.join(". ");
11894
+ try {
11895
+ const { data, error } = await callEdgeFunction(
11896
+ "kie-image-generate",
11897
+ {
11898
+ prompt: imagePrompt,
11899
+ model: image_model,
11900
+ aspectRatio: ratio
11901
+ },
11902
+ { timeoutMs: 3e4 }
11903
+ );
11904
+ if (error || !data?.taskId && !data?.asyncJobId) {
11905
+ return {
11906
+ slideNumber: slide.slideNumber,
11907
+ jobId: null,
11908
+ model: image_model,
11909
+ error: error ?? "No job ID returned"
11910
+ };
11911
+ }
11912
+ const jobId = data.asyncJobId ?? data.taskId ?? null;
11913
+ if (jobId) {
11914
+ addCreditsUsed2(perImageCost);
11915
+ addAssetsGenerated2(1);
11916
+ }
11917
+ return {
11918
+ slideNumber: slide.slideNumber,
11919
+ jobId,
11920
+ model: image_model,
11921
+ error: null
11922
+ };
11923
+ } catch (err) {
11924
+ return {
11925
+ slideNumber: slide.slideNumber,
11926
+ jobId: null,
11927
+ model: image_model,
11928
+ error: sanitizeError(err)
11929
+ };
11930
+ }
11931
+ })
11932
+ );
11933
+ const successfulJobs = imageJobs.filter((j) => j.jobId !== null);
11934
+ const failedJobs = imageJobs.filter((j) => j.jobId === null);
11935
+ await logMcpToolInvocation({
11936
+ toolName: "create_carousel",
11937
+ status: failedJobs.length === imageJobs.length ? "error" : "success",
11938
+ durationMs: Date.now() - startedAt,
11939
+ details: {
11940
+ carouselId: carousel.id,
11941
+ templateId,
11942
+ slideCount: carousel.slides.length,
11943
+ imagesStarted: successfulJobs.length,
11944
+ imagesFailed: failedJobs.length,
11945
+ imageModel: image_model,
11946
+ creditsUsed: getCreditsUsed2()
11947
+ }
11948
+ });
11949
+ if (format === "json") {
11950
+ return {
11951
+ content: [
11952
+ {
11953
+ type: "text",
11954
+ text: JSON.stringify(
11955
+ {
11956
+ _meta: { version: MCP_VERSION, timestamp: (/* @__PURE__ */ new Date()).toISOString() },
11957
+ data: {
11958
+ carouselId: carousel.id,
11959
+ templateId,
11960
+ style: resolvedStyle,
11961
+ slideCount: carousel.slides.length,
11962
+ slides: carousel.slides.map((s) => {
11963
+ const job = imageJobs.find((j) => j.slideNumber === s.slideNumber);
11964
+ return {
11965
+ ...s,
11966
+ imageJobId: job?.jobId ?? null,
11967
+ imageError: job?.error ?? null
11968
+ };
11969
+ }),
11970
+ imageModel: image_model,
11971
+ brandApplied: brandContext ? {
11972
+ brandName: brandContext.brandName,
11973
+ hasLogo: !!brandContext.logoDescription,
11974
+ stylePrefix: brandContext.stylePrefix
11975
+ } : null,
11976
+ jobIds: successfulJobs.map((j) => j.jobId),
11977
+ failedSlides: failedJobs.map((j) => ({
11978
+ slideNumber: j.slideNumber,
11979
+ error: j.error
11980
+ })),
11981
+ credits: {
11982
+ textGeneration: textCredits,
11983
+ imagesEstimated: successfulJobs.length * perImageCost,
11984
+ totalEstimated: textCredits + successfulJobs.length * perImageCost
11985
+ }
11986
+ }
11987
+ },
11988
+ null,
11989
+ 2
11990
+ )
11991
+ }
11992
+ ]
11993
+ };
11994
+ }
11995
+ const lines = [
11996
+ `Carousel created: ${carousel.slides.length} slides + ${successfulJobs.length} image jobs started.`,
11997
+ ` Carousel ID: ${carousel.id}`,
11998
+ ` Template: ${templateId} | Style: ${resolvedStyle}`,
11999
+ ` Image model: ${image_model}`,
12000
+ ` Credits: ~${textCredits + successfulJobs.length * perImageCost} (${textCredits} text + ${successfulJobs.length * perImageCost} images)`
12001
+ ];
12002
+ if (brandContext) {
12003
+ lines.push(
12004
+ ` Brand: ${brandContext.brandName || "unnamed"}${brandContext.logoDescription ? " (logo overlay via prompt)" : ""}`
12005
+ );
12006
+ }
12007
+ lines.push("", "Slides:");
12008
+ for (const slide of carousel.slides) {
12009
+ const job = imageJobs.find((j) => j.slideNumber === slide.slideNumber);
12010
+ const status = job?.jobId ? `image: ${job.jobId}` : `image FAILED: ${job?.error}`;
12011
+ lines.push(` ${slide.slideNumber}. ${slide.headline || "(no headline)"} [${status}]`);
12012
+ }
12013
+ if (failedJobs.length > 0) {
12014
+ lines.push("");
12015
+ lines.push(
12016
+ `WARNING: ${failedJobs.length}/${imageJobs.length} image generations failed. Use generate_image manually for failed slides.`
12017
+ );
12018
+ }
12019
+ const jobIdList = successfulJobs.map((j) => j.jobId).join(", ");
12020
+ lines.push("");
12021
+ lines.push("Next steps:");
12022
+ lines.push(` 1. Poll each job: check_status with job_id for each of: ${jobIdList}`);
12023
+ lines.push(
12024
+ " 2. When all complete: schedule_post with job_ids=[...] and media_type=CAROUSEL_ALBUM"
12025
+ );
12026
+ return {
12027
+ content: [{ type: "text", text: lines.join("\n") }]
12028
+ };
12029
+ }
12030
+ );
12031
+ }
12032
+
10833
12033
  // src/lib/register-tools.ts
10834
12034
  function applyScopeEnforcement(server, scopeResolver) {
10835
12035
  const originalTool = server.tool.bind(server);
@@ -10924,6 +12124,7 @@ function registerAllTools(server, options) {
10924
12124
  registerLoopSummaryTools(server);
10925
12125
  registerUsageTools(server);
10926
12126
  registerAutopilotTools(server);
12127
+ registerRecipeTools(server);
10927
12128
  registerExtractionTools(server);
10928
12129
  registerQualityTools(server);
10929
12130
  registerPlanningTools(server);
@@ -10933,21 +12134,22 @@ function registerAllTools(server, options) {
10933
12134
  registerSuggestTools(server);
10934
12135
  registerDigestTools(server);
10935
12136
  registerBrandRuntimeTools(server);
12137
+ registerCarouselTools(server);
10936
12138
  applyAnnotations(server);
10937
12139
  }
10938
12140
 
10939
12141
  // src/prompts.ts
10940
- import { z as z26 } from "zod";
12142
+ import { z as z28 } from "zod";
10941
12143
  function registerPrompts(server) {
10942
12144
  server.prompt(
10943
12145
  "create_weekly_content_plan",
10944
12146
  "Generate a full week of social media content (7 days, multiple platforms). Returns a structured plan with topics, formats, and posting times.",
10945
12147
  {
10946
- niche: z26.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
10947
- platforms: z26.string().optional().describe(
12148
+ niche: z28.string().describe('Your content niche or industry (e.g., "fitness coaching", "SaaS marketing")'),
12149
+ platforms: z28.string().optional().describe(
10948
12150
  'Comma-separated platforms to target (default: "YouTube, Instagram, TikTok, LinkedIn")'
10949
12151
  ),
10950
- tone: z26.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
12152
+ tone: z28.string().optional().describe('Brand tone of voice (e.g., "professional", "casual", "bold and edgy")')
10951
12153
  },
10952
12154
  ({ niche, platforms, tone }) => {
10953
12155
  const targetPlatforms = platforms || "YouTube, Instagram, TikTok, LinkedIn";
@@ -10989,8 +12191,8 @@ After building the plan, use \`save_content_plan\` to save it.`
10989
12191
  "analyze_top_content",
10990
12192
  "Analyze your best-performing posts to identify patterns and replicate success. Returns insights on hooks, formats, timing, and topics that resonate.",
10991
12193
  {
10992
- timeframe: z26.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
10993
- platform: z26.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
12194
+ timeframe: z28.string().optional().describe('Analysis period (default: "30 days"). E.g., "7 days", "90 days"'),
12195
+ platform: z28.string().optional().describe('Filter to a specific platform (e.g., "youtube", "instagram")')
10994
12196
  },
10995
12197
  ({ timeframe, platform: platform2 }) => {
10996
12198
  const period = timeframe || "30 days";
@@ -11027,10 +12229,10 @@ Format as a clear, actionable performance report.`
11027
12229
  "repurpose_content",
11028
12230
  "Take one piece of content and transform it into 8-10 pieces across multiple platforms and formats.",
11029
12231
  {
11030
- source: z26.string().describe(
12232
+ source: z28.string().describe(
11031
12233
  "The source content to repurpose \u2014 a URL, transcript, or the content text itself"
11032
12234
  ),
11033
- target_platforms: z26.string().optional().describe(
12235
+ target_platforms: z28.string().optional().describe(
11034
12236
  'Comma-separated target platforms (default: "Twitter, LinkedIn, Instagram, TikTok, YouTube")'
11035
12237
  )
11036
12238
  },
@@ -11072,9 +12274,9 @@ For each piece, include the platform, format, character count, and suggested pos
11072
12274
  "setup_brand_voice",
11073
12275
  "Define or refine your brand voice profile so all generated content stays on-brand. Walks through tone, audience, values, and style.",
11074
12276
  {
11075
- brand_name: z26.string().describe("Your brand or business name"),
11076
- industry: z26.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
11077
- website: z26.string().optional().describe("Your website URL for context")
12277
+ brand_name: z28.string().describe("Your brand or business name"),
12278
+ industry: z28.string().optional().describe('Your industry or niche (e.g., "B2B SaaS", "fitness coaching")'),
12279
+ website: z28.string().optional().describe("Your website URL for context")
11078
12280
  },
11079
12281
  ({ brand_name, industry, website }) => {
11080
12282
  const industryContext = industry ? ` in the ${industry} space` : "";
@@ -12140,7 +13342,7 @@ app.post("/mcp", authenticateRequest, async (req, res) => {
12140
13342
  const rawMessage = err instanceof Error ? err.message : "Internal server error";
12141
13343
  console.error(`[MCP HTTP] POST /mcp error: ${rawMessage}`);
12142
13344
  if (!res.headersSent) {
12143
- 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) } });
12144
13346
  }
12145
13347
  }
12146
13348
  });
@@ -12182,7 +13384,7 @@ app.delete("/mcp", authenticateRequest, async (req, res) => {
12182
13384
  app.use((err, _req, res, _next) => {
12183
13385
  console.error("[MCP HTTP] Unhandled Express error:", err.stack || err.message || err);
12184
13386
  if (!res.headersSent) {
12185
- res.status(500).json({ error: "internal_error", error_description: sanitizeError2(err) });
13387
+ res.status(500).json({ error: "internal_error", error_description: sanitizeError(err) });
12186
13388
  }
12187
13389
  });
12188
13390
  var httpServer = app.listen(PORT, "0.0.0.0", () => {