@productmaker/mcp 0.1.5 → 0.1.6

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/README.md CHANGED
@@ -41,6 +41,16 @@ Header: `Authorization: Bearer pm_live_...`
41
41
  | `publish_to_shopify` | Publish a completed task to a Shopify store |
42
42
  | `publish_to_meta` | Create a Meta Ads campaign from a task (always created PAUSED) |
43
43
 
44
+ ## Multiple photos per product
45
+
46
+ Creative tools accept an `images` array of **1–5 photos of the same product**. The first entry is the hero; the rest are alternate angles. Don't mix different products in one call — that's a separate task each time.
47
+
48
+ Each entry uses `{ "url": "https://..." }` (preferred — public HTTPS) or `{ "base64": "..." }` (only for local files <500 KB; clients truncate large arguments).
49
+
50
+ ## Downloading results
51
+
52
+ `get_task_status` returns short-lived (1 h) `mcp.productmaker.app/assets/...` URLs for every image and video. They stream R2 content through the MCP origin so clients with strict allowlists (claude.ai web, ChatGPT) can fetch them. Markdown image URLs auto-render in every major MCP client; videos are clickable download links.
53
+
44
54
  ## Environment variables
45
55
 
46
56
  | Var | Default | Required for |
@@ -48,6 +58,8 @@ Header: `Authorization: Bearer pm_live_...`
48
58
  | `PM_API_KEY` | — | stdio (required) |
49
59
  | `PM_API_URL` | `https://api.productmaker.app` | both |
50
60
  | `PORT` | `8080` | http only |
61
+ | `MCP_PROXY_BASE_URL` | `https://mcp.productmaker.app` | http only |
62
+ | `PM_PROXY_SECRET` | — | http only (32+ chars; enables asset proxy) |
51
63
 
52
64
  ## Support
53
65
 
package/dist/http.js CHANGED
@@ -4,6 +4,8 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
4
4
  // src/http.ts
5
5
  import express from "express";
6
6
  import rateLimit from "express-rate-limit";
7
+ import { Readable } from "stream";
8
+ import { pipeline } from "stream/promises";
7
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
10
 
9
11
  // src/server.ts
@@ -73,13 +75,18 @@ var ApiClient = class {
73
75
  }
74
76
  async postMultipart(path, files, fields) {
75
77
  const form = new FormData();
76
- for (const [name, file] of Object.entries(files)) {
77
- const blob = new Blob([
78
- new Uint8Array(file.buffer)
79
- ], {
80
- type: file.mimetype
81
- });
82
- form.append(name, blob, file.filename);
78
+ for (const [name, value] of Object.entries(files)) {
79
+ const parts = Array.isArray(value) ? value : [
80
+ value
81
+ ];
82
+ for (const file of parts) {
83
+ const blob = new Blob([
84
+ new Uint8Array(file.buffer)
85
+ ], {
86
+ type: file.mimetype
87
+ });
88
+ form.append(name, blob, file.filename);
89
+ }
83
90
  }
84
91
  for (const [k, v] of Object.entries(fields)) {
85
92
  if (v !== void 0) form.append(k, v);
@@ -112,6 +119,7 @@ var MCP_VERSION = "0.1.0";
112
119
  import { z as z2 } from "zod";
113
120
 
114
121
  // src/resolve-image.ts
122
+ import { lookup as dnsLookup } from "dns/promises";
115
123
  var MAX_BYTES = 20 * 1024 * 1024;
116
124
  var ALLOWED = /* @__PURE__ */ new Set([
117
125
  "image/jpeg",
@@ -146,11 +154,53 @@ function isPrivateHost(host) {
146
154
  if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
147
155
  if (host.startsWith("169.254.")) return true;
148
156
  if (host === "[::1]" || host === "::1") return true;
157
+ if (/^\[?0*:(?:0*:){6}0*1\]?$/i.test(host)) return true;
158
+ const ipv4Mapped = host.match(/^\[?::ffff:([0-9a-f.:]+)\]?$/i);
159
+ if (ipv4Mapped && ipv4Mapped[1]) {
160
+ const inner = ipv4Mapped[1];
161
+ if (inner.includes(".")) return isPrivateHost(inner);
162
+ const parts = inner.split(":").map((h) => parseInt(h, 16));
163
+ if (parts.length === 2 && parts.every((n) => !Number.isNaN(n))) {
164
+ const bytes = [
165
+ parts[0] >> 8,
166
+ parts[0] & 255,
167
+ parts[1] >> 8,
168
+ parts[1] & 255
169
+ ];
170
+ return isPrivateHost(bytes.join("."));
171
+ }
172
+ }
149
173
  if (/^\[?f[cd][0-9a-f]{2}:/i.test(host)) return true;
150
174
  if (/^\[?fe[89ab][0-9a-f]:/i.test(host)) return true;
151
175
  return false;
152
176
  }
153
177
  __name(isPrivateHost, "isPrivateHost");
178
+ async function assertHostResolvesPublic(hostname) {
179
+ let records;
180
+ try {
181
+ records = await dnsLookup(hostname, {
182
+ all: true
183
+ });
184
+ } catch {
185
+ if (process.env.NODE_ENV === "test" || process.env.VITEST) return;
186
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl DNS lookup failed for ${hostname}`);
187
+ }
188
+ for (const { address } of records) {
189
+ if (isPrivateHost(address)) {
190
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl host resolves to a private address (${address})`);
191
+ }
192
+ }
193
+ }
194
+ __name(assertHostResolvesPublic, "assertHostResolvesPublic");
195
+ async function resolveImages(images) {
196
+ if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
197
+ if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
198
+ return Promise.all(images.map((ref) => resolveImage({
199
+ imageUrl: ref.url,
200
+ imageBase64: ref.base64
201
+ })));
202
+ }
203
+ __name(resolveImages, "resolveImages");
154
204
  async function resolveImage(input) {
155
205
  if (input.imageUrl && input.imageBase64) {
156
206
  throw new ResolveImageError("INVALID_INPUT", "Provide only one of imageUrl or imageBase64");
@@ -164,6 +214,7 @@ async function resolveImage(input) {
164
214
  }
165
215
  if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
166
216
  if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
217
+ await assertHostResolvesPublic(u.hostname);
167
218
  let res;
168
219
  try {
169
220
  res = await fetch(input.imageUrl, {
@@ -360,7 +411,39 @@ var VideoModelEnum = z.enum([
360
411
  "grok",
361
412
  "kling"
362
413
  ]);
414
+ var ImageCreativeStyleEnum = z.enum([
415
+ "studio",
416
+ "floating",
417
+ "ingredients",
418
+ "in_use"
419
+ ]);
420
+ var ImageCreativeArchetypeEnum = z.enum([
421
+ "direct_response_product_ad",
422
+ "hero_outcome",
423
+ "offer_value_prop",
424
+ "social_proof_trust",
425
+ "product_clarity",
426
+ "problem_solution",
427
+ "lifestyle_identity"
428
+ ]);
429
+ var ImageCreativeVisualTreatmentEnum = z.enum([
430
+ "performance_product_ad",
431
+ "premium_dtc_carousel"
432
+ ]);
433
+ var ImageCreativeAspectRatioEnum = z.enum([
434
+ "1:1",
435
+ "4:5",
436
+ "9:16",
437
+ "16:9"
438
+ ]);
363
439
  var ImageBase64 = z.string().max(75e5);
440
+ var ImageRefSchema = z.object({
441
+ url: z.string().url().optional().describe("URL p\xFAblica HTTPS de la foto."),
442
+ base64: ImageBase64.optional().describe("Bytes de la foto en base64 (sin prefijo data:). \xDAsalo SOLO para archivos locales <500 KB.")
443
+ }).refine((v) => Boolean(v.url) !== Boolean(v.base64), {
444
+ message: "each image entry must have exactly one of `url` or `base64`"
445
+ });
446
+ var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe("1 a 5 fotos del MISMO producto. La PRIMERA es la principal (hero); las dem\xE1s son \xE1ngulos adicionales. Cada entrada usa `url` (preferido \u2014 p\xFAblico HTTPS) o `base64` (solo para archivos locales <500 KB; clientes MCP truncan argumentos >1 MB). PREFIERE siempre `url` si el usuario te dio una. NO mezcles productos distintos en una sola llamada.");
364
447
  function asText(value) {
365
448
  return {
366
449
  content: [
@@ -384,16 +467,14 @@ function flatten(obj) {
384
467
  }
385
468
  __name(flatten, "flatten");
386
469
  async function submitCreative(api, opts) {
387
- const { imageUrl, imageBase64, ...rest } = opts.input;
388
- const img = await resolveImage({
389
- imageUrl,
390
- imageBase64
391
- });
470
+ const { images, ...rest } = opts.input;
471
+ const resolved = await resolveImages(images);
472
+ const parts = resolved.map((img, i) => ({
473
+ ...img,
474
+ filename: `image-${i}.png`
475
+ }));
392
476
  const r = await api.postMultipart(opts.path, {
393
- [opts.fileField]: {
394
- ...img,
395
- filename: "image.png"
396
- }
477
+ images: parts
397
478
  }, flatten(rest));
398
479
  return asText({
399
480
  taskId: r.taskId,
@@ -403,8 +484,135 @@ async function submitCreative(api, opts) {
403
484
  }
404
485
  __name(submitCreative, "submitCreative");
405
486
 
487
+ // src/asset-proxy.ts
488
+ import { createHmac, timingSafeEqual } from "crypto";
489
+ var TOKEN_TTL_SECONDS = 3600;
490
+ var NO_SECRET = "";
491
+ function b64url(buf) {
492
+ return buf.toString("base64url");
493
+ }
494
+ __name(b64url, "b64url");
495
+ function fromB64url(s) {
496
+ return Buffer.from(s, "base64url");
497
+ }
498
+ __name(fromB64url, "fromB64url");
499
+ var ALLOWED_PROXY_HOSTS = /\.r2\.cloudflarestorage\.com$/i;
500
+ function isAllowedProxyTarget(rawUrl) {
501
+ try {
502
+ const u = new URL(rawUrl);
503
+ if (u.protocol !== "https:") return false;
504
+ return ALLOWED_PROXY_HOSTS.test(u.hostname);
505
+ } catch {
506
+ return false;
507
+ }
508
+ }
509
+ __name(isAllowedProxyTarget, "isAllowedProxyTarget");
510
+ function getProxySecret() {
511
+ const s = process.env.PM_PROXY_SECRET ?? NO_SECRET;
512
+ return s.length >= 32 ? s : NO_SECRET;
513
+ }
514
+ __name(getProxySecret, "getProxySecret");
515
+ function isProxyEnabled() {
516
+ return getProxySecret().length >= 32;
517
+ }
518
+ __name(isProxyEnabled, "isProxyEnabled");
519
+ var FILENAME_SAFE = /[^a-zA-Z0-9._-]+/g;
520
+ function sanitizeContentDispositionFilename(name) {
521
+ return name.replace(FILENAME_SAFE, "_").slice(0, 96) || "asset";
522
+ }
523
+ __name(sanitizeContentDispositionFilename, "sanitizeContentDispositionFilename");
524
+ function mintToken(payload, secret = getProxySecret()) {
525
+ if (secret.length < 32) throw new Error("PM_PROXY_SECRET too short");
526
+ if (!isAllowedProxyTarget(payload.url)) throw new Error("asset URL is outside the proxy allowlist");
527
+ const body = {
528
+ url: payload.url,
529
+ name: payload.name,
530
+ exp: Math.floor(Date.now() / 1e3) + TOKEN_TTL_SECONDS
531
+ };
532
+ const json = Buffer.from(JSON.stringify(body), "utf8");
533
+ const sig = createHmac("sha256", secret).update(json).digest();
534
+ return `${b64url(json)}.${b64url(sig)}`;
535
+ }
536
+ __name(mintToken, "mintToken");
537
+ function verifyToken(token, secret = getProxySecret()) {
538
+ if (secret.length < 32) return null;
539
+ const parts = token.split(".");
540
+ if (parts.length !== 2) return null;
541
+ let json;
542
+ let sig;
543
+ try {
544
+ json = fromB64url(parts[0]);
545
+ sig = fromB64url(parts[1]);
546
+ } catch {
547
+ return null;
548
+ }
549
+ const expected = createHmac("sha256", secret).update(json).digest();
550
+ if (expected.length !== sig.length) return null;
551
+ if (!timingSafeEqual(expected, sig)) return null;
552
+ let raw;
553
+ try {
554
+ raw = JSON.parse(json.toString("utf8"));
555
+ } catch {
556
+ return null;
557
+ }
558
+ if (!isAssetPayload(raw)) return null;
559
+ if (raw.exp < Math.floor(Date.now() / 1e3)) return null;
560
+ return raw;
561
+ }
562
+ __name(verifyToken, "verifyToken");
563
+ function isAssetPayload(v) {
564
+ if (!v || typeof v !== "object") return false;
565
+ const o = v;
566
+ return typeof o.url === "string" && typeof o.name === "string" && typeof o.exp === "number";
567
+ }
568
+ __name(isAssetPayload, "isAssetPayload");
569
+ function buildProxyUrl(opts) {
570
+ const secret = getProxySecret();
571
+ if (!opts.proxyBaseUrl || !secret) return null;
572
+ if (!isAllowedProxyTarget(opts.r2Url)) return null;
573
+ const token = mintToken({
574
+ url: opts.r2Url,
575
+ name: opts.filename
576
+ }, secret);
577
+ return `${opts.proxyBaseUrl.replace(/\/+$/, "")}/assets/${token}/${encodeURIComponent(opts.filename)}`;
578
+ }
579
+ __name(buildProxyUrl, "buildProxyUrl");
580
+
406
581
  // src/tools/task-outputs.ts
407
582
  var WEBAPP_BASE = "https://productmaker.app";
583
+ function proxifyOutputs(outputs, proxyBaseUrl) {
584
+ if (!proxyBaseUrl || !isProxyEnabled()) return outputs;
585
+ const rewrite = /* @__PURE__ */ __name((url, filename) => buildProxyUrl({
586
+ r2Url: url,
587
+ filename,
588
+ proxyBaseUrl
589
+ }) ?? url, "rewrite");
590
+ return {
591
+ webappUrl: outputs.webappUrl,
592
+ images: outputs.images.map((img, i) => ({
593
+ ...img,
594
+ url: rewrite(img.url, `image-${i}.png`)
595
+ })),
596
+ landing: {
597
+ sections: outputs.landing.sections.map((s) => ({
598
+ ...s,
599
+ url: rewrite(s.url, `landing-${sanitizeFilename(s.role)}.png`)
600
+ }))
601
+ },
602
+ videos: outputs.videos.map((v) => ({
603
+ ...v,
604
+ variants: v.variants.map((variant) => ({
605
+ ...variant,
606
+ url: rewrite(variant.url, `video-${sanitizeFilename(v.angleKey)}-${sanitizeFilename(variant.kind)}.mp4`)
607
+ }))
608
+ }))
609
+ };
610
+ }
611
+ __name(proxifyOutputs, "proxifyOutputs");
612
+ function sanitizeFilename(s) {
613
+ return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "asset";
614
+ }
615
+ __name(sanitizeFilename, "sanitizeFilename");
408
616
  async function fetchTaskOutputs(api, taskId) {
409
617
  const raw = await api.getJson(`/v1/results/${encodeURIComponent(taskId)}?signedUrls=true`);
410
618
  const videos = (raw.videoGenerators ?? []).map((vg) => {
@@ -450,7 +658,7 @@ async function fetchTaskOutputs(api, taskId) {
450
658
  }
451
659
  __name(fetchTaskOutputs, "fetchTaskOutputs");
452
660
  function escapeMdLabel(s) {
453
- return s.replace(/[\[\]()]/g, "");
661
+ return s.replace(/[\[\]\(\)\{\}`<>\r\n\\]/g, " ").replace(/\s+/g, " ").trim();
454
662
  }
455
663
  __name(escapeMdLabel, "escapeMdLabel");
456
664
  function renderOutputsMarkdown(outputs) {
@@ -479,13 +687,12 @@ __name(renderOutputsMarkdown, "renderOutputsMarkdown");
479
687
 
480
688
  // src/tools/tasks.ts
481
689
  var CreateProductTaskInput = z2.object({
482
- imageUrl: z2.string().url().optional().describe("URL p\xFAblica de la foto del producto. Provee este O imageBase64 (uno solo). Requerido si no se pasa imageBase64."),
483
- imageBase64: ImageBase64.optional().describe("Imagen del producto en base64 (sin prefijo data:). Provee este O imageUrl (uno solo)."),
690
+ images: ImagesInput,
484
691
  productUrl: z2.string().url().optional().describe("Opcional. URL de la p\xE1gina del producto (Shopify, AliExpress, Amazon, etc.) para enriquecer el contexto. Si se omite, la IA infiere todo desde la imagen."),
485
- language: Lang.describe('C\xF3digo de idioma para el contenido generado. Ejemplos: "es" (espa\xF1ol), "en" (ingl\xE9s), "pt" (portugu\xE9s). Si el usuario no lo especific\xF3, preg\xFAntale o usa "es" como default LATAM.'),
486
- country: Country.describe('C\xF3digo ISO del pa\xEDs objetivo. Ejemplos: "CO" (Colombia), "MX" (M\xE9xico), "US" (Estados Unidos), "AR" (Argentina). Define moneda, m\xE9todos de pago locales y tono. Si el usuario no lo especific\xF3, preg\xFAntale.'),
487
- price: z2.string().optional().describe('Opcional. Precio que se mostrar\xE1 en la landing y video. Cualquier formato libre \u2014 ej. "$89.900", "USD 29.99", "MXN 599". Si se omite, la IA sugiere uno basado en producto+pa\xEDs.'),
488
- offer: z2.string().optional().describe('Opcional. Oferta o promoci\xF3n a destacar. Ej. "2x1", "Env\xEDo gratis hoy", "50% OFF por lanzamiento". Si se omite, la IA decide si a\xF1adir una.'),
692
+ language: Lang.describe('C\xF3digo de idioma para el contenido generado. Ejemplos: "es", "en", "pt". REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas "es" por default.'),
693
+ country: Country.describe('C\xF3digo ISO del pa\xEDs objetivo. Ejemplos: "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago locales y tono. REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat.'),
694
+ price: z2.string().optional().describe('Precio que se mostrar\xE1 en la landing y video. Formato libre \u2014 ej. "$89.900", "USD 29.99", "MXN 599". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si te dice "t\xFA elige", om\xEDtelo y el backend sugiere uno.'),
695
+ offer: z2.string().optional().describe('Oferta o promoci\xF3n a destacar. Ej. "2x1", "Env\xEDo gratis hoy", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si te dice "t\xFA elige", om\xEDtelo.'),
489
696
  videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual del video. "ugc" = creador hablando a c\xE1mara (default, mejor CTR LATAM). "cartoon_3d" = animaci\xF3n 3D. "product_only" = sin actor. "ai_dynamic" = cinematogr\xE1fico AI. Si se omite, default "ugc".'),
490
697
  narrativeStyle: NarrativeStyleEnum.optional().describe('Opcional. Estructura narrativa. "review" = testimonial (default, alta conversi\xF3n). "problem_product_cta" = problema\u2192soluci\xF3n. "product_showcase" = features. "metaphor_product_cta" = met\xE1fora visual. "wearable_showcase" = para ropa/accesorios. Si se omite, default "review".'),
491
698
  videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). "bytedance"/"seedance" = TikTok-style. "kling"/"wan" = alternativos. "infinitalk" = lipsync largo. "grok" = grok-imagine. Si se omite, el sistema escoge seg\xFAn costo/calidad.'),
@@ -519,10 +726,14 @@ var EditTaskDraftInput = z2.object({
519
726
  scriptNotes: z2.string().max(2e3).optional()
520
727
  })
521
728
  });
522
- function registerTaskTools(server, api) {
523
- server.tool("create_product_task", 'Crea una tarea de producto desde una imagen. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: imageUrl o imageBase64 (uno solo), language, country.\n\nINPUTS OPCIONALES (TODOS los de abajo se pueden omitir \u2014 si el usuario no los mencion\xF3, PREG\xDANTALE primero si quiere personalizar precio/oferta/estilo, o llama la tool sin ellos y la IA elige defaults sensatos):\n- price, offer (texto libre del precio y promoci\xF3n)\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo para valores v\xE1lidos)\n- multiAngleVideos (booleano \u2014 generar varios \xE1ngulos en paralelo)\n- outputs (subconjunto de {landing,video,image})\n- productUrl (URL del producto en Shopify/AliExpress/etc.)\n\nNUNCA inventes valores fuera de los enums declarados. Si el usuario pide algo no soportado (ej. "estilo anime"), preg\xFAntale por una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
729
+ function registerTaskTools(server, api, proxyBaseUrl = null) {
730
+ server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo)\n- multiAngleVideos (booleano)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
524
731
  try {
525
- const img = await resolveImage(input);
732
+ const imgs = await resolveImages(input.images);
733
+ const parts = imgs.map((img, i) => ({
734
+ ...img,
735
+ filename: `image-${i}.png`
736
+ }));
526
737
  const fields = flatten({
527
738
  "options.languageCode": input.language,
528
739
  "options.country": input.country,
@@ -536,10 +747,7 @@ function registerTaskTools(server, api) {
536
747
  url: input.productUrl
537
748
  });
538
749
  const r = await api.postMultipart("/v1/ingest", {
539
- image: {
540
- ...img,
541
- filename: "image.png"
542
- }
750
+ images: parts
543
751
  }, fields);
544
752
  return asText({
545
753
  taskId: r.taskId,
@@ -557,7 +765,8 @@ function registerTaskTools(server, api) {
557
765
  const snapshot = await pollStatus(fetcher, input.waitSeconds ?? 0, signal);
558
766
  if (snapshot.status !== "done") return asText(snapshot);
559
767
  try {
560
- const outputs = await fetchTaskOutputs(api, input.taskId);
768
+ const rawOutputs = await fetchTaskOutputs(api, input.taskId);
769
+ const outputs = proxifyOutputs(rawOutputs, proxyBaseUrl);
561
770
  return {
562
771
  content: [
563
772
  {
@@ -611,21 +820,21 @@ __name(registerTaskTools, "registerTaskTools");
611
820
 
612
821
  // src/tools/creatives.ts
613
822
  import { z as z3 } from "zod";
614
- var ImageInputRefs = {
615
- imageUrl: z3.string().url().optional().describe("URL p\xFAblica de la foto del producto. Provee este O imageBase64 (uno solo)."),
616
- imageBase64: ImageBase64.optional().describe("Imagen del producto en base64 (sin prefijo data:). Provee este O imageUrl (uno solo)."),
617
- language: Lang.describe('Idioma del contenido. Ej. "es", "en", "pt". Si el usuario no lo dijo, preg\xFAntale.'),
618
- country: Country.describe('Pa\xEDs objetivo ISO. Ej. "CO", "MX", "US", "AR". Define moneda y tono. Si el usuario no lo dijo, preg\xFAntale.'),
619
- price: z3.string().optional().describe('Opcional. Precio en formato libre \u2014 ej. "$89.900", "USD 29.99". Om\xEDtelo y la IA sugiere uno.'),
620
- offer: z3.string().optional().describe('Opcional. Oferta a destacar \u2014 ej. "2x1", "Env\xEDo gratis", "50% OFF".')
823
+ var CreativeRefs = {
824
+ images: ImagesInput,
825
+ language: Lang.describe('Idioma del contenido. Ej. "es", "en", "pt". REQUERIDO \u2014 si el usuario no lo dijo, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas un default.'),
826
+ country: Country.describe('Pa\xEDs objetivo ISO. Ej. "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago y tono. REQUERIDO \u2014 si el usuario no lo dijo, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas un default.'),
827
+ price: z3.string().optional().describe('Precio en formato libre \u2014 ej. "$89.900", "USD 29.99". Opcional pero MUY IMPACTANTE para conversi\xF3n: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si el usuario te dice "t\xFA elige", omite el campo y la IA del backend sugiere uno.'),
828
+ offer: z3.string().optional().describe('Oferta a destacar \u2014 ej. "2x1", "Env\xEDo gratis", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si el usuario te dice "t\xFA elige", omite el campo.'),
829
+ buyerPersona: z3.string().optional().describe('Opcional. Descripci\xF3n del comprador objetivo. Ej. "Mujeres 35-50, profesionales urbanas". Si se omite, la IA infiere desde el \xE1ngulo y producto.'),
830
+ isCombo: z3.boolean().optional().describe("Opcional. Si true, el creativo presenta el producto como combo/pack. Default false.")
621
831
  };
622
832
  var VideoInput = z3.object({
623
- imageUrl: ImageInputRefs.imageUrl,
624
- imageBase64: ImageInputRefs.imageBase64,
833
+ images: CreativeRefs.images,
625
834
  title: z3.string().min(1).describe('T\xEDtulo corto del producto. Aparece en la primera vista del video. Ej. "Crema Anti-Edad Premium".'),
626
835
  primaryAngle: z3.string().min(1).describe('\xC1ngulo de venta principal (frase del beneficio). Ej. "Reduce arrugas en 14 d\xEDas" o "Para quienes odian las cremas grasosas".'),
627
- language: ImageInputRefs.language,
628
- country: ImageInputRefs.country,
836
+ language: CreativeRefs.language,
837
+ country: CreativeRefs.country,
629
838
  aspectRatio: z3.enum([
630
839
  "9:16",
631
840
  "16:9",
@@ -636,45 +845,54 @@ var VideoInput = z3.object({
636
845
  videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual. "ugc" (creador hablando, default), "cartoon_3d", "product_only", "ai_dynamic".'),
637
846
  videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). Ver lista de valores v\xE1lidos.'),
638
847
  features: z3.array(z3.string()).optional().describe('Opcional. Lista de features/beneficios clave a mencionar. Ej. ["resistente al agua", "bater\xEDa 24h", "carga inal\xE1mbrica"].'),
639
- price: ImageInputRefs.price,
640
- offer: ImageInputRefs.offer,
848
+ price: CreativeRefs.price,
849
+ offer: CreativeRefs.offer,
641
850
  cta: z3.string().optional().describe('Opcional. Call-to-action final del video. Ej. "Compra ahora", "Pide el tuyo hoy", "Link en bio". Si se omite, la IA escribe uno.'),
642
851
  scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion. Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico".')
643
852
  });
853
+ var imageStyleList = ImageCreativeStyleEnum.options.join(" | ");
854
+ var archetypeList = ImageCreativeArchetypeEnum.options.join(" | ");
855
+ var visualTreatmentList = ImageCreativeVisualTreatmentEnum.options.join(" | ");
856
+ var aspectRatioList = ImageCreativeAspectRatioEnum.options.join(" | ");
644
857
  var ImageInput = z3.object({
645
- imageUrl: ImageInputRefs.imageUrl,
646
- imageBase64: ImageInputRefs.imageBase64,
858
+ images: CreativeRefs.images,
647
859
  productTitle: z3.string().min(1).describe('Nombre del producto. Ej. "Crema Anti-Edad Premium".'),
648
860
  primaryAngle: z3.string().min(1).describe('\xC1ngulo de venta principal. Ej. "Reduce arrugas en 14 d\xEDas".'),
649
- language: ImageInputRefs.language,
650
- country: ImageInputRefs.country,
861
+ buyerPersona: CreativeRefs.buyerPersona,
862
+ language: CreativeRefs.language,
863
+ country: CreativeRefs.country,
651
864
  nVariants: z3.number().int().min(1).max(6).optional().describe("Opcional. N\xFAmero de variantes a generar (1-6). Default 3."),
652
865
  imageModel: z3.string().optional().describe('Opcional. Modelo de imagen. Ej. "gpt-image-2", "nano-banana-2". Om\xEDtelo para usar el default optimizado por costo/calidad.'),
653
- imageStyle: z3.string().optional().describe('Opcional. Estilo visual libre. Ej. "minimalista fondo blanco", "lifestyle outdoor", "ne\xF3n cyberpunk".'),
866
+ imageStyle: ImageCreativeStyleEnum.optional().describe(`Opcional. Estilo del shot del producto. Valores: ${imageStyleList}. Si se omite, la IA escoge seg\xFAn el archetype.`),
867
+ creativeArchetype: ImageCreativeArchetypeEnum.optional().describe(`Opcional. Arquetipo CRO de la composici\xF3n. Valores: ${archetypeList}. Si se omite, la IA escoge seg\xFAn \xE1ngulo+oferta (ej. "offer_value_prop" cuando hay descuento, "social_proof_trust" cuando el \xE1ngulo es testimonial).`),
868
+ visualTreatment: ImageCreativeVisualTreatmentEnum.optional().describe(`Opcional. Tratamiento gr\xE1fico. Valores: ${visualTreatmentList} ("performance_product_ad" = dealer-style alto CTR para Meta/TikTok ads; "premium_dtc_carousel" = fotograf\xEDa limpia estilo Allbirds/Glossier). Si se omite, la IA escoge.`),
869
+ styleMix: z3.boolean().optional().describe("Opcional. Si true, las N variantes mezclan distintos imageStyle para A/B test. Si false/omitido, todas comparten estilo. Default false."),
870
+ aspectRatio: ImageCreativeAspectRatioEnum.optional().describe(`Opcional. Relaci\xF3n de aspecto. Valores: ${aspectRatioList}. Default "4:5" (vertical Instagram feed).`),
654
871
  generateAdCopy: z3.boolean().optional().describe("Opcional. Si true, tambi\xE9n genera headline/body/CTA para el ad. Default false."),
872
+ pauseForAngleReview: z3.boolean().optional().describe("Opcional. Si true, pausa despu\xE9s de generar el \xE1ngulo y antes de las im\xE1genes para que el usuario edite v\xEDa edit_task_draft. Default false."),
655
873
  features: z3.array(z3.string()).optional().describe('Opcional. Features/beneficios a destacar visualmente. Ej. ["resistente al agua", "ultra liviano"].'),
656
874
  brandColors: z3.array(z3.string()).optional().describe('Opcional. Paleta de marca en hex. Ej. ["#FF6B35", "#004E89"]. Si se omite, la IA elige seg\xFAn el producto.'),
657
- price: ImageInputRefs.price,
658
- offer: ImageInputRefs.offer
875
+ price: CreativeRefs.price,
876
+ compareAtPrice: z3.string().optional().describe('Opcional. Precio "antes" de la oferta \u2014 solo para im\xE1genes con descuento. Ej. "$129.000" cuando price="$89.900". \xDAtil con creativeArchetype="offer_value_prop".'),
877
+ offer: CreativeRefs.offer,
878
+ isCombo: CreativeRefs.isCombo
659
879
  });
660
880
  var LandingInput = z3.object({
661
- imageUrl: ImageInputRefs.imageUrl,
662
- imageBase64: ImageInputRefs.imageBase64,
663
- language: ImageInputRefs.language,
664
- country: ImageInputRefs.country,
881
+ images: CreativeRefs.images,
882
+ language: CreativeRefs.language,
883
+ country: CreativeRefs.country,
665
884
  salesAngle: z3.string().optional().describe('Opcional. \xC1ngulo de venta principal. Ej. "Reduce arrugas en 14 d\xEDas". Si se omite, la IA lo deduce de la imagen.'),
666
- buyerPersona: z3.string().optional().describe('Opcional. Descripci\xF3n del comprador objetivo. Ej. "Mujeres 35-50, profesionales urbanas". Si se omite, la IA infiere.'),
667
- price: ImageInputRefs.price,
668
- offer: ImageInputRefs.offer,
885
+ buyerPersona: CreativeRefs.buyerPersona,
886
+ price: CreativeRefs.price,
887
+ offer: CreativeRefs.offer,
669
888
  paymentMethods: z3.string().optional().describe('Opcional. M\xE9todos de pago a mostrar (lista libre). Ej. "Tarjeta, PSE, Nequi, Contraentrega". Si se omite, la IA usa los m\xE1s comunes del pa\xEDs.'),
670
- isCombo: z3.boolean().optional().describe("Opcional. Si true, la landing presenta el producto como combo/pack. Default false.")
889
+ isCombo: CreativeRefs.isCombo
671
890
  });
672
891
  function registerCreativeTools(server, api) {
673
- server.tool("generate_video_creative", "Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: imageUrl o imageBase64, title, primaryAngle, language, country, aspectRatio.\n\nOPCIONALES (si el usuario no los mencion\xF3, PREG\xDANTALE antes de llamar \u2014 o invoca sin ellos y la IA decide): durationSeconds, narrativeStyle, videoStyle, videoModel (enums \u2014 usa SOLO valores declarados), features, price, offer, cta, scriptNotes.\n\nNo inventes valores fuera de los enums; si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.", VideoInput.shape, async (input) => {
892
+ server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, narrativeStyle, videoStyle, videoModel, features, cta, scriptNotes.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.', VideoInput.shape, async (input) => {
674
893
  try {
675
894
  return await submitCreative(api, {
676
895
  path: "/v1/creatives/videos",
677
- fileField: "media",
678
896
  estimatedSeconds: 300,
679
897
  input
680
898
  });
@@ -682,11 +900,10 @@ function registerCreativeTools(server, api) {
682
900
  throw translateError(e);
683
901
  }
684
902
  });
685
- server.tool("generate_image_creatives", "Genera N variantes de imagen creativa (ads est\xE1ticos). Devuelve un taskId.\n\nREQUERIDOS: imageUrl o imageBase64, productTitle, primaryAngle, language, country.\n\nOPCIONALES (si el usuario no los mencion\xF3, PREG\xDANTALE \u2014 o invoca sin ellos y la IA decide): nVariants (default 3), imageModel, imageStyle (texto libre), generateAdCopy, features, brandColors (hex), price, offer.", ImageInput.shape, async (input) => {
903
+ server.tool("generate_image_creatives", 'Genera N variantes de imagen creativa (ads est\xE1ticos). Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), productTitle, primaryAngle, language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario:\n- price y offer (se renderizan en la imagen \u2014 nunca los asumas; si dice "t\xFA elige", om\xEDtelos).\n- compareAtPrice si hay descuento (ej. "$129.000" para tachar).\n- creativeArchetype y visualTreatment si quiere un look espec\xEDfico (performance vs premium DTC). Si no le importa, om\xEDtelos.\n\nNO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente.\n\nOTROS OPCIONALES: buyerPersona, nVariants (default 3), imageModel, imageStyle, styleMix, aspectRatio (default 4:5), generateAdCopy, pauseForAngleReview, features, brandColors, isCombo.', ImageInput.shape, async (input) => {
686
904
  try {
687
905
  return await submitCreative(api, {
688
906
  path: "/v1/creatives/images",
689
- fileField: "image",
690
907
  estimatedSeconds: 60,
691
908
  input
692
909
  });
@@ -694,11 +911,10 @@ function registerCreativeTools(server, api) {
694
911
  throw translateError(e);
695
912
  }
696
913
  });
697
- server.tool("generate_landing", "Genera una landing page lista para Shopify. Devuelve un taskId.\n\nREQUERIDOS: imageUrl o imageBase64, language, country.\n\nOPCIONALES (si el usuario no los mencion\xF3, PREG\xDANTALE primero por price/offer/paymentMethods \u2014 son los m\xE1s impactantes para conversi\xF3n \u2014 o invoca sin ellos y la IA elige): salesAngle, buyerPersona, price, offer, paymentMethods (texto libre con m\xE9todos locales del pa\xEDs), isCombo.", LandingInput.shape, async (input) => {
914
+ server.tool("generate_landing", 'Genera una landing page lista para Shopify. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price, offer y paymentMethods si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para esos campos, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults): salesAngle, buyerPersona, isCombo.', LandingInput.shape, async (input) => {
698
915
  try {
699
916
  return await submitCreative(api, {
700
917
  path: "/v1/creatives/landing",
701
- fileField: "image",
702
918
  estimatedSeconds: 60,
703
919
  input
704
920
  });
@@ -782,7 +998,7 @@ function registerPublishTools(server, api) {
782
998
  __name(registerPublishTools, "registerPublishTools");
783
999
 
784
1000
  // src/server.ts
785
- function createServer(apiKey, apiBaseUrl) {
1001
+ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
786
1002
  const server = new McpServer({
787
1003
  name: "productmaker",
788
1004
  version: MCP_VERSION
@@ -795,7 +1011,7 @@ function createServer(apiKey, apiBaseUrl) {
795
1011
  }
796
1012
  });
797
1013
  const api = new ApiClient(apiBaseUrl, apiKey);
798
- registerTaskTools(server, api);
1014
+ registerTaskTools(server, api, proxyBaseUrl);
799
1015
  registerCreativeTools(server, api);
800
1016
  registerConnectionTools(server, api);
801
1017
  registerPublishTools(server, api);
@@ -804,21 +1020,21 @@ function createServer(apiKey, apiBaseUrl) {
804
1020
  __name(createServer, "createServer");
805
1021
 
806
1022
  // src/validate-api-url.ts
807
- function assertSafeApiBaseUrl(url) {
1023
+ function assertSafePublicBaseUrl(envName, url) {
808
1024
  let u;
809
1025
  try {
810
1026
  u = new URL(url);
811
1027
  } catch {
812
- throw new Error(`PM_API_URL is not a valid URL: ${url}`);
1028
+ throw new Error(`${envName} is not a valid URL: ${url}`);
813
1029
  }
814
1030
  if (u.protocol !== "https:") {
815
- throw new Error(`PM_API_URL must use https (got ${u.protocol})`);
1031
+ throw new Error(`${envName} must use https (got ${u.protocol})`);
816
1032
  }
817
1033
  if (isPrivateHost(u.hostname)) {
818
- throw new Error(`PM_API_URL must not point to a private/loopback host: ${u.hostname}`);
1034
+ throw new Error(`${envName} must not point to a private/loopback host: ${u.hostname}`);
819
1035
  }
820
1036
  }
821
- __name(assertSafeApiBaseUrl, "assertSafeApiBaseUrl");
1037
+ __name(assertSafePublicBaseUrl, "assertSafePublicBaseUrl");
822
1038
 
823
1039
  // src/http.ts
824
1040
  var PM_API_KEY_PREFIX = "pm_live_";
@@ -847,6 +1063,73 @@ function extractApiKey(req) {
847
1063
  return null;
848
1064
  }
849
1065
  __name(extractApiKey, "extractApiKey");
1066
+ var PROXY_FETCH_TIMEOUT_MS = 3e4;
1067
+ var INLINE_RENDERABLE_MIMES = /* @__PURE__ */ new Set([
1068
+ "image/jpeg",
1069
+ "image/png",
1070
+ "image/webp",
1071
+ "video/mp4"
1072
+ ]);
1073
+ async function handleAsset(req, res) {
1074
+ if (!getProxySecret()) {
1075
+ res.status(503).json({
1076
+ error: "Proxy disabled"
1077
+ });
1078
+ return;
1079
+ }
1080
+ const token = req.params.token;
1081
+ if (typeof token !== "string" || !token) {
1082
+ res.status(404).end();
1083
+ return;
1084
+ }
1085
+ const payload = verifyToken(token);
1086
+ if (!payload || !isAllowedProxyTarget(payload.url)) {
1087
+ res.status(404).end();
1088
+ return;
1089
+ }
1090
+ let upstream;
1091
+ try {
1092
+ upstream = await fetch(payload.url, {
1093
+ signal: AbortSignal.timeout(PROXY_FETCH_TIMEOUT_MS),
1094
+ redirect: "error"
1095
+ });
1096
+ } catch (err) {
1097
+ process.stderr.write(`mcp asset proxy fetch failed: ${String(err)}
1098
+ `);
1099
+ res.status(502).json({
1100
+ error: "Upstream fetch failed"
1101
+ });
1102
+ return;
1103
+ }
1104
+ if (!upstream.ok) {
1105
+ res.status(upstream.status === 404 ? 404 : 502).json({
1106
+ error: `Upstream HTTP ${upstream.status}`
1107
+ });
1108
+ return;
1109
+ }
1110
+ const rawCt = upstream.headers.get("content-type") ?? "application/octet-stream";
1111
+ const ct = rawCt.split(";")[0]?.trim() ?? "application/octet-stream";
1112
+ const cl = upstream.headers.get("content-length");
1113
+ const disposition = INLINE_RENDERABLE_MIMES.has(ct) ? "inline" : "attachment";
1114
+ res.status(200);
1115
+ res.setHeader("Content-Type", ct);
1116
+ if (cl) res.setHeader("Content-Length", cl);
1117
+ res.setHeader("Cache-Control", "private, max-age=300");
1118
+ res.setHeader("Content-Disposition", `${disposition}; filename="${sanitizeContentDispositionFilename(payload.name)}"`);
1119
+ res.setHeader("X-Content-Type-Options", "nosniff");
1120
+ if (!upstream.body) {
1121
+ res.end();
1122
+ return;
1123
+ }
1124
+ try {
1125
+ await pipeline(Readable.fromWeb(upstream.body), res);
1126
+ } catch (err) {
1127
+ process.stderr.write(`mcp asset proxy stream failed: ${String(err)}
1128
+ `);
1129
+ if (!res.writableEnded) res.end();
1130
+ }
1131
+ }
1132
+ __name(handleAsset, "handleAsset");
850
1133
  function createApp(opts) {
851
1134
  const app = express();
852
1135
  app.set("trust proxy", 1);
@@ -878,6 +1161,12 @@ function createApp(opts) {
878
1161
  ok: true
879
1162
  });
880
1163
  });
1164
+ app.get("/assets/:token/:filename?", rateLimit({
1165
+ windowMs: 6e4,
1166
+ max: 600,
1167
+ standardHeaders: true,
1168
+ legacyHeaders: false
1169
+ }), handleAsset);
881
1170
  app.post("/mcp", async (req, res) => {
882
1171
  const apiKey = extractApiKey(req);
883
1172
  if (!apiKey) {
@@ -887,7 +1176,7 @@ function createApp(opts) {
887
1176
  return;
888
1177
  }
889
1178
  try {
890
- const server = createServer(apiKey, opts.apiBaseUrl);
1179
+ const server = createServer(apiKey, opts.apiBaseUrl, opts.proxyBaseUrl ?? null);
891
1180
  const transport = new StreamableHTTPServerTransport({
892
1181
  sessionIdGenerator: void 0
893
1182
  });
@@ -907,15 +1196,24 @@ __name(createApp, "createApp");
907
1196
  if (process.env.MCP_HTTP_AUTOSTART !== "false") {
908
1197
  const port = Number(process.env.PORT ?? 8080);
909
1198
  const apiBaseUrl = process.env.PM_API_URL ?? "https://api.productmaker.app";
1199
+ const proxyBaseUrl = process.env.MCP_PROXY_BASE_URL ?? "https://mcp.productmaker.app";
1200
+ try {
1201
+ assertSafePublicBaseUrl("PM_API_URL", apiBaseUrl);
1202
+ } catch (e) {
1203
+ process.stderr.write(`${e.message}
1204
+ `);
1205
+ process.exit(1);
1206
+ }
910
1207
  try {
911
- assertSafeApiBaseUrl(apiBaseUrl);
1208
+ assertSafePublicBaseUrl("MCP_PROXY_BASE_URL", proxyBaseUrl);
912
1209
  } catch (e) {
913
1210
  process.stderr.write(`${e.message}
914
1211
  `);
915
1212
  process.exit(1);
916
1213
  }
917
1214
  createApp({
918
- apiBaseUrl
1215
+ apiBaseUrl,
1216
+ proxyBaseUrl
919
1217
  }).listen(port, () => {
920
1218
  process.stdout.write(`mcp http listening on ${port}
921
1219
  `);
package/dist/stdio.js CHANGED
@@ -72,13 +72,18 @@ var ApiClient = class {
72
72
  }
73
73
  async postMultipart(path, files, fields) {
74
74
  const form = new FormData();
75
- for (const [name, file] of Object.entries(files)) {
76
- const blob = new Blob([
77
- new Uint8Array(file.buffer)
78
- ], {
79
- type: file.mimetype
80
- });
81
- form.append(name, blob, file.filename);
75
+ for (const [name, value] of Object.entries(files)) {
76
+ const parts = Array.isArray(value) ? value : [
77
+ value
78
+ ];
79
+ for (const file of parts) {
80
+ const blob = new Blob([
81
+ new Uint8Array(file.buffer)
82
+ ], {
83
+ type: file.mimetype
84
+ });
85
+ form.append(name, blob, file.filename);
86
+ }
82
87
  }
83
88
  for (const [k, v] of Object.entries(fields)) {
84
89
  if (v !== void 0) form.append(k, v);
@@ -111,6 +116,7 @@ var MCP_VERSION = "0.1.0";
111
116
  import { z as z2 } from "zod";
112
117
 
113
118
  // src/resolve-image.ts
119
+ import { lookup as dnsLookup } from "dns/promises";
114
120
  var MAX_BYTES = 20 * 1024 * 1024;
115
121
  var ALLOWED = /* @__PURE__ */ new Set([
116
122
  "image/jpeg",
@@ -145,11 +151,53 @@ function isPrivateHost(host) {
145
151
  if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
146
152
  if (host.startsWith("169.254.")) return true;
147
153
  if (host === "[::1]" || host === "::1") return true;
154
+ if (/^\[?0*:(?:0*:){6}0*1\]?$/i.test(host)) return true;
155
+ const ipv4Mapped = host.match(/^\[?::ffff:([0-9a-f.:]+)\]?$/i);
156
+ if (ipv4Mapped && ipv4Mapped[1]) {
157
+ const inner = ipv4Mapped[1];
158
+ if (inner.includes(".")) return isPrivateHost(inner);
159
+ const parts = inner.split(":").map((h) => parseInt(h, 16));
160
+ if (parts.length === 2 && parts.every((n) => !Number.isNaN(n))) {
161
+ const bytes = [
162
+ parts[0] >> 8,
163
+ parts[0] & 255,
164
+ parts[1] >> 8,
165
+ parts[1] & 255
166
+ ];
167
+ return isPrivateHost(bytes.join("."));
168
+ }
169
+ }
148
170
  if (/^\[?f[cd][0-9a-f]{2}:/i.test(host)) return true;
149
171
  if (/^\[?fe[89ab][0-9a-f]:/i.test(host)) return true;
150
172
  return false;
151
173
  }
152
174
  __name(isPrivateHost, "isPrivateHost");
175
+ async function assertHostResolvesPublic(hostname) {
176
+ let records;
177
+ try {
178
+ records = await dnsLookup(hostname, {
179
+ all: true
180
+ });
181
+ } catch {
182
+ if (process.env.NODE_ENV === "test" || process.env.VITEST) return;
183
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl DNS lookup failed for ${hostname}`);
184
+ }
185
+ for (const { address } of records) {
186
+ if (isPrivateHost(address)) {
187
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl host resolves to a private address (${address})`);
188
+ }
189
+ }
190
+ }
191
+ __name(assertHostResolvesPublic, "assertHostResolvesPublic");
192
+ async function resolveImages(images) {
193
+ if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
194
+ if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
195
+ return Promise.all(images.map((ref) => resolveImage({
196
+ imageUrl: ref.url,
197
+ imageBase64: ref.base64
198
+ })));
199
+ }
200
+ __name(resolveImages, "resolveImages");
153
201
  async function resolveImage(input) {
154
202
  if (input.imageUrl && input.imageBase64) {
155
203
  throw new ResolveImageError("INVALID_INPUT", "Provide only one of imageUrl or imageBase64");
@@ -163,6 +211,7 @@ async function resolveImage(input) {
163
211
  }
164
212
  if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
165
213
  if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
214
+ await assertHostResolvesPublic(u.hostname);
166
215
  let res;
167
216
  try {
168
217
  res = await fetch(input.imageUrl, {
@@ -359,7 +408,39 @@ var VideoModelEnum = z.enum([
359
408
  "grok",
360
409
  "kling"
361
410
  ]);
411
+ var ImageCreativeStyleEnum = z.enum([
412
+ "studio",
413
+ "floating",
414
+ "ingredients",
415
+ "in_use"
416
+ ]);
417
+ var ImageCreativeArchetypeEnum = z.enum([
418
+ "direct_response_product_ad",
419
+ "hero_outcome",
420
+ "offer_value_prop",
421
+ "social_proof_trust",
422
+ "product_clarity",
423
+ "problem_solution",
424
+ "lifestyle_identity"
425
+ ]);
426
+ var ImageCreativeVisualTreatmentEnum = z.enum([
427
+ "performance_product_ad",
428
+ "premium_dtc_carousel"
429
+ ]);
430
+ var ImageCreativeAspectRatioEnum = z.enum([
431
+ "1:1",
432
+ "4:5",
433
+ "9:16",
434
+ "16:9"
435
+ ]);
362
436
  var ImageBase64 = z.string().max(75e5);
437
+ var ImageRefSchema = z.object({
438
+ url: z.string().url().optional().describe("URL p\xFAblica HTTPS de la foto."),
439
+ base64: ImageBase64.optional().describe("Bytes de la foto en base64 (sin prefijo data:). \xDAsalo SOLO para archivos locales <500 KB.")
440
+ }).refine((v) => Boolean(v.url) !== Boolean(v.base64), {
441
+ message: "each image entry must have exactly one of `url` or `base64`"
442
+ });
443
+ var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe("1 a 5 fotos del MISMO producto. La PRIMERA es la principal (hero); las dem\xE1s son \xE1ngulos adicionales. Cada entrada usa `url` (preferido \u2014 p\xFAblico HTTPS) o `base64` (solo para archivos locales <500 KB; clientes MCP truncan argumentos >1 MB). PREFIERE siempre `url` si el usuario te dio una. NO mezcles productos distintos en una sola llamada.");
363
444
  function asText(value) {
364
445
  return {
365
446
  content: [
@@ -383,16 +464,14 @@ function flatten(obj) {
383
464
  }
384
465
  __name(flatten, "flatten");
385
466
  async function submitCreative(api, opts) {
386
- const { imageUrl, imageBase64, ...rest } = opts.input;
387
- const img = await resolveImage({
388
- imageUrl,
389
- imageBase64
390
- });
467
+ const { images, ...rest } = opts.input;
468
+ const resolved = await resolveImages(images);
469
+ const parts = resolved.map((img, i) => ({
470
+ ...img,
471
+ filename: `image-${i}.png`
472
+ }));
391
473
  const r = await api.postMultipart(opts.path, {
392
- [opts.fileField]: {
393
- ...img,
394
- filename: "image.png"
395
- }
474
+ images: parts
396
475
  }, flatten(rest));
397
476
  return asText({
398
477
  taskId: r.taskId,
@@ -402,8 +481,94 @@ async function submitCreative(api, opts) {
402
481
  }
403
482
  __name(submitCreative, "submitCreative");
404
483
 
484
+ // src/asset-proxy.ts
485
+ import { createHmac, timingSafeEqual } from "crypto";
486
+ var TOKEN_TTL_SECONDS = 3600;
487
+ var NO_SECRET = "";
488
+ function b64url(buf) {
489
+ return buf.toString("base64url");
490
+ }
491
+ __name(b64url, "b64url");
492
+ var ALLOWED_PROXY_HOSTS = /\.r2\.cloudflarestorage\.com$/i;
493
+ function isAllowedProxyTarget(rawUrl) {
494
+ try {
495
+ const u = new URL(rawUrl);
496
+ if (u.protocol !== "https:") return false;
497
+ return ALLOWED_PROXY_HOSTS.test(u.hostname);
498
+ } catch {
499
+ return false;
500
+ }
501
+ }
502
+ __name(isAllowedProxyTarget, "isAllowedProxyTarget");
503
+ function getProxySecret() {
504
+ const s = process.env.PM_PROXY_SECRET ?? NO_SECRET;
505
+ return s.length >= 32 ? s : NO_SECRET;
506
+ }
507
+ __name(getProxySecret, "getProxySecret");
508
+ function isProxyEnabled() {
509
+ return getProxySecret().length >= 32;
510
+ }
511
+ __name(isProxyEnabled, "isProxyEnabled");
512
+ function mintToken(payload, secret = getProxySecret()) {
513
+ if (secret.length < 32) throw new Error("PM_PROXY_SECRET too short");
514
+ if (!isAllowedProxyTarget(payload.url)) throw new Error("asset URL is outside the proxy allowlist");
515
+ const body = {
516
+ url: payload.url,
517
+ name: payload.name,
518
+ exp: Math.floor(Date.now() / 1e3) + TOKEN_TTL_SECONDS
519
+ };
520
+ const json = Buffer.from(JSON.stringify(body), "utf8");
521
+ const sig = createHmac("sha256", secret).update(json).digest();
522
+ return `${b64url(json)}.${b64url(sig)}`;
523
+ }
524
+ __name(mintToken, "mintToken");
525
+ function buildProxyUrl(opts) {
526
+ const secret = getProxySecret();
527
+ if (!opts.proxyBaseUrl || !secret) return null;
528
+ if (!isAllowedProxyTarget(opts.r2Url)) return null;
529
+ const token = mintToken({
530
+ url: opts.r2Url,
531
+ name: opts.filename
532
+ }, secret);
533
+ return `${opts.proxyBaseUrl.replace(/\/+$/, "")}/assets/${token}/${encodeURIComponent(opts.filename)}`;
534
+ }
535
+ __name(buildProxyUrl, "buildProxyUrl");
536
+
405
537
  // src/tools/task-outputs.ts
406
538
  var WEBAPP_BASE = "https://productmaker.app";
539
+ function proxifyOutputs(outputs, proxyBaseUrl) {
540
+ if (!proxyBaseUrl || !isProxyEnabled()) return outputs;
541
+ const rewrite = /* @__PURE__ */ __name((url, filename) => buildProxyUrl({
542
+ r2Url: url,
543
+ filename,
544
+ proxyBaseUrl
545
+ }) ?? url, "rewrite");
546
+ return {
547
+ webappUrl: outputs.webappUrl,
548
+ images: outputs.images.map((img, i) => ({
549
+ ...img,
550
+ url: rewrite(img.url, `image-${i}.png`)
551
+ })),
552
+ landing: {
553
+ sections: outputs.landing.sections.map((s) => ({
554
+ ...s,
555
+ url: rewrite(s.url, `landing-${sanitizeFilename(s.role)}.png`)
556
+ }))
557
+ },
558
+ videos: outputs.videos.map((v) => ({
559
+ ...v,
560
+ variants: v.variants.map((variant) => ({
561
+ ...variant,
562
+ url: rewrite(variant.url, `video-${sanitizeFilename(v.angleKey)}-${sanitizeFilename(variant.kind)}.mp4`)
563
+ }))
564
+ }))
565
+ };
566
+ }
567
+ __name(proxifyOutputs, "proxifyOutputs");
568
+ function sanitizeFilename(s) {
569
+ return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "asset";
570
+ }
571
+ __name(sanitizeFilename, "sanitizeFilename");
407
572
  async function fetchTaskOutputs(api, taskId) {
408
573
  const raw = await api.getJson(`/v1/results/${encodeURIComponent(taskId)}?signedUrls=true`);
409
574
  const videos = (raw.videoGenerators ?? []).map((vg) => {
@@ -449,7 +614,7 @@ async function fetchTaskOutputs(api, taskId) {
449
614
  }
450
615
  __name(fetchTaskOutputs, "fetchTaskOutputs");
451
616
  function escapeMdLabel(s) {
452
- return s.replace(/[\[\]()]/g, "");
617
+ return s.replace(/[\[\]\(\)\{\}`<>\r\n\\]/g, " ").replace(/\s+/g, " ").trim();
453
618
  }
454
619
  __name(escapeMdLabel, "escapeMdLabel");
455
620
  function renderOutputsMarkdown(outputs) {
@@ -478,13 +643,12 @@ __name(renderOutputsMarkdown, "renderOutputsMarkdown");
478
643
 
479
644
  // src/tools/tasks.ts
480
645
  var CreateProductTaskInput = z2.object({
481
- imageUrl: z2.string().url().optional().describe("URL p\xFAblica de la foto del producto. Provee este O imageBase64 (uno solo). Requerido si no se pasa imageBase64."),
482
- imageBase64: ImageBase64.optional().describe("Imagen del producto en base64 (sin prefijo data:). Provee este O imageUrl (uno solo)."),
646
+ images: ImagesInput,
483
647
  productUrl: z2.string().url().optional().describe("Opcional. URL de la p\xE1gina del producto (Shopify, AliExpress, Amazon, etc.) para enriquecer el contexto. Si se omite, la IA infiere todo desde la imagen."),
484
- language: Lang.describe('C\xF3digo de idioma para el contenido generado. Ejemplos: "es" (espa\xF1ol), "en" (ingl\xE9s), "pt" (portugu\xE9s). Si el usuario no lo especific\xF3, preg\xFAntale o usa "es" como default LATAM.'),
485
- country: Country.describe('C\xF3digo ISO del pa\xEDs objetivo. Ejemplos: "CO" (Colombia), "MX" (M\xE9xico), "US" (Estados Unidos), "AR" (Argentina). Define moneda, m\xE9todos de pago locales y tono. Si el usuario no lo especific\xF3, preg\xFAntale.'),
486
- price: z2.string().optional().describe('Opcional. Precio que se mostrar\xE1 en la landing y video. Cualquier formato libre \u2014 ej. "$89.900", "USD 29.99", "MXN 599". Si se omite, la IA sugiere uno basado en producto+pa\xEDs.'),
487
- offer: z2.string().optional().describe('Opcional. Oferta o promoci\xF3n a destacar. Ej. "2x1", "Env\xEDo gratis hoy", "50% OFF por lanzamiento". Si se omite, la IA decide si a\xF1adir una.'),
648
+ language: Lang.describe('C\xF3digo de idioma para el contenido generado. Ejemplos: "es", "en", "pt". REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas "es" por default.'),
649
+ country: Country.describe('C\xF3digo ISO del pa\xEDs objetivo. Ejemplos: "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago locales y tono. REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat.'),
650
+ price: z2.string().optional().describe('Precio que se mostrar\xE1 en la landing y video. Formato libre \u2014 ej. "$89.900", "USD 29.99", "MXN 599". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si te dice "t\xFA elige", om\xEDtelo y el backend sugiere uno.'),
651
+ offer: z2.string().optional().describe('Oferta o promoci\xF3n a destacar. Ej. "2x1", "Env\xEDo gratis hoy", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si te dice "t\xFA elige", om\xEDtelo.'),
488
652
  videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual del video. "ugc" = creador hablando a c\xE1mara (default, mejor CTR LATAM). "cartoon_3d" = animaci\xF3n 3D. "product_only" = sin actor. "ai_dynamic" = cinematogr\xE1fico AI. Si se omite, default "ugc".'),
489
653
  narrativeStyle: NarrativeStyleEnum.optional().describe('Opcional. Estructura narrativa. "review" = testimonial (default, alta conversi\xF3n). "problem_product_cta" = problema\u2192soluci\xF3n. "product_showcase" = features. "metaphor_product_cta" = met\xE1fora visual. "wearable_showcase" = para ropa/accesorios. Si se omite, default "review".'),
490
654
  videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). "bytedance"/"seedance" = TikTok-style. "kling"/"wan" = alternativos. "infinitalk" = lipsync largo. "grok" = grok-imagine. Si se omite, el sistema escoge seg\xFAn costo/calidad.'),
@@ -518,10 +682,14 @@ var EditTaskDraftInput = z2.object({
518
682
  scriptNotes: z2.string().max(2e3).optional()
519
683
  })
520
684
  });
521
- function registerTaskTools(server, api) {
522
- server.tool("create_product_task", 'Crea una tarea de producto desde una imagen. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: imageUrl o imageBase64 (uno solo), language, country.\n\nINPUTS OPCIONALES (TODOS los de abajo se pueden omitir \u2014 si el usuario no los mencion\xF3, PREG\xDANTALE primero si quiere personalizar precio/oferta/estilo, o llama la tool sin ellos y la IA elige defaults sensatos):\n- price, offer (texto libre del precio y promoci\xF3n)\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo para valores v\xE1lidos)\n- multiAngleVideos (booleano \u2014 generar varios \xE1ngulos en paralelo)\n- outputs (subconjunto de {landing,video,image})\n- productUrl (URL del producto en Shopify/AliExpress/etc.)\n\nNUNCA inventes valores fuera de los enums declarados. Si el usuario pide algo no soportado (ej. "estilo anime"), preg\xFAntale por una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
685
+ function registerTaskTools(server, api, proxyBaseUrl = null) {
686
+ server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo)\n- multiAngleVideos (booleano)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
523
687
  try {
524
- const img = await resolveImage(input);
688
+ const imgs = await resolveImages(input.images);
689
+ const parts = imgs.map((img, i) => ({
690
+ ...img,
691
+ filename: `image-${i}.png`
692
+ }));
525
693
  const fields = flatten({
526
694
  "options.languageCode": input.language,
527
695
  "options.country": input.country,
@@ -535,10 +703,7 @@ function registerTaskTools(server, api) {
535
703
  url: input.productUrl
536
704
  });
537
705
  const r = await api.postMultipart("/v1/ingest", {
538
- image: {
539
- ...img,
540
- filename: "image.png"
541
- }
706
+ images: parts
542
707
  }, fields);
543
708
  return asText({
544
709
  taskId: r.taskId,
@@ -556,7 +721,8 @@ function registerTaskTools(server, api) {
556
721
  const snapshot = await pollStatus(fetcher, input.waitSeconds ?? 0, signal);
557
722
  if (snapshot.status !== "done") return asText(snapshot);
558
723
  try {
559
- const outputs = await fetchTaskOutputs(api, input.taskId);
724
+ const rawOutputs = await fetchTaskOutputs(api, input.taskId);
725
+ const outputs = proxifyOutputs(rawOutputs, proxyBaseUrl);
560
726
  return {
561
727
  content: [
562
728
  {
@@ -610,21 +776,21 @@ __name(registerTaskTools, "registerTaskTools");
610
776
 
611
777
  // src/tools/creatives.ts
612
778
  import { z as z3 } from "zod";
613
- var ImageInputRefs = {
614
- imageUrl: z3.string().url().optional().describe("URL p\xFAblica de la foto del producto. Provee este O imageBase64 (uno solo)."),
615
- imageBase64: ImageBase64.optional().describe("Imagen del producto en base64 (sin prefijo data:). Provee este O imageUrl (uno solo)."),
616
- language: Lang.describe('Idioma del contenido. Ej. "es", "en", "pt". Si el usuario no lo dijo, preg\xFAntale.'),
617
- country: Country.describe('Pa\xEDs objetivo ISO. Ej. "CO", "MX", "US", "AR". Define moneda y tono. Si el usuario no lo dijo, preg\xFAntale.'),
618
- price: z3.string().optional().describe('Opcional. Precio en formato libre \u2014 ej. "$89.900", "USD 29.99". Om\xEDtelo y la IA sugiere uno.'),
619
- offer: z3.string().optional().describe('Opcional. Oferta a destacar \u2014 ej. "2x1", "Env\xEDo gratis", "50% OFF".')
779
+ var CreativeRefs = {
780
+ images: ImagesInput,
781
+ language: Lang.describe('Idioma del contenido. Ej. "es", "en", "pt". REQUERIDO \u2014 si el usuario no lo dijo, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas un default.'),
782
+ country: Country.describe('Pa\xEDs objetivo ISO. Ej. "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago y tono. REQUERIDO \u2014 si el usuario no lo dijo, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas un default.'),
783
+ price: z3.string().optional().describe('Precio en formato libre \u2014 ej. "$89.900", "USD 29.99". Opcional pero MUY IMPACTANTE para conversi\xF3n: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si el usuario te dice "t\xFA elige", omite el campo y la IA del backend sugiere uno.'),
784
+ offer: z3.string().optional().describe('Oferta a destacar \u2014 ej. "2x1", "Env\xEDo gratis", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si el usuario te dice "t\xFA elige", omite el campo.'),
785
+ buyerPersona: z3.string().optional().describe('Opcional. Descripci\xF3n del comprador objetivo. Ej. "Mujeres 35-50, profesionales urbanas". Si se omite, la IA infiere desde el \xE1ngulo y producto.'),
786
+ isCombo: z3.boolean().optional().describe("Opcional. Si true, el creativo presenta el producto como combo/pack. Default false.")
620
787
  };
621
788
  var VideoInput = z3.object({
622
- imageUrl: ImageInputRefs.imageUrl,
623
- imageBase64: ImageInputRefs.imageBase64,
789
+ images: CreativeRefs.images,
624
790
  title: z3.string().min(1).describe('T\xEDtulo corto del producto. Aparece en la primera vista del video. Ej. "Crema Anti-Edad Premium".'),
625
791
  primaryAngle: z3.string().min(1).describe('\xC1ngulo de venta principal (frase del beneficio). Ej. "Reduce arrugas en 14 d\xEDas" o "Para quienes odian las cremas grasosas".'),
626
- language: ImageInputRefs.language,
627
- country: ImageInputRefs.country,
792
+ language: CreativeRefs.language,
793
+ country: CreativeRefs.country,
628
794
  aspectRatio: z3.enum([
629
795
  "9:16",
630
796
  "16:9",
@@ -635,45 +801,54 @@ var VideoInput = z3.object({
635
801
  videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual. "ugc" (creador hablando, default), "cartoon_3d", "product_only", "ai_dynamic".'),
636
802
  videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). Ver lista de valores v\xE1lidos.'),
637
803
  features: z3.array(z3.string()).optional().describe('Opcional. Lista de features/beneficios clave a mencionar. Ej. ["resistente al agua", "bater\xEDa 24h", "carga inal\xE1mbrica"].'),
638
- price: ImageInputRefs.price,
639
- offer: ImageInputRefs.offer,
804
+ price: CreativeRefs.price,
805
+ offer: CreativeRefs.offer,
640
806
  cta: z3.string().optional().describe('Opcional. Call-to-action final del video. Ej. "Compra ahora", "Pide el tuyo hoy", "Link en bio". Si se omite, la IA escribe uno.'),
641
807
  scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion. Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico".')
642
808
  });
809
+ var imageStyleList = ImageCreativeStyleEnum.options.join(" | ");
810
+ var archetypeList = ImageCreativeArchetypeEnum.options.join(" | ");
811
+ var visualTreatmentList = ImageCreativeVisualTreatmentEnum.options.join(" | ");
812
+ var aspectRatioList = ImageCreativeAspectRatioEnum.options.join(" | ");
643
813
  var ImageInput = z3.object({
644
- imageUrl: ImageInputRefs.imageUrl,
645
- imageBase64: ImageInputRefs.imageBase64,
814
+ images: CreativeRefs.images,
646
815
  productTitle: z3.string().min(1).describe('Nombre del producto. Ej. "Crema Anti-Edad Premium".'),
647
816
  primaryAngle: z3.string().min(1).describe('\xC1ngulo de venta principal. Ej. "Reduce arrugas en 14 d\xEDas".'),
648
- language: ImageInputRefs.language,
649
- country: ImageInputRefs.country,
817
+ buyerPersona: CreativeRefs.buyerPersona,
818
+ language: CreativeRefs.language,
819
+ country: CreativeRefs.country,
650
820
  nVariants: z3.number().int().min(1).max(6).optional().describe("Opcional. N\xFAmero de variantes a generar (1-6). Default 3."),
651
821
  imageModel: z3.string().optional().describe('Opcional. Modelo de imagen. Ej. "gpt-image-2", "nano-banana-2". Om\xEDtelo para usar el default optimizado por costo/calidad.'),
652
- imageStyle: z3.string().optional().describe('Opcional. Estilo visual libre. Ej. "minimalista fondo blanco", "lifestyle outdoor", "ne\xF3n cyberpunk".'),
822
+ imageStyle: ImageCreativeStyleEnum.optional().describe(`Opcional. Estilo del shot del producto. Valores: ${imageStyleList}. Si se omite, la IA escoge seg\xFAn el archetype.`),
823
+ creativeArchetype: ImageCreativeArchetypeEnum.optional().describe(`Opcional. Arquetipo CRO de la composici\xF3n. Valores: ${archetypeList}. Si se omite, la IA escoge seg\xFAn \xE1ngulo+oferta (ej. "offer_value_prop" cuando hay descuento, "social_proof_trust" cuando el \xE1ngulo es testimonial).`),
824
+ visualTreatment: ImageCreativeVisualTreatmentEnum.optional().describe(`Opcional. Tratamiento gr\xE1fico. Valores: ${visualTreatmentList} ("performance_product_ad" = dealer-style alto CTR para Meta/TikTok ads; "premium_dtc_carousel" = fotograf\xEDa limpia estilo Allbirds/Glossier). Si se omite, la IA escoge.`),
825
+ styleMix: z3.boolean().optional().describe("Opcional. Si true, las N variantes mezclan distintos imageStyle para A/B test. Si false/omitido, todas comparten estilo. Default false."),
826
+ aspectRatio: ImageCreativeAspectRatioEnum.optional().describe(`Opcional. Relaci\xF3n de aspecto. Valores: ${aspectRatioList}. Default "4:5" (vertical Instagram feed).`),
653
827
  generateAdCopy: z3.boolean().optional().describe("Opcional. Si true, tambi\xE9n genera headline/body/CTA para el ad. Default false."),
828
+ pauseForAngleReview: z3.boolean().optional().describe("Opcional. Si true, pausa despu\xE9s de generar el \xE1ngulo y antes de las im\xE1genes para que el usuario edite v\xEDa edit_task_draft. Default false."),
654
829
  features: z3.array(z3.string()).optional().describe('Opcional. Features/beneficios a destacar visualmente. Ej. ["resistente al agua", "ultra liviano"].'),
655
830
  brandColors: z3.array(z3.string()).optional().describe('Opcional. Paleta de marca en hex. Ej. ["#FF6B35", "#004E89"]. Si se omite, la IA elige seg\xFAn el producto.'),
656
- price: ImageInputRefs.price,
657
- offer: ImageInputRefs.offer
831
+ price: CreativeRefs.price,
832
+ compareAtPrice: z3.string().optional().describe('Opcional. Precio "antes" de la oferta \u2014 solo para im\xE1genes con descuento. Ej. "$129.000" cuando price="$89.900". \xDAtil con creativeArchetype="offer_value_prop".'),
833
+ offer: CreativeRefs.offer,
834
+ isCombo: CreativeRefs.isCombo
658
835
  });
659
836
  var LandingInput = z3.object({
660
- imageUrl: ImageInputRefs.imageUrl,
661
- imageBase64: ImageInputRefs.imageBase64,
662
- language: ImageInputRefs.language,
663
- country: ImageInputRefs.country,
837
+ images: CreativeRefs.images,
838
+ language: CreativeRefs.language,
839
+ country: CreativeRefs.country,
664
840
  salesAngle: z3.string().optional().describe('Opcional. \xC1ngulo de venta principal. Ej. "Reduce arrugas en 14 d\xEDas". Si se omite, la IA lo deduce de la imagen.'),
665
- buyerPersona: z3.string().optional().describe('Opcional. Descripci\xF3n del comprador objetivo. Ej. "Mujeres 35-50, profesionales urbanas". Si se omite, la IA infiere.'),
666
- price: ImageInputRefs.price,
667
- offer: ImageInputRefs.offer,
841
+ buyerPersona: CreativeRefs.buyerPersona,
842
+ price: CreativeRefs.price,
843
+ offer: CreativeRefs.offer,
668
844
  paymentMethods: z3.string().optional().describe('Opcional. M\xE9todos de pago a mostrar (lista libre). Ej. "Tarjeta, PSE, Nequi, Contraentrega". Si se omite, la IA usa los m\xE1s comunes del pa\xEDs.'),
669
- isCombo: z3.boolean().optional().describe("Opcional. Si true, la landing presenta el producto como combo/pack. Default false.")
845
+ isCombo: CreativeRefs.isCombo
670
846
  });
671
847
  function registerCreativeTools(server, api) {
672
- server.tool("generate_video_creative", "Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: imageUrl o imageBase64, title, primaryAngle, language, country, aspectRatio.\n\nOPCIONALES (si el usuario no los mencion\xF3, PREG\xDANTALE antes de llamar \u2014 o invoca sin ellos y la IA decide): durationSeconds, narrativeStyle, videoStyle, videoModel (enums \u2014 usa SOLO valores declarados), features, price, offer, cta, scriptNotes.\n\nNo inventes valores fuera de los enums; si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.", VideoInput.shape, async (input) => {
848
+ server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, narrativeStyle, videoStyle, videoModel, features, cta, scriptNotes.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.', VideoInput.shape, async (input) => {
673
849
  try {
674
850
  return await submitCreative(api, {
675
851
  path: "/v1/creatives/videos",
676
- fileField: "media",
677
852
  estimatedSeconds: 300,
678
853
  input
679
854
  });
@@ -681,11 +856,10 @@ function registerCreativeTools(server, api) {
681
856
  throw translateError(e);
682
857
  }
683
858
  });
684
- server.tool("generate_image_creatives", "Genera N variantes de imagen creativa (ads est\xE1ticos). Devuelve un taskId.\n\nREQUERIDOS: imageUrl o imageBase64, productTitle, primaryAngle, language, country.\n\nOPCIONALES (si el usuario no los mencion\xF3, PREG\xDANTALE \u2014 o invoca sin ellos y la IA decide): nVariants (default 3), imageModel, imageStyle (texto libre), generateAdCopy, features, brandColors (hex), price, offer.", ImageInput.shape, async (input) => {
859
+ server.tool("generate_image_creatives", 'Genera N variantes de imagen creativa (ads est\xE1ticos). Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), productTitle, primaryAngle, language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario:\n- price y offer (se renderizan en la imagen \u2014 nunca los asumas; si dice "t\xFA elige", om\xEDtelos).\n- compareAtPrice si hay descuento (ej. "$129.000" para tachar).\n- creativeArchetype y visualTreatment si quiere un look espec\xEDfico (performance vs premium DTC). Si no le importa, om\xEDtelos.\n\nNO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente.\n\nOTROS OPCIONALES: buyerPersona, nVariants (default 3), imageModel, imageStyle, styleMix, aspectRatio (default 4:5), generateAdCopy, pauseForAngleReview, features, brandColors, isCombo.', ImageInput.shape, async (input) => {
685
860
  try {
686
861
  return await submitCreative(api, {
687
862
  path: "/v1/creatives/images",
688
- fileField: "image",
689
863
  estimatedSeconds: 60,
690
864
  input
691
865
  });
@@ -693,11 +867,10 @@ function registerCreativeTools(server, api) {
693
867
  throw translateError(e);
694
868
  }
695
869
  });
696
- server.tool("generate_landing", "Genera una landing page lista para Shopify. Devuelve un taskId.\n\nREQUERIDOS: imageUrl o imageBase64, language, country.\n\nOPCIONALES (si el usuario no los mencion\xF3, PREG\xDANTALE primero por price/offer/paymentMethods \u2014 son los m\xE1s impactantes para conversi\xF3n \u2014 o invoca sin ellos y la IA elige): salesAngle, buyerPersona, price, offer, paymentMethods (texto libre con m\xE9todos locales del pa\xEDs), isCombo.", LandingInput.shape, async (input) => {
870
+ server.tool("generate_landing", 'Genera una landing page lista para Shopify. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price, offer y paymentMethods si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para esos campos, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults): salesAngle, buyerPersona, isCombo.', LandingInput.shape, async (input) => {
697
871
  try {
698
872
  return await submitCreative(api, {
699
873
  path: "/v1/creatives/landing",
700
- fileField: "image",
701
874
  estimatedSeconds: 60,
702
875
  input
703
876
  });
@@ -781,7 +954,7 @@ function registerPublishTools(server, api) {
781
954
  __name(registerPublishTools, "registerPublishTools");
782
955
 
783
956
  // src/server.ts
784
- function createServer(apiKey, apiBaseUrl) {
957
+ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
785
958
  const server = new McpServer({
786
959
  name: "productmaker",
787
960
  version: MCP_VERSION
@@ -794,7 +967,7 @@ function createServer(apiKey, apiBaseUrl) {
794
967
  }
795
968
  });
796
969
  const api = new ApiClient(apiBaseUrl, apiKey);
797
- registerTaskTools(server, api);
970
+ registerTaskTools(server, api, proxyBaseUrl);
798
971
  registerCreativeTools(server, api);
799
972
  registerConnectionTools(server, api);
800
973
  registerPublishTools(server, api);
@@ -803,20 +976,24 @@ function createServer(apiKey, apiBaseUrl) {
803
976
  __name(createServer, "createServer");
804
977
 
805
978
  // src/validate-api-url.ts
806
- function assertSafeApiBaseUrl(url) {
979
+ function assertSafePublicBaseUrl(envName, url) {
807
980
  let u;
808
981
  try {
809
982
  u = new URL(url);
810
983
  } catch {
811
- throw new Error(`PM_API_URL is not a valid URL: ${url}`);
984
+ throw new Error(`${envName} is not a valid URL: ${url}`);
812
985
  }
813
986
  if (u.protocol !== "https:") {
814
- throw new Error(`PM_API_URL must use https (got ${u.protocol})`);
987
+ throw new Error(`${envName} must use https (got ${u.protocol})`);
815
988
  }
816
989
  if (isPrivateHost(u.hostname)) {
817
- throw new Error(`PM_API_URL must not point to a private/loopback host: ${u.hostname}`);
990
+ throw new Error(`${envName} must not point to a private/loopback host: ${u.hostname}`);
818
991
  }
819
992
  }
993
+ __name(assertSafePublicBaseUrl, "assertSafePublicBaseUrl");
994
+ function assertSafeApiBaseUrl(url) {
995
+ assertSafePublicBaseUrl("PM_API_URL", url);
996
+ }
820
997
  __name(assertSafeApiBaseUrl, "assertSafeApiBaseUrl");
821
998
 
822
999
  // src/stdio.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@productmaker/mcp",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "bin": {