@productmaker/mcp 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +21 -0
  2. package/dist/http.js +561 -147
  3. package/dist/stdio.js +435 -143
  4. package/package.json +1 -1
package/dist/stdio.js CHANGED
@@ -35,14 +35,14 @@ var ApiClient = class {
35
35
  Authorization: `Bearer ${this.apiKey}`
36
36
  };
37
37
  }
38
- async getJson(path) {
39
- const res = await fetch(`${this.baseUrl}${path}`, {
38
+ async getJson(path2) {
39
+ const res = await fetch(`${this.baseUrl}${path2}`, {
40
40
  headers: this.authHeaders()
41
41
  });
42
42
  return this.parse(res);
43
43
  }
44
- async postJson(path, body) {
45
- const res = await fetch(`${this.baseUrl}${path}`, {
44
+ async postJson(path2, body) {
45
+ const res = await fetch(`${this.baseUrl}${path2}`, {
46
46
  method: "POST",
47
47
  headers: {
48
48
  ...this.authHeaders(),
@@ -52,8 +52,8 @@ var ApiClient = class {
52
52
  });
53
53
  return this.parse(res);
54
54
  }
55
- async patchJson(path, body) {
56
- const res = await fetch(`${this.baseUrl}${path}`, {
55
+ async patchJson(path2, body) {
56
+ const res = await fetch(`${this.baseUrl}${path2}`, {
57
57
  method: "PATCH",
58
58
  headers: {
59
59
  ...this.authHeaders(),
@@ -63,27 +63,32 @@ var ApiClient = class {
63
63
  });
64
64
  return this.parse(res);
65
65
  }
66
- async deleteJson(path) {
67
- const res = await fetch(`${this.baseUrl}${path}`, {
66
+ async deleteJson(path2) {
67
+ const res = await fetch(`${this.baseUrl}${path2}`, {
68
68
  method: "DELETE",
69
69
  headers: this.authHeaders()
70
70
  });
71
71
  return this.parse(res);
72
72
  }
73
- async postMultipart(path, files, fields) {
73
+ async postMultipart(path2, 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);
85
90
  }
86
- const res = await fetch(`${this.baseUrl}${path}`, {
91
+ const res = await fetch(`${this.baseUrl}${path2}`, {
87
92
  method: "POST",
88
93
  headers: this.authHeaders(),
89
94
  body: form
@@ -105,18 +110,40 @@ var ApiClient = class {
105
110
  };
106
111
 
107
112
  // src/index.ts
108
- var MCP_VERSION = "0.1.0";
113
+ var MCP_VERSION = "0.2.0";
109
114
 
110
115
  // src/tools/tasks.ts
111
116
  import { z as z2 } from "zod";
112
117
 
113
118
  // src/resolve-image.ts
119
+ import { lookup as dnsLookup } from "dns/promises";
120
+ import { promises as fs, createReadStream } from "fs";
121
+ import * as path from "path";
122
+ import * as os from "os";
114
123
  var MAX_BYTES = 20 * 1024 * 1024;
115
- var ALLOWED = /* @__PURE__ */ new Set([
116
- "image/jpeg",
117
- "image/png",
118
- "image/webp"
119
- ]);
124
+ var IMAGE_FORMATS = [
125
+ {
126
+ mime: "image/jpeg",
127
+ exts: [
128
+ ".jpg",
129
+ ".jpeg"
130
+ ]
131
+ },
132
+ {
133
+ mime: "image/png",
134
+ exts: [
135
+ ".png"
136
+ ]
137
+ },
138
+ {
139
+ mime: "image/webp",
140
+ exts: [
141
+ ".webp"
142
+ ]
143
+ }
144
+ ];
145
+ var ALLOWED_MIMES = new Set(IMAGE_FORMATS.map((f) => f.mime));
146
+ var ALLOWED_EXTS = new Set(IMAGE_FORMATS.flatMap((f) => f.exts));
120
147
  var ResolveImageError = class extends Error {
121
148
  static {
122
149
  __name(this, "ResolveImageError");
@@ -145,64 +172,177 @@ function isPrivateHost(host) {
145
172
  if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
146
173
  if (host.startsWith("169.254.")) return true;
147
174
  if (host === "[::1]" || host === "::1") return true;
175
+ if (/^\[?0*:(?:0*:){6}0*1\]?$/i.test(host)) return true;
176
+ const ipv4Mapped = host.match(/^\[?::ffff:([0-9a-f.:]+)\]?$/i);
177
+ if (ipv4Mapped && ipv4Mapped[1]) {
178
+ const inner = ipv4Mapped[1];
179
+ if (inner.includes(".")) return isPrivateHost(inner);
180
+ const parts = inner.split(":").map((h) => parseInt(h, 16));
181
+ if (parts.length === 2 && parts.every((n) => !Number.isNaN(n))) {
182
+ const bytes = [
183
+ parts[0] >> 8,
184
+ parts[0] & 255,
185
+ parts[1] >> 8,
186
+ parts[1] & 255
187
+ ];
188
+ return isPrivateHost(bytes.join("."));
189
+ }
190
+ }
148
191
  if (/^\[?f[cd][0-9a-f]{2}:/i.test(host)) return true;
149
192
  if (/^\[?fe[89ab][0-9a-f]:/i.test(host)) return true;
150
193
  return false;
151
194
  }
152
195
  __name(isPrivateHost, "isPrivateHost");
153
- async function resolveImage(input) {
154
- if (input.imageUrl && input.imageBase64) {
155
- throw new ResolveImageError("INVALID_INPUT", "Provide only one of imageUrl or imageBase64");
196
+ async function assertHostResolvesPublic(hostname) {
197
+ let records;
198
+ try {
199
+ records = await dnsLookup(hostname, {
200
+ all: true
201
+ });
202
+ } catch {
203
+ if (process.env.NODE_ENV === "test" || process.env.VITEST) return;
204
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl DNS lookup failed for ${hostname}`);
156
205
  }
157
- if (input.imageUrl) {
158
- let u;
159
- try {
160
- u = new URL(input.imageUrl);
161
- } catch {
162
- throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
206
+ for (const { address } of records) {
207
+ if (isPrivateHost(address)) {
208
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl host resolves to a private address (${address})`);
163
209
  }
164
- if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
165
- if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
166
- let res;
167
- try {
168
- res = await fetch(input.imageUrl, {
169
- signal: AbortSignal.timeout(3e4),
170
- redirect: "error"
171
- });
172
- } catch (e) {
173
- throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
210
+ }
211
+ }
212
+ __name(assertHostResolvesPublic, "assertHostResolvesPublic");
213
+ function expandUserPath(raw) {
214
+ let s = raw.trim();
215
+ if (s === "~") return os.homedir();
216
+ if (s.startsWith("~/") || s.startsWith("~\\")) s = path.join(os.homedir(), s.slice(2));
217
+ s = s.replace(/^\$HOME(?=$|[/\\])/, os.homedir());
218
+ s = s.replace(/^%USERPROFILE%(?=$|[/\\])/i, os.homedir());
219
+ return s;
220
+ }
221
+ __name(expandUserPath, "expandUserPath");
222
+ async function readFileWithCap(absPath, max) {
223
+ const chunks = [];
224
+ let total = 0;
225
+ let exceeded = false;
226
+ const stream = createReadStream(absPath);
227
+ try {
228
+ for await (const chunk of stream) {
229
+ total += chunk.length;
230
+ if (total > max) {
231
+ exceeded = true;
232
+ break;
233
+ }
234
+ chunks.push(chunk);
174
235
  }
175
- if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
176
- const cl = res.headers.get("content-length");
177
- if (cl && Number(cl) > MAX_BYTES) {
178
- throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
236
+ } catch (e) {
237
+ const code = e.code;
238
+ if (code === "ENOENT") throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", "file disappeared during read");
239
+ if (code === "EACCES" || code === "EPERM") throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot read file (${code})`);
240
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", e.message);
241
+ } finally {
242
+ if (!stream.destroyed) stream.destroy();
243
+ }
244
+ if (exceeded) throw new ResolveImageError("IMAGE_TOO_LARGE", `file exceeds ${max} bytes`);
245
+ return Buffer.concat(chunks, total);
246
+ }
247
+ __name(readFileWithCap, "readFileWithCap");
248
+ async function resolvePath(rawPath) {
249
+ const expanded = expandUserPath(rawPath);
250
+ const abs = path.resolve(expanded);
251
+ const ext = path.extname(abs).toLowerCase();
252
+ if (!ALLOWED_EXTS.has(ext)) {
253
+ throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `path extension ${ext || "(none)"} is not in the image allowlist (.jpg .jpeg .png .webp)`);
254
+ }
255
+ let stat;
256
+ try {
257
+ stat = await fs.stat(abs);
258
+ } catch (e) {
259
+ const code = e.code;
260
+ if (code === "ENOENT") {
261
+ throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", `no such file: ${abs}`);
179
262
  }
180
- const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
181
- if (!ALLOWED.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
182
- const buf = Buffer.from(await res.arrayBuffer());
183
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
184
- const sniffed = sniffMime(buf);
185
- if (!sniffed || sniffed !== ct) {
186
- throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
263
+ if (code === "EACCES" || code === "EPERM") {
264
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot access ${abs} (${code})`);
187
265
  }
188
- return {
189
- buffer: buf,
190
- mimetype: sniffed
191
- };
266
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `stat failed: ${e.message}`);
192
267
  }
193
- if (input.imageBase64) {
194
- const buf = Buffer.from(input.imageBase64, "base64");
195
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
196
- const mime = sniffMime(buf);
197
- if (!mime) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", "cannot detect image type");
198
- return {
199
- buffer: buf,
200
- mimetype: mime
201
- };
268
+ if (!stat.isFile()) {
269
+ throw new ResolveImageError("IMAGE_PATH_NOT_A_FILE", `${abs} is not a regular file`);
270
+ }
271
+ if (stat.size > MAX_BYTES) {
272
+ throw new ResolveImageError("IMAGE_TOO_LARGE", `${stat.size} bytes (cap ${MAX_BYTES})`);
273
+ }
274
+ const buf = await readFileWithCap(abs, MAX_BYTES);
275
+ const sniffed = sniffMime(buf);
276
+ if (!sniffed) {
277
+ throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes do not match PNG/JPEG/WEBP for ${abs}`);
202
278
  }
203
- throw new ResolveImageError("INVALID_INPUT", "imageUrl or imageBase64 required");
279
+ return {
280
+ buffer: buf,
281
+ mimetype: sniffed
282
+ };
204
283
  }
205
- __name(resolveImage, "resolveImage");
284
+ __name(resolvePath, "resolvePath");
285
+ async function resolveImageUrl(rawUrl) {
286
+ let u;
287
+ try {
288
+ u = new URL(rawUrl);
289
+ } catch {
290
+ throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
291
+ }
292
+ if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
293
+ if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
294
+ await assertHostResolvesPublic(u.hostname);
295
+ let res;
296
+ try {
297
+ res = await fetch(rawUrl, {
298
+ signal: AbortSignal.timeout(3e4),
299
+ redirect: "error"
300
+ });
301
+ } catch (e) {
302
+ throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
303
+ }
304
+ if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
305
+ const cl = res.headers.get("content-length");
306
+ if (cl && Number(cl) > MAX_BYTES) {
307
+ throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
308
+ }
309
+ const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
310
+ if (!ALLOWED_MIMES.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
311
+ const buf = Buffer.from(await res.arrayBuffer());
312
+ if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
313
+ const sniffed = sniffMime(buf);
314
+ if (!sniffed || sniffed !== ct) {
315
+ throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
316
+ }
317
+ return {
318
+ buffer: buf,
319
+ mimetype: sniffed
320
+ };
321
+ }
322
+ __name(resolveImageUrl, "resolveImageUrl");
323
+ async function resolveImages(images, opts = {
324
+ transport: "http"
325
+ }) {
326
+ if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
327
+ if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
328
+ return Promise.all(images.map((ref) => resolveOne(ref, opts.transport)));
329
+ }
330
+ __name(resolveImages, "resolveImages");
331
+ async function resolveOne(ref, transport) {
332
+ const hasUrl = Boolean(ref.url);
333
+ const hasPath = Boolean(ref.path);
334
+ if (hasUrl === hasPath) {
335
+ throw new ResolveImageError("INVALID_INPUT", "each image entry must have exactly one of `url` or `path`");
336
+ }
337
+ if (hasPath) {
338
+ if (transport === "http") {
339
+ throw new ResolveImageError("PATH_NOT_SUPPORTED_IN_HTTP_MODE", "Local file paths are only available when running the MCP locally (npm). For the hosted endpoint at mcp.productmaker.app, pass a public HTTPS `url` instead, or ask the user to host the photo first.");
340
+ }
341
+ return resolvePath(ref.path);
342
+ }
343
+ return resolveImageUrl(ref.url);
344
+ }
345
+ __name(resolveOne, "resolveOne");
206
346
 
207
347
  // src/status-poller.ts
208
348
  var TERMINAL = /* @__PURE__ */ new Set([
@@ -230,14 +370,14 @@ async function pollStatus(fetcher, waitSeconds, signal, opts = {}) {
230
370
  }
231
371
  __name(pollStatus, "pollStatus");
232
372
  function sleep(ms, signal) {
233
- return new Promise((resolve, reject) => {
373
+ return new Promise((resolve2, reject) => {
234
374
  const onAbort = /* @__PURE__ */ __name(() => {
235
375
  clearTimeout(t);
236
376
  reject(new Error("aborted"));
237
377
  }, "onAbort");
238
378
  const t = setTimeout(() => {
239
379
  signal?.removeEventListener("abort", onAbort);
240
- resolve();
380
+ resolve2();
241
381
  }, ms);
242
382
  signal?.addEventListener("abort", onAbort, {
243
383
  once: true
@@ -256,6 +396,10 @@ var McpErrorCode = {
256
396
  IMAGE_DOWNLOAD_FAILED: "IMAGE_DOWNLOAD_FAILED",
257
397
  IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
258
398
  IMAGE_TYPE_UNSUPPORTED: "IMAGE_TYPE_UNSUPPORTED",
399
+ IMAGE_PATH_NOT_FOUND: "IMAGE_PATH_NOT_FOUND",
400
+ IMAGE_PATH_NOT_READABLE: "IMAGE_PATH_NOT_READABLE",
401
+ IMAGE_PATH_NOT_A_FILE: "IMAGE_PATH_NOT_A_FILE",
402
+ PATH_NOT_SUPPORTED_IN_HTTP_MODE: "PATH_NOT_SUPPORTED_IN_HTTP_MODE",
259
403
  TASK_NOT_FOUND: "TASK_NOT_FOUND",
260
404
  TASK_NOT_READY: "TASK_NOT_READY",
261
405
  NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
@@ -269,7 +413,11 @@ var McpErrorCode = {
269
413
  var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
270
414
  McpErrorCode.INVALID_INPUT,
271
415
  McpErrorCode.INVALID_API_KEY,
272
- McpErrorCode.REVOKED_API_KEY
416
+ McpErrorCode.REVOKED_API_KEY,
417
+ McpErrorCode.IMAGE_PATH_NOT_FOUND,
418
+ McpErrorCode.IMAGE_PATH_NOT_READABLE,
419
+ McpErrorCode.IMAGE_PATH_NOT_A_FILE,
420
+ McpErrorCode.PATH_NOT_SUPPORTED_IN_HTTP_MODE
273
421
  ]);
274
422
  function mcp(code, message, extra = {}) {
275
423
  const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
@@ -359,7 +507,38 @@ var VideoModelEnum = z.enum([
359
507
  "grok",
360
508
  "kling"
361
509
  ]);
362
- var ImageBase64 = z.string().max(75e5);
510
+ var ImageCreativeStyleEnum = z.enum([
511
+ "studio",
512
+ "floating",
513
+ "ingredients",
514
+ "in_use"
515
+ ]);
516
+ var ImageCreativeArchetypeEnum = z.enum([
517
+ "direct_response_product_ad",
518
+ "hero_outcome",
519
+ "offer_value_prop",
520
+ "social_proof_trust",
521
+ "product_clarity",
522
+ "problem_solution",
523
+ "lifestyle_identity"
524
+ ]);
525
+ var ImageCreativeVisualTreatmentEnum = z.enum([
526
+ "performance_product_ad",
527
+ "premium_dtc_carousel"
528
+ ]);
529
+ var ImageCreativeAspectRatioEnum = z.enum([
530
+ "1:1",
531
+ "4:5",
532
+ "9:16",
533
+ "16:9"
534
+ ]);
535
+ var ImageRefSchema = z.object({
536
+ url: z.string().url().optional().describe('URL p\xFAblica HTTPS de la foto. Ej: una URL de Shopify CDN, Cloudinary, Imgur. \xDAsala cuando el usuario te diga "est\xE1 en X.com/foto.jpg" o cuando la foto ya est\xE9 hospedada.'),
537
+ path: z.string().min(1).optional().describe('Ruta del archivo en el computador del usuario. Ej: "/Users/maria/Desktop/foto.jpg", "~/Pictures/camiseta.png", "C:\\Users\\Maria\\Desktop\\foto.jpg". El MCP local la lee del disco y la sube. SOLO funciona cuando el MCP corre local (stdio). NO funciona en mcp.productmaker.app (HTTP) \u2014 ah\xED usa `url`.')
538
+ }).refine((v) => Boolean(v.url) !== Boolean(v.path), {
539
+ message: "each image entry must have exactly one of `url` or `path`"
540
+ });
541
+ 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. NO mezcles productos distintos en una sola llamada.\n\nCada entrada tiene EXACTAMENTE uno de:\n\u2022 `url` \u2014 URL p\xFAblica HTTPS (Shopify CDN, Cloudinary). Prefi\xE9rela si la foto ya est\xE1 hospedada.\n\u2022 `path` \u2014 Ruta del archivo en el computador del usuario (ej. "/Users/maria/Desktop/foto.jpg", "~/Downloads/camiseta.png"). \xDAsala cuando el usuario te dijo d\xF3nde guard\xF3 la foto. Solo MCP local \u2014 el cliente web rechaza paths.\n\nSi el usuario te peg\xF3 la foto en el chat pero no te dio ni URL ni ruta: PREG\xDANTALE d\xF3nde la tiene guardada (Escritorio, Descargas, etc.). Si el `path` falla con IMAGE_PATH_NOT_FOUND, prueba 1-2 ubicaciones obvias alternativas (~/Desktop, ~/Downloads) antes de molestar al usuario. Tras 3 intentos fallidos, p\xEDdele la ruta completa.');
363
542
  function asText(value) {
364
543
  return {
365
544
  content: [
@@ -383,16 +562,16 @@ function flatten(obj) {
383
562
  }
384
563
  __name(flatten, "flatten");
385
564
  async function submitCreative(api, opts) {
386
- const { imageUrl, imageBase64, ...rest } = opts.input;
387
- const img = await resolveImage({
388
- imageUrl,
389
- imageBase64
565
+ const { images, ...rest } = opts.input;
566
+ const resolved = await resolveImages(images, {
567
+ transport: opts.transport
390
568
  });
569
+ const parts = resolved.map((img, i) => ({
570
+ ...img,
571
+ filename: `image-${i}.png`
572
+ }));
391
573
  const r = await api.postMultipart(opts.path, {
392
- [opts.fileField]: {
393
- ...img,
394
- filename: "image.png"
395
- }
574
+ images: parts
396
575
  }, flatten(rest));
397
576
  return asText({
398
577
  taskId: r.taskId,
@@ -402,8 +581,94 @@ async function submitCreative(api, opts) {
402
581
  }
403
582
  __name(submitCreative, "submitCreative");
404
583
 
584
+ // src/asset-proxy.ts
585
+ import { createHmac, timingSafeEqual } from "crypto";
586
+ var TOKEN_TTL_SECONDS = 3600;
587
+ var NO_SECRET = "";
588
+ function b64url(buf) {
589
+ return buf.toString("base64url");
590
+ }
591
+ __name(b64url, "b64url");
592
+ var ALLOWED_PROXY_HOSTS = /\.r2\.cloudflarestorage\.com$/i;
593
+ function isAllowedProxyTarget(rawUrl) {
594
+ try {
595
+ const u = new URL(rawUrl);
596
+ if (u.protocol !== "https:") return false;
597
+ return ALLOWED_PROXY_HOSTS.test(u.hostname);
598
+ } catch {
599
+ return false;
600
+ }
601
+ }
602
+ __name(isAllowedProxyTarget, "isAllowedProxyTarget");
603
+ function getProxySecret() {
604
+ const s = process.env.PM_PROXY_SECRET ?? NO_SECRET;
605
+ return s.length >= 32 ? s : NO_SECRET;
606
+ }
607
+ __name(getProxySecret, "getProxySecret");
608
+ function isProxyEnabled() {
609
+ return getProxySecret().length >= 32;
610
+ }
611
+ __name(isProxyEnabled, "isProxyEnabled");
612
+ function mintToken(payload, secret = getProxySecret()) {
613
+ if (secret.length < 32) throw new Error("PM_PROXY_SECRET too short");
614
+ if (!isAllowedProxyTarget(payload.url)) throw new Error("asset URL is outside the proxy allowlist");
615
+ const body = {
616
+ url: payload.url,
617
+ name: payload.name,
618
+ exp: Math.floor(Date.now() / 1e3) + TOKEN_TTL_SECONDS
619
+ };
620
+ const json = Buffer.from(JSON.stringify(body), "utf8");
621
+ const sig = createHmac("sha256", secret).update(json).digest();
622
+ return `${b64url(json)}.${b64url(sig)}`;
623
+ }
624
+ __name(mintToken, "mintToken");
625
+ function buildProxyUrl(opts) {
626
+ const secret = getProxySecret();
627
+ if (!opts.proxyBaseUrl || !secret) return null;
628
+ if (!isAllowedProxyTarget(opts.r2Url)) return null;
629
+ const token = mintToken({
630
+ url: opts.r2Url,
631
+ name: opts.filename
632
+ }, secret);
633
+ return `${opts.proxyBaseUrl.replace(/\/+$/, "")}/assets/${token}/${encodeURIComponent(opts.filename)}`;
634
+ }
635
+ __name(buildProxyUrl, "buildProxyUrl");
636
+
405
637
  // src/tools/task-outputs.ts
406
638
  var WEBAPP_BASE = "https://productmaker.app";
639
+ function proxifyOutputs(outputs, proxyBaseUrl) {
640
+ if (!proxyBaseUrl || !isProxyEnabled()) return outputs;
641
+ const rewrite = /* @__PURE__ */ __name((url, filename) => buildProxyUrl({
642
+ r2Url: url,
643
+ filename,
644
+ proxyBaseUrl
645
+ }) ?? url, "rewrite");
646
+ return {
647
+ webappUrl: outputs.webappUrl,
648
+ images: outputs.images.map((img, i) => ({
649
+ ...img,
650
+ url: rewrite(img.url, `image-${i}.png`)
651
+ })),
652
+ landing: {
653
+ sections: outputs.landing.sections.map((s) => ({
654
+ ...s,
655
+ url: rewrite(s.url, `landing-${sanitizeFilename(s.role)}.png`)
656
+ }))
657
+ },
658
+ videos: outputs.videos.map((v) => ({
659
+ ...v,
660
+ variants: v.variants.map((variant) => ({
661
+ ...variant,
662
+ url: rewrite(variant.url, `video-${sanitizeFilename(v.angleKey)}-${sanitizeFilename(variant.kind)}.mp4`)
663
+ }))
664
+ }))
665
+ };
666
+ }
667
+ __name(proxifyOutputs, "proxifyOutputs");
668
+ function sanitizeFilename(s) {
669
+ return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "asset";
670
+ }
671
+ __name(sanitizeFilename, "sanitizeFilename");
407
672
  async function fetchTaskOutputs(api, taskId) {
408
673
  const raw = await api.getJson(`/v1/results/${encodeURIComponent(taskId)}?signedUrls=true`);
409
674
  const videos = (raw.videoGenerators ?? []).map((vg) => {
@@ -449,7 +714,7 @@ async function fetchTaskOutputs(api, taskId) {
449
714
  }
450
715
  __name(fetchTaskOutputs, "fetchTaskOutputs");
451
716
  function escapeMdLabel(s) {
452
- return s.replace(/[\[\]()]/g, "");
717
+ return s.replace(/[\[\]\(\)\{\}`<>\r\n\\]/g, " ").replace(/\s+/g, " ").trim();
453
718
  }
454
719
  __name(escapeMdLabel, "escapeMdLabel");
455
720
  function renderOutputsMarkdown(outputs) {
@@ -478,13 +743,12 @@ __name(renderOutputsMarkdown, "renderOutputsMarkdown");
478
743
 
479
744
  // src/tools/tasks.ts
480
745
  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)."),
746
+ images: ImagesInput,
483
747
  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.'),
748
+ 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.'),
749
+ 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.'),
750
+ 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.'),
751
+ 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
752
  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
753
  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
754
  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 +782,16 @@ var EditTaskDraftInput = z2.object({
518
782
  scriptNotes: z2.string().max(2e3).optional()
519
783
  })
520
784
  });
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) => {
785
+ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
786
+ 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
787
  try {
524
- const img = await resolveImage(input);
788
+ const imgs = await resolveImages(input.images, {
789
+ transport
790
+ });
791
+ const parts = imgs.map((img, i) => ({
792
+ ...img,
793
+ filename: `image-${i}.png`
794
+ }));
525
795
  const fields = flatten({
526
796
  "options.languageCode": input.language,
527
797
  "options.country": input.country,
@@ -535,10 +805,7 @@ function registerTaskTools(server, api) {
535
805
  url: input.productUrl
536
806
  });
537
807
  const r = await api.postMultipart("/v1/ingest", {
538
- image: {
539
- ...img,
540
- filename: "image.png"
541
- }
808
+ images: parts
542
809
  }, fields);
543
810
  return asText({
544
811
  taskId: r.taskId,
@@ -556,7 +823,8 @@ function registerTaskTools(server, api) {
556
823
  const snapshot = await pollStatus(fetcher, input.waitSeconds ?? 0, signal);
557
824
  if (snapshot.status !== "done") return asText(snapshot);
558
825
  try {
559
- const outputs = await fetchTaskOutputs(api, input.taskId);
826
+ const rawOutputs = await fetchTaskOutputs(api, input.taskId);
827
+ const outputs = proxifyOutputs(rawOutputs, proxyBaseUrl);
560
828
  return {
561
829
  content: [
562
830
  {
@@ -588,8 +856,8 @@ function registerTaskTools(server, api) {
588
856
  if (input.limit != null) qs.set("limit", String(input.limit));
589
857
  if (input.offset != null) qs.set("offset", String(input.offset));
590
858
  if (input.search) qs.set("search", input.search);
591
- const path = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
592
- const r = await api.getJson(path);
859
+ const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
860
+ const r = await api.getJson(path2);
593
861
  return asText(r);
594
862
  } catch (e) {
595
863
  throw translateError(e);
@@ -610,21 +878,21 @@ __name(registerTaskTools, "registerTaskTools");
610
878
 
611
879
  // src/tools/creatives.ts
612
880
  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".')
881
+ var CreativeRefs = {
882
+ images: ImagesInput,
883
+ 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.'),
884
+ 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.'),
885
+ 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.'),
886
+ 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.'),
887
+ 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.'),
888
+ isCombo: z3.boolean().optional().describe("Opcional. Si true, el creativo presenta el producto como combo/pack. Default false.")
620
889
  };
621
890
  var VideoInput = z3.object({
622
- imageUrl: ImageInputRefs.imageUrl,
623
- imageBase64: ImageInputRefs.imageBase64,
891
+ images: CreativeRefs.images,
624
892
  title: z3.string().min(1).describe('T\xEDtulo corto del producto. Aparece en la primera vista del video. Ej. "Crema Anti-Edad Premium".'),
625
893
  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,
894
+ language: CreativeRefs.language,
895
+ country: CreativeRefs.country,
628
896
  aspectRatio: z3.enum([
629
897
  "9:16",
630
898
  "16:9",
@@ -635,71 +903,81 @@ var VideoInput = z3.object({
635
903
  videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual. "ugc" (creador hablando, default), "cartoon_3d", "product_only", "ai_dynamic".'),
636
904
  videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). Ver lista de valores v\xE1lidos.'),
637
905
  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,
906
+ price: CreativeRefs.price,
907
+ offer: CreativeRefs.offer,
640
908
  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
909
  scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion. Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico".')
642
910
  });
911
+ var imageStyleList = ImageCreativeStyleEnum.options.join(" | ");
912
+ var archetypeList = ImageCreativeArchetypeEnum.options.join(" | ");
913
+ var visualTreatmentList = ImageCreativeVisualTreatmentEnum.options.join(" | ");
914
+ var aspectRatioList = ImageCreativeAspectRatioEnum.options.join(" | ");
643
915
  var ImageInput = z3.object({
644
- imageUrl: ImageInputRefs.imageUrl,
645
- imageBase64: ImageInputRefs.imageBase64,
916
+ images: CreativeRefs.images,
646
917
  productTitle: z3.string().min(1).describe('Nombre del producto. Ej. "Crema Anti-Edad Premium".'),
647
918
  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,
919
+ buyerPersona: CreativeRefs.buyerPersona,
920
+ language: CreativeRefs.language,
921
+ country: CreativeRefs.country,
650
922
  nVariants: z3.number().int().min(1).max(6).optional().describe("Opcional. N\xFAmero de variantes a generar (1-6). Default 3."),
651
923
  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".'),
924
+ imageStyle: ImageCreativeStyleEnum.optional().describe(`Opcional. Estilo del shot del producto. Valores: ${imageStyleList}. Si se omite, la IA escoge seg\xFAn el archetype.`),
925
+ 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).`),
926
+ 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.`),
927
+ 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."),
928
+ aspectRatio: ImageCreativeAspectRatioEnum.optional().describe(`Opcional. Relaci\xF3n de aspecto. Valores: ${aspectRatioList}. Default "4:5" (vertical Instagram feed).`),
653
929
  generateAdCopy: z3.boolean().optional().describe("Opcional. Si true, tambi\xE9n genera headline/body/CTA para el ad. Default false."),
930
+ 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
931
  features: z3.array(z3.string()).optional().describe('Opcional. Features/beneficios a destacar visualmente. Ej. ["resistente al agua", "ultra liviano"].'),
655
932
  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
933
+ price: CreativeRefs.price,
934
+ 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".'),
935
+ offer: CreativeRefs.offer,
936
+ isCombo: CreativeRefs.isCombo
658
937
  });
659
938
  var LandingInput = z3.object({
660
- imageUrl: ImageInputRefs.imageUrl,
661
- imageBase64: ImageInputRefs.imageBase64,
662
- language: ImageInputRefs.language,
663
- country: ImageInputRefs.country,
939
+ images: CreativeRefs.images,
940
+ language: CreativeRefs.language,
941
+ country: CreativeRefs.country,
664
942
  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,
943
+ buyerPersona: CreativeRefs.buyerPersona,
944
+ price: CreativeRefs.price,
945
+ offer: CreativeRefs.offer,
668
946
  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.")
947
+ isCombo: CreativeRefs.isCombo
670
948
  });
671
- 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) => {
949
+ function registerCreativeTools(server, api, { transport = "http" } = {}) {
950
+ 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
951
  try {
674
952
  return await submitCreative(api, {
675
953
  path: "/v1/creatives/videos",
676
- fileField: "media",
677
954
  estimatedSeconds: 300,
678
- input
955
+ input,
956
+ transport
679
957
  });
680
958
  } catch (e) {
681
959
  throw translateError(e);
682
960
  }
683
961
  });
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) => {
962
+ 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
963
  try {
686
964
  return await submitCreative(api, {
687
965
  path: "/v1/creatives/images",
688
- fileField: "image",
689
966
  estimatedSeconds: 60,
690
- input
967
+ input,
968
+ transport
691
969
  });
692
970
  } catch (e) {
693
971
  throw translateError(e);
694
972
  }
695
973
  });
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) => {
974
+ 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
975
  try {
698
976
  return await submitCreative(api, {
699
977
  path: "/v1/creatives/landing",
700
- fileField: "image",
701
978
  estimatedSeconds: 60,
702
- input
979
+ input,
980
+ transport
703
981
  });
704
982
  } catch (e) {
705
983
  throw translateError(e);
@@ -781,7 +1059,8 @@ function registerPublishTools(server, api) {
781
1059
  __name(registerPublishTools, "registerPublishTools");
782
1060
 
783
1061
  // src/server.ts
784
- function createServer(apiKey, apiBaseUrl) {
1062
+ function createServer(opts) {
1063
+ const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
785
1064
  const server = new McpServer({
786
1065
  name: "productmaker",
787
1066
  version: MCP_VERSION
@@ -794,8 +1073,13 @@ function createServer(apiKey, apiBaseUrl) {
794
1073
  }
795
1074
  });
796
1075
  const api = new ApiClient(apiBaseUrl, apiKey);
797
- registerTaskTools(server, api);
798
- registerCreativeTools(server, api);
1076
+ registerTaskTools(server, api, {
1077
+ proxyBaseUrl,
1078
+ transport
1079
+ });
1080
+ registerCreativeTools(server, api, {
1081
+ transport
1082
+ });
799
1083
  registerConnectionTools(server, api);
800
1084
  registerPublishTools(server, api);
801
1085
  return server;
@@ -803,20 +1087,24 @@ function createServer(apiKey, apiBaseUrl) {
803
1087
  __name(createServer, "createServer");
804
1088
 
805
1089
  // src/validate-api-url.ts
806
- function assertSafeApiBaseUrl(url) {
1090
+ function assertSafePublicBaseUrl(envName, url) {
807
1091
  let u;
808
1092
  try {
809
1093
  u = new URL(url);
810
1094
  } catch {
811
- throw new Error(`PM_API_URL is not a valid URL: ${url}`);
1095
+ throw new Error(`${envName} is not a valid URL: ${url}`);
812
1096
  }
813
1097
  if (u.protocol !== "https:") {
814
- throw new Error(`PM_API_URL must use https (got ${u.protocol})`);
1098
+ throw new Error(`${envName} must use https (got ${u.protocol})`);
815
1099
  }
816
1100
  if (isPrivateHost(u.hostname)) {
817
- throw new Error(`PM_API_URL must not point to a private/loopback host: ${u.hostname}`);
1101
+ throw new Error(`${envName} must not point to a private/loopback host: ${u.hostname}`);
818
1102
  }
819
1103
  }
1104
+ __name(assertSafePublicBaseUrl, "assertSafePublicBaseUrl");
1105
+ function assertSafeApiBaseUrl(url) {
1106
+ assertSafePublicBaseUrl("PM_API_URL", url);
1107
+ }
820
1108
  __name(assertSafeApiBaseUrl, "assertSafeApiBaseUrl");
821
1109
 
822
1110
  // src/stdio.ts
@@ -833,7 +1121,11 @@ async function main() {
833
1121
  console.error(e.message);
834
1122
  process.exit(1);
835
1123
  }
836
- const server = createServer(apiKey, apiBaseUrl);
1124
+ const server = createServer({
1125
+ apiKey,
1126
+ apiBaseUrl,
1127
+ transport: "stdio"
1128
+ });
837
1129
  await server.connect(new StdioServerTransport());
838
1130
  }
839
1131
  __name(main, "main");