@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/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
@@ -36,14 +38,14 @@ var ApiClient = class {
36
38
  Authorization: `Bearer ${this.apiKey}`
37
39
  };
38
40
  }
39
- async getJson(path) {
40
- const res = await fetch(`${this.baseUrl}${path}`, {
41
+ async getJson(path2) {
42
+ const res = await fetch(`${this.baseUrl}${path2}`, {
41
43
  headers: this.authHeaders()
42
44
  });
43
45
  return this.parse(res);
44
46
  }
45
- async postJson(path, body) {
46
- const res = await fetch(`${this.baseUrl}${path}`, {
47
+ async postJson(path2, body) {
48
+ const res = await fetch(`${this.baseUrl}${path2}`, {
47
49
  method: "POST",
48
50
  headers: {
49
51
  ...this.authHeaders(),
@@ -53,8 +55,8 @@ var ApiClient = class {
53
55
  });
54
56
  return this.parse(res);
55
57
  }
56
- async patchJson(path, body) {
57
- const res = await fetch(`${this.baseUrl}${path}`, {
58
+ async patchJson(path2, body) {
59
+ const res = await fetch(`${this.baseUrl}${path2}`, {
58
60
  method: "PATCH",
59
61
  headers: {
60
62
  ...this.authHeaders(),
@@ -64,27 +66,32 @@ var ApiClient = class {
64
66
  });
65
67
  return this.parse(res);
66
68
  }
67
- async deleteJson(path) {
68
- const res = await fetch(`${this.baseUrl}${path}`, {
69
+ async deleteJson(path2) {
70
+ const res = await fetch(`${this.baseUrl}${path2}`, {
69
71
  method: "DELETE",
70
72
  headers: this.authHeaders()
71
73
  });
72
74
  return this.parse(res);
73
75
  }
74
- async postMultipart(path, files, fields) {
76
+ async postMultipart(path2, 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);
86
93
  }
87
- const res = await fetch(`${this.baseUrl}${path}`, {
94
+ const res = await fetch(`${this.baseUrl}${path2}`, {
88
95
  method: "POST",
89
96
  headers: this.authHeaders(),
90
97
  body: form
@@ -106,18 +113,40 @@ var ApiClient = class {
106
113
  };
107
114
 
108
115
  // src/index.ts
109
- var MCP_VERSION = "0.1.0";
116
+ var MCP_VERSION = "0.2.0";
110
117
 
111
118
  // src/tools/tasks.ts
112
119
  import { z as z2 } from "zod";
113
120
 
114
121
  // src/resolve-image.ts
122
+ import { lookup as dnsLookup } from "dns/promises";
123
+ import { promises as fs, createReadStream } from "fs";
124
+ import * as path from "path";
125
+ import * as os from "os";
115
126
  var MAX_BYTES = 20 * 1024 * 1024;
116
- var ALLOWED = /* @__PURE__ */ new Set([
117
- "image/jpeg",
118
- "image/png",
119
- "image/webp"
120
- ]);
127
+ var IMAGE_FORMATS = [
128
+ {
129
+ mime: "image/jpeg",
130
+ exts: [
131
+ ".jpg",
132
+ ".jpeg"
133
+ ]
134
+ },
135
+ {
136
+ mime: "image/png",
137
+ exts: [
138
+ ".png"
139
+ ]
140
+ },
141
+ {
142
+ mime: "image/webp",
143
+ exts: [
144
+ ".webp"
145
+ ]
146
+ }
147
+ ];
148
+ var ALLOWED_MIMES = new Set(IMAGE_FORMATS.map((f) => f.mime));
149
+ var ALLOWED_EXTS = new Set(IMAGE_FORMATS.flatMap((f) => f.exts));
121
150
  var ResolveImageError = class extends Error {
122
151
  static {
123
152
  __name(this, "ResolveImageError");
@@ -146,64 +175,177 @@ function isPrivateHost(host) {
146
175
  if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
147
176
  if (host.startsWith("169.254.")) return true;
148
177
  if (host === "[::1]" || host === "::1") return true;
178
+ if (/^\[?0*:(?:0*:){6}0*1\]?$/i.test(host)) return true;
179
+ const ipv4Mapped = host.match(/^\[?::ffff:([0-9a-f.:]+)\]?$/i);
180
+ if (ipv4Mapped && ipv4Mapped[1]) {
181
+ const inner = ipv4Mapped[1];
182
+ if (inner.includes(".")) return isPrivateHost(inner);
183
+ const parts = inner.split(":").map((h) => parseInt(h, 16));
184
+ if (parts.length === 2 && parts.every((n) => !Number.isNaN(n))) {
185
+ const bytes = [
186
+ parts[0] >> 8,
187
+ parts[0] & 255,
188
+ parts[1] >> 8,
189
+ parts[1] & 255
190
+ ];
191
+ return isPrivateHost(bytes.join("."));
192
+ }
193
+ }
149
194
  if (/^\[?f[cd][0-9a-f]{2}:/i.test(host)) return true;
150
195
  if (/^\[?fe[89ab][0-9a-f]:/i.test(host)) return true;
151
196
  return false;
152
197
  }
153
198
  __name(isPrivateHost, "isPrivateHost");
154
- async function resolveImage(input) {
155
- if (input.imageUrl && input.imageBase64) {
156
- throw new ResolveImageError("INVALID_INPUT", "Provide only one of imageUrl or imageBase64");
199
+ async function assertHostResolvesPublic(hostname) {
200
+ let records;
201
+ try {
202
+ records = await dnsLookup(hostname, {
203
+ all: true
204
+ });
205
+ } catch {
206
+ if (process.env.NODE_ENV === "test" || process.env.VITEST) return;
207
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl DNS lookup failed for ${hostname}`);
157
208
  }
158
- if (input.imageUrl) {
159
- let u;
160
- try {
161
- u = new URL(input.imageUrl);
162
- } catch {
163
- throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
209
+ for (const { address } of records) {
210
+ if (isPrivateHost(address)) {
211
+ throw new ResolveImageError("INVALID_INPUT", `imageUrl host resolves to a private address (${address})`);
164
212
  }
165
- if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
166
- if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
167
- let res;
168
- try {
169
- res = await fetch(input.imageUrl, {
170
- signal: AbortSignal.timeout(3e4),
171
- redirect: "error"
172
- });
173
- } catch (e) {
174
- throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
213
+ }
214
+ }
215
+ __name(assertHostResolvesPublic, "assertHostResolvesPublic");
216
+ function expandUserPath(raw) {
217
+ let s = raw.trim();
218
+ if (s === "~") return os.homedir();
219
+ if (s.startsWith("~/") || s.startsWith("~\\")) s = path.join(os.homedir(), s.slice(2));
220
+ s = s.replace(/^\$HOME(?=$|[/\\])/, os.homedir());
221
+ s = s.replace(/^%USERPROFILE%(?=$|[/\\])/i, os.homedir());
222
+ return s;
223
+ }
224
+ __name(expandUserPath, "expandUserPath");
225
+ async function readFileWithCap(absPath, max) {
226
+ const chunks = [];
227
+ let total = 0;
228
+ let exceeded = false;
229
+ const stream = createReadStream(absPath);
230
+ try {
231
+ for await (const chunk of stream) {
232
+ total += chunk.length;
233
+ if (total > max) {
234
+ exceeded = true;
235
+ break;
236
+ }
237
+ chunks.push(chunk);
175
238
  }
176
- if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
177
- const cl = res.headers.get("content-length");
178
- if (cl && Number(cl) > MAX_BYTES) {
179
- throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
239
+ } catch (e) {
240
+ const code = e.code;
241
+ if (code === "ENOENT") throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", "file disappeared during read");
242
+ if (code === "EACCES" || code === "EPERM") throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot read file (${code})`);
243
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", e.message);
244
+ } finally {
245
+ if (!stream.destroyed) stream.destroy();
246
+ }
247
+ if (exceeded) throw new ResolveImageError("IMAGE_TOO_LARGE", `file exceeds ${max} bytes`);
248
+ return Buffer.concat(chunks, total);
249
+ }
250
+ __name(readFileWithCap, "readFileWithCap");
251
+ async function resolvePath(rawPath) {
252
+ const expanded = expandUserPath(rawPath);
253
+ const abs = path.resolve(expanded);
254
+ const ext = path.extname(abs).toLowerCase();
255
+ if (!ALLOWED_EXTS.has(ext)) {
256
+ throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `path extension ${ext || "(none)"} is not in the image allowlist (.jpg .jpeg .png .webp)`);
257
+ }
258
+ let stat;
259
+ try {
260
+ stat = await fs.stat(abs);
261
+ } catch (e) {
262
+ const code = e.code;
263
+ if (code === "ENOENT") {
264
+ throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", `no such file: ${abs}`);
180
265
  }
181
- const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
182
- if (!ALLOWED.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
183
- const buf = Buffer.from(await res.arrayBuffer());
184
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
185
- const sniffed = sniffMime(buf);
186
- if (!sniffed || sniffed !== ct) {
187
- throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
266
+ if (code === "EACCES" || code === "EPERM") {
267
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot access ${abs} (${code})`);
188
268
  }
189
- return {
190
- buffer: buf,
191
- mimetype: sniffed
192
- };
269
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `stat failed: ${e.message}`);
193
270
  }
194
- if (input.imageBase64) {
195
- const buf = Buffer.from(input.imageBase64, "base64");
196
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
197
- const mime = sniffMime(buf);
198
- if (!mime) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", "cannot detect image type");
199
- return {
200
- buffer: buf,
201
- mimetype: mime
202
- };
271
+ if (!stat.isFile()) {
272
+ throw new ResolveImageError("IMAGE_PATH_NOT_A_FILE", `${abs} is not a regular file`);
273
+ }
274
+ if (stat.size > MAX_BYTES) {
275
+ throw new ResolveImageError("IMAGE_TOO_LARGE", `${stat.size} bytes (cap ${MAX_BYTES})`);
276
+ }
277
+ const buf = await readFileWithCap(abs, MAX_BYTES);
278
+ const sniffed = sniffMime(buf);
279
+ if (!sniffed) {
280
+ throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes do not match PNG/JPEG/WEBP for ${abs}`);
203
281
  }
204
- throw new ResolveImageError("INVALID_INPUT", "imageUrl or imageBase64 required");
282
+ return {
283
+ buffer: buf,
284
+ mimetype: sniffed
285
+ };
205
286
  }
206
- __name(resolveImage, "resolveImage");
287
+ __name(resolvePath, "resolvePath");
288
+ async function resolveImageUrl(rawUrl) {
289
+ let u;
290
+ try {
291
+ u = new URL(rawUrl);
292
+ } catch {
293
+ throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
294
+ }
295
+ if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
296
+ if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
297
+ await assertHostResolvesPublic(u.hostname);
298
+ let res;
299
+ try {
300
+ res = await fetch(rawUrl, {
301
+ signal: AbortSignal.timeout(3e4),
302
+ redirect: "error"
303
+ });
304
+ } catch (e) {
305
+ throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
306
+ }
307
+ if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
308
+ const cl = res.headers.get("content-length");
309
+ if (cl && Number(cl) > MAX_BYTES) {
310
+ throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
311
+ }
312
+ const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
313
+ if (!ALLOWED_MIMES.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
314
+ const buf = Buffer.from(await res.arrayBuffer());
315
+ if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
316
+ const sniffed = sniffMime(buf);
317
+ if (!sniffed || sniffed !== ct) {
318
+ throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
319
+ }
320
+ return {
321
+ buffer: buf,
322
+ mimetype: sniffed
323
+ };
324
+ }
325
+ __name(resolveImageUrl, "resolveImageUrl");
326
+ async function resolveImages(images, opts = {
327
+ transport: "http"
328
+ }) {
329
+ if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
330
+ if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
331
+ return Promise.all(images.map((ref) => resolveOne(ref, opts.transport)));
332
+ }
333
+ __name(resolveImages, "resolveImages");
334
+ async function resolveOne(ref, transport) {
335
+ const hasUrl = Boolean(ref.url);
336
+ const hasPath = Boolean(ref.path);
337
+ if (hasUrl === hasPath) {
338
+ throw new ResolveImageError("INVALID_INPUT", "each image entry must have exactly one of `url` or `path`");
339
+ }
340
+ if (hasPath) {
341
+ if (transport === "http") {
342
+ 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.");
343
+ }
344
+ return resolvePath(ref.path);
345
+ }
346
+ return resolveImageUrl(ref.url);
347
+ }
348
+ __name(resolveOne, "resolveOne");
207
349
 
208
350
  // src/status-poller.ts
209
351
  var TERMINAL = /* @__PURE__ */ new Set([
@@ -231,14 +373,14 @@ async function pollStatus(fetcher, waitSeconds, signal, opts = {}) {
231
373
  }
232
374
  __name(pollStatus, "pollStatus");
233
375
  function sleep(ms, signal) {
234
- return new Promise((resolve, reject) => {
376
+ return new Promise((resolve2, reject) => {
235
377
  const onAbort = /* @__PURE__ */ __name(() => {
236
378
  clearTimeout(t);
237
379
  reject(new Error("aborted"));
238
380
  }, "onAbort");
239
381
  const t = setTimeout(() => {
240
382
  signal?.removeEventListener("abort", onAbort);
241
- resolve();
383
+ resolve2();
242
384
  }, ms);
243
385
  signal?.addEventListener("abort", onAbort, {
244
386
  once: true
@@ -257,6 +399,10 @@ var McpErrorCode = {
257
399
  IMAGE_DOWNLOAD_FAILED: "IMAGE_DOWNLOAD_FAILED",
258
400
  IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
259
401
  IMAGE_TYPE_UNSUPPORTED: "IMAGE_TYPE_UNSUPPORTED",
402
+ IMAGE_PATH_NOT_FOUND: "IMAGE_PATH_NOT_FOUND",
403
+ IMAGE_PATH_NOT_READABLE: "IMAGE_PATH_NOT_READABLE",
404
+ IMAGE_PATH_NOT_A_FILE: "IMAGE_PATH_NOT_A_FILE",
405
+ PATH_NOT_SUPPORTED_IN_HTTP_MODE: "PATH_NOT_SUPPORTED_IN_HTTP_MODE",
260
406
  TASK_NOT_FOUND: "TASK_NOT_FOUND",
261
407
  TASK_NOT_READY: "TASK_NOT_READY",
262
408
  NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
@@ -270,7 +416,11 @@ var McpErrorCode = {
270
416
  var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
271
417
  McpErrorCode.INVALID_INPUT,
272
418
  McpErrorCode.INVALID_API_KEY,
273
- McpErrorCode.REVOKED_API_KEY
419
+ McpErrorCode.REVOKED_API_KEY,
420
+ McpErrorCode.IMAGE_PATH_NOT_FOUND,
421
+ McpErrorCode.IMAGE_PATH_NOT_READABLE,
422
+ McpErrorCode.IMAGE_PATH_NOT_A_FILE,
423
+ McpErrorCode.PATH_NOT_SUPPORTED_IN_HTTP_MODE
274
424
  ]);
275
425
  function mcp(code, message, extra = {}) {
276
426
  const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
@@ -360,7 +510,38 @@ var VideoModelEnum = z.enum([
360
510
  "grok",
361
511
  "kling"
362
512
  ]);
363
- var ImageBase64 = z.string().max(75e5);
513
+ var ImageCreativeStyleEnum = z.enum([
514
+ "studio",
515
+ "floating",
516
+ "ingredients",
517
+ "in_use"
518
+ ]);
519
+ var ImageCreativeArchetypeEnum = z.enum([
520
+ "direct_response_product_ad",
521
+ "hero_outcome",
522
+ "offer_value_prop",
523
+ "social_proof_trust",
524
+ "product_clarity",
525
+ "problem_solution",
526
+ "lifestyle_identity"
527
+ ]);
528
+ var ImageCreativeVisualTreatmentEnum = z.enum([
529
+ "performance_product_ad",
530
+ "premium_dtc_carousel"
531
+ ]);
532
+ var ImageCreativeAspectRatioEnum = z.enum([
533
+ "1:1",
534
+ "4:5",
535
+ "9:16",
536
+ "16:9"
537
+ ]);
538
+ var ImageRefSchema = z.object({
539
+ 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.'),
540
+ 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`.')
541
+ }).refine((v) => Boolean(v.url) !== Boolean(v.path), {
542
+ message: "each image entry must have exactly one of `url` or `path`"
543
+ });
544
+ 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.');
364
545
  function asText(value) {
365
546
  return {
366
547
  content: [
@@ -384,16 +565,16 @@ function flatten(obj) {
384
565
  }
385
566
  __name(flatten, "flatten");
386
567
  async function submitCreative(api, opts) {
387
- const { imageUrl, imageBase64, ...rest } = opts.input;
388
- const img = await resolveImage({
389
- imageUrl,
390
- imageBase64
568
+ const { images, ...rest } = opts.input;
569
+ const resolved = await resolveImages(images, {
570
+ transport: opts.transport
391
571
  });
572
+ const parts = resolved.map((img, i) => ({
573
+ ...img,
574
+ filename: `image-${i}.png`
575
+ }));
392
576
  const r = await api.postMultipart(opts.path, {
393
- [opts.fileField]: {
394
- ...img,
395
- filename: "image.png"
396
- }
577
+ images: parts
397
578
  }, flatten(rest));
398
579
  return asText({
399
580
  taskId: r.taskId,
@@ -403,8 +584,135 @@ async function submitCreative(api, opts) {
403
584
  }
404
585
  __name(submitCreative, "submitCreative");
405
586
 
587
+ // src/asset-proxy.ts
588
+ import { createHmac, timingSafeEqual } from "crypto";
589
+ var TOKEN_TTL_SECONDS = 3600;
590
+ var NO_SECRET = "";
591
+ function b64url(buf) {
592
+ return buf.toString("base64url");
593
+ }
594
+ __name(b64url, "b64url");
595
+ function fromB64url(s) {
596
+ return Buffer.from(s, "base64url");
597
+ }
598
+ __name(fromB64url, "fromB64url");
599
+ var ALLOWED_PROXY_HOSTS = /\.r2\.cloudflarestorage\.com$/i;
600
+ function isAllowedProxyTarget(rawUrl) {
601
+ try {
602
+ const u = new URL(rawUrl);
603
+ if (u.protocol !== "https:") return false;
604
+ return ALLOWED_PROXY_HOSTS.test(u.hostname);
605
+ } catch {
606
+ return false;
607
+ }
608
+ }
609
+ __name(isAllowedProxyTarget, "isAllowedProxyTarget");
610
+ function getProxySecret() {
611
+ const s = process.env.PM_PROXY_SECRET ?? NO_SECRET;
612
+ return s.length >= 32 ? s : NO_SECRET;
613
+ }
614
+ __name(getProxySecret, "getProxySecret");
615
+ function isProxyEnabled() {
616
+ return getProxySecret().length >= 32;
617
+ }
618
+ __name(isProxyEnabled, "isProxyEnabled");
619
+ var FILENAME_SAFE = /[^a-zA-Z0-9._-]+/g;
620
+ function sanitizeContentDispositionFilename(name) {
621
+ return name.replace(FILENAME_SAFE, "_").slice(0, 96) || "asset";
622
+ }
623
+ __name(sanitizeContentDispositionFilename, "sanitizeContentDispositionFilename");
624
+ function mintToken(payload, secret = getProxySecret()) {
625
+ if (secret.length < 32) throw new Error("PM_PROXY_SECRET too short");
626
+ if (!isAllowedProxyTarget(payload.url)) throw new Error("asset URL is outside the proxy allowlist");
627
+ const body = {
628
+ url: payload.url,
629
+ name: payload.name,
630
+ exp: Math.floor(Date.now() / 1e3) + TOKEN_TTL_SECONDS
631
+ };
632
+ const json = Buffer.from(JSON.stringify(body), "utf8");
633
+ const sig = createHmac("sha256", secret).update(json).digest();
634
+ return `${b64url(json)}.${b64url(sig)}`;
635
+ }
636
+ __name(mintToken, "mintToken");
637
+ function verifyToken(token, secret = getProxySecret()) {
638
+ if (secret.length < 32) return null;
639
+ const parts = token.split(".");
640
+ if (parts.length !== 2) return null;
641
+ let json;
642
+ let sig;
643
+ try {
644
+ json = fromB64url(parts[0]);
645
+ sig = fromB64url(parts[1]);
646
+ } catch {
647
+ return null;
648
+ }
649
+ const expected = createHmac("sha256", secret).update(json).digest();
650
+ if (expected.length !== sig.length) return null;
651
+ if (!timingSafeEqual(expected, sig)) return null;
652
+ let raw;
653
+ try {
654
+ raw = JSON.parse(json.toString("utf8"));
655
+ } catch {
656
+ return null;
657
+ }
658
+ if (!isAssetPayload(raw)) return null;
659
+ if (raw.exp < Math.floor(Date.now() / 1e3)) return null;
660
+ return raw;
661
+ }
662
+ __name(verifyToken, "verifyToken");
663
+ function isAssetPayload(v) {
664
+ if (!v || typeof v !== "object") return false;
665
+ const o = v;
666
+ return typeof o.url === "string" && typeof o.name === "string" && typeof o.exp === "number";
667
+ }
668
+ __name(isAssetPayload, "isAssetPayload");
669
+ function buildProxyUrl(opts) {
670
+ const secret = getProxySecret();
671
+ if (!opts.proxyBaseUrl || !secret) return null;
672
+ if (!isAllowedProxyTarget(opts.r2Url)) return null;
673
+ const token = mintToken({
674
+ url: opts.r2Url,
675
+ name: opts.filename
676
+ }, secret);
677
+ return `${opts.proxyBaseUrl.replace(/\/+$/, "")}/assets/${token}/${encodeURIComponent(opts.filename)}`;
678
+ }
679
+ __name(buildProxyUrl, "buildProxyUrl");
680
+
406
681
  // src/tools/task-outputs.ts
407
682
  var WEBAPP_BASE = "https://productmaker.app";
683
+ function proxifyOutputs(outputs, proxyBaseUrl) {
684
+ if (!proxyBaseUrl || !isProxyEnabled()) return outputs;
685
+ const rewrite = /* @__PURE__ */ __name((url, filename) => buildProxyUrl({
686
+ r2Url: url,
687
+ filename,
688
+ proxyBaseUrl
689
+ }) ?? url, "rewrite");
690
+ return {
691
+ webappUrl: outputs.webappUrl,
692
+ images: outputs.images.map((img, i) => ({
693
+ ...img,
694
+ url: rewrite(img.url, `image-${i}.png`)
695
+ })),
696
+ landing: {
697
+ sections: outputs.landing.sections.map((s) => ({
698
+ ...s,
699
+ url: rewrite(s.url, `landing-${sanitizeFilename(s.role)}.png`)
700
+ }))
701
+ },
702
+ videos: outputs.videos.map((v) => ({
703
+ ...v,
704
+ variants: v.variants.map((variant) => ({
705
+ ...variant,
706
+ url: rewrite(variant.url, `video-${sanitizeFilename(v.angleKey)}-${sanitizeFilename(variant.kind)}.mp4`)
707
+ }))
708
+ }))
709
+ };
710
+ }
711
+ __name(proxifyOutputs, "proxifyOutputs");
712
+ function sanitizeFilename(s) {
713
+ return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "asset";
714
+ }
715
+ __name(sanitizeFilename, "sanitizeFilename");
408
716
  async function fetchTaskOutputs(api, taskId) {
409
717
  const raw = await api.getJson(`/v1/results/${encodeURIComponent(taskId)}?signedUrls=true`);
410
718
  const videos = (raw.videoGenerators ?? []).map((vg) => {
@@ -450,7 +758,7 @@ async function fetchTaskOutputs(api, taskId) {
450
758
  }
451
759
  __name(fetchTaskOutputs, "fetchTaskOutputs");
452
760
  function escapeMdLabel(s) {
453
- return s.replace(/[\[\]()]/g, "");
761
+ return s.replace(/[\[\]\(\)\{\}`<>\r\n\\]/g, " ").replace(/\s+/g, " ").trim();
454
762
  }
455
763
  __name(escapeMdLabel, "escapeMdLabel");
456
764
  function renderOutputsMarkdown(outputs) {
@@ -479,13 +787,12 @@ __name(renderOutputsMarkdown, "renderOutputsMarkdown");
479
787
 
480
788
  // src/tools/tasks.ts
481
789
  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)."),
790
+ images: ImagesInput,
484
791
  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.'),
792
+ 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.'),
793
+ 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.'),
794
+ 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.'),
795
+ 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
796
  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
797
  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
798
  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 +826,16 @@ var EditTaskDraftInput = z2.object({
519
826
  scriptNotes: z2.string().max(2e3).optional()
520
827
  })
521
828
  });
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) => {
829
+ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
830
+ 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
831
  try {
525
- const img = await resolveImage(input);
832
+ const imgs = await resolveImages(input.images, {
833
+ transport
834
+ });
835
+ const parts = imgs.map((img, i) => ({
836
+ ...img,
837
+ filename: `image-${i}.png`
838
+ }));
526
839
  const fields = flatten({
527
840
  "options.languageCode": input.language,
528
841
  "options.country": input.country,
@@ -536,10 +849,7 @@ function registerTaskTools(server, api) {
536
849
  url: input.productUrl
537
850
  });
538
851
  const r = await api.postMultipart("/v1/ingest", {
539
- image: {
540
- ...img,
541
- filename: "image.png"
542
- }
852
+ images: parts
543
853
  }, fields);
544
854
  return asText({
545
855
  taskId: r.taskId,
@@ -557,7 +867,8 @@ function registerTaskTools(server, api) {
557
867
  const snapshot = await pollStatus(fetcher, input.waitSeconds ?? 0, signal);
558
868
  if (snapshot.status !== "done") return asText(snapshot);
559
869
  try {
560
- const outputs = await fetchTaskOutputs(api, input.taskId);
870
+ const rawOutputs = await fetchTaskOutputs(api, input.taskId);
871
+ const outputs = proxifyOutputs(rawOutputs, proxyBaseUrl);
561
872
  return {
562
873
  content: [
563
874
  {
@@ -589,8 +900,8 @@ function registerTaskTools(server, api) {
589
900
  if (input.limit != null) qs.set("limit", String(input.limit));
590
901
  if (input.offset != null) qs.set("offset", String(input.offset));
591
902
  if (input.search) qs.set("search", input.search);
592
- const path = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
593
- const r = await api.getJson(path);
903
+ const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
904
+ const r = await api.getJson(path2);
594
905
  return asText(r);
595
906
  } catch (e) {
596
907
  throw translateError(e);
@@ -611,21 +922,21 @@ __name(registerTaskTools, "registerTaskTools");
611
922
 
612
923
  // src/tools/creatives.ts
613
924
  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".')
925
+ var CreativeRefs = {
926
+ images: ImagesInput,
927
+ 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.'),
928
+ 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.'),
929
+ 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.'),
930
+ 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.'),
931
+ 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.'),
932
+ isCombo: z3.boolean().optional().describe("Opcional. Si true, el creativo presenta el producto como combo/pack. Default false.")
621
933
  };
622
934
  var VideoInput = z3.object({
623
- imageUrl: ImageInputRefs.imageUrl,
624
- imageBase64: ImageInputRefs.imageBase64,
935
+ images: CreativeRefs.images,
625
936
  title: z3.string().min(1).describe('T\xEDtulo corto del producto. Aparece en la primera vista del video. Ej. "Crema Anti-Edad Premium".'),
626
937
  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,
938
+ language: CreativeRefs.language,
939
+ country: CreativeRefs.country,
629
940
  aspectRatio: z3.enum([
630
941
  "9:16",
631
942
  "16:9",
@@ -636,71 +947,81 @@ var VideoInput = z3.object({
636
947
  videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual. "ugc" (creador hablando, default), "cartoon_3d", "product_only", "ai_dynamic".'),
637
948
  videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). Ver lista de valores v\xE1lidos.'),
638
949
  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,
950
+ price: CreativeRefs.price,
951
+ offer: CreativeRefs.offer,
641
952
  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
953
  scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion. Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico".')
643
954
  });
955
+ var imageStyleList = ImageCreativeStyleEnum.options.join(" | ");
956
+ var archetypeList = ImageCreativeArchetypeEnum.options.join(" | ");
957
+ var visualTreatmentList = ImageCreativeVisualTreatmentEnum.options.join(" | ");
958
+ var aspectRatioList = ImageCreativeAspectRatioEnum.options.join(" | ");
644
959
  var ImageInput = z3.object({
645
- imageUrl: ImageInputRefs.imageUrl,
646
- imageBase64: ImageInputRefs.imageBase64,
960
+ images: CreativeRefs.images,
647
961
  productTitle: z3.string().min(1).describe('Nombre del producto. Ej. "Crema Anti-Edad Premium".'),
648
962
  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,
963
+ buyerPersona: CreativeRefs.buyerPersona,
964
+ language: CreativeRefs.language,
965
+ country: CreativeRefs.country,
651
966
  nVariants: z3.number().int().min(1).max(6).optional().describe("Opcional. N\xFAmero de variantes a generar (1-6). Default 3."),
652
967
  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".'),
968
+ imageStyle: ImageCreativeStyleEnum.optional().describe(`Opcional. Estilo del shot del producto. Valores: ${imageStyleList}. Si se omite, la IA escoge seg\xFAn el archetype.`),
969
+ 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).`),
970
+ 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.`),
971
+ 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."),
972
+ aspectRatio: ImageCreativeAspectRatioEnum.optional().describe(`Opcional. Relaci\xF3n de aspecto. Valores: ${aspectRatioList}. Default "4:5" (vertical Instagram feed).`),
654
973
  generateAdCopy: z3.boolean().optional().describe("Opcional. Si true, tambi\xE9n genera headline/body/CTA para el ad. Default false."),
974
+ 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
975
  features: z3.array(z3.string()).optional().describe('Opcional. Features/beneficios a destacar visualmente. Ej. ["resistente al agua", "ultra liviano"].'),
656
976
  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
977
+ price: CreativeRefs.price,
978
+ 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".'),
979
+ offer: CreativeRefs.offer,
980
+ isCombo: CreativeRefs.isCombo
659
981
  });
660
982
  var LandingInput = z3.object({
661
- imageUrl: ImageInputRefs.imageUrl,
662
- imageBase64: ImageInputRefs.imageBase64,
663
- language: ImageInputRefs.language,
664
- country: ImageInputRefs.country,
983
+ images: CreativeRefs.images,
984
+ language: CreativeRefs.language,
985
+ country: CreativeRefs.country,
665
986
  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,
987
+ buyerPersona: CreativeRefs.buyerPersona,
988
+ price: CreativeRefs.price,
989
+ offer: CreativeRefs.offer,
669
990
  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.")
991
+ isCombo: CreativeRefs.isCombo
671
992
  });
672
- 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) => {
993
+ function registerCreativeTools(server, api, { transport = "http" } = {}) {
994
+ 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
995
  try {
675
996
  return await submitCreative(api, {
676
997
  path: "/v1/creatives/videos",
677
- fileField: "media",
678
998
  estimatedSeconds: 300,
679
- input
999
+ input,
1000
+ transport
680
1001
  });
681
1002
  } catch (e) {
682
1003
  throw translateError(e);
683
1004
  }
684
1005
  });
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) => {
1006
+ 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
1007
  try {
687
1008
  return await submitCreative(api, {
688
1009
  path: "/v1/creatives/images",
689
- fileField: "image",
690
1010
  estimatedSeconds: 60,
691
- input
1011
+ input,
1012
+ transport
692
1013
  });
693
1014
  } catch (e) {
694
1015
  throw translateError(e);
695
1016
  }
696
1017
  });
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) => {
1018
+ 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
1019
  try {
699
1020
  return await submitCreative(api, {
700
1021
  path: "/v1/creatives/landing",
701
- fileField: "image",
702
1022
  estimatedSeconds: 60,
703
- input
1023
+ input,
1024
+ transport
704
1025
  });
705
1026
  } catch (e) {
706
1027
  throw translateError(e);
@@ -782,7 +1103,8 @@ function registerPublishTools(server, api) {
782
1103
  __name(registerPublishTools, "registerPublishTools");
783
1104
 
784
1105
  // src/server.ts
785
- function createServer(apiKey, apiBaseUrl) {
1106
+ function createServer(opts) {
1107
+ const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
786
1108
  const server = new McpServer({
787
1109
  name: "productmaker",
788
1110
  version: MCP_VERSION
@@ -795,8 +1117,13 @@ function createServer(apiKey, apiBaseUrl) {
795
1117
  }
796
1118
  });
797
1119
  const api = new ApiClient(apiBaseUrl, apiKey);
798
- registerTaskTools(server, api);
799
- registerCreativeTools(server, api);
1120
+ registerTaskTools(server, api, {
1121
+ proxyBaseUrl,
1122
+ transport
1123
+ });
1124
+ registerCreativeTools(server, api, {
1125
+ transport
1126
+ });
800
1127
  registerConnectionTools(server, api);
801
1128
  registerPublishTools(server, api);
802
1129
  return server;
@@ -804,21 +1131,21 @@ function createServer(apiKey, apiBaseUrl) {
804
1131
  __name(createServer, "createServer");
805
1132
 
806
1133
  // src/validate-api-url.ts
807
- function assertSafeApiBaseUrl(url) {
1134
+ function assertSafePublicBaseUrl(envName, url) {
808
1135
  let u;
809
1136
  try {
810
1137
  u = new URL(url);
811
1138
  } catch {
812
- throw new Error(`PM_API_URL is not a valid URL: ${url}`);
1139
+ throw new Error(`${envName} is not a valid URL: ${url}`);
813
1140
  }
814
1141
  if (u.protocol !== "https:") {
815
- throw new Error(`PM_API_URL must use https (got ${u.protocol})`);
1142
+ throw new Error(`${envName} must use https (got ${u.protocol})`);
816
1143
  }
817
1144
  if (isPrivateHost(u.hostname)) {
818
- throw new Error(`PM_API_URL must not point to a private/loopback host: ${u.hostname}`);
1145
+ throw new Error(`${envName} must not point to a private/loopback host: ${u.hostname}`);
819
1146
  }
820
1147
  }
821
- __name(assertSafeApiBaseUrl, "assertSafeApiBaseUrl");
1148
+ __name(assertSafePublicBaseUrl, "assertSafePublicBaseUrl");
822
1149
 
823
1150
  // src/http.ts
824
1151
  var PM_API_KEY_PREFIX = "pm_live_";
@@ -847,6 +1174,73 @@ function extractApiKey(req) {
847
1174
  return null;
848
1175
  }
849
1176
  __name(extractApiKey, "extractApiKey");
1177
+ var PROXY_FETCH_TIMEOUT_MS = 3e4;
1178
+ var INLINE_RENDERABLE_MIMES = /* @__PURE__ */ new Set([
1179
+ "image/jpeg",
1180
+ "image/png",
1181
+ "image/webp",
1182
+ "video/mp4"
1183
+ ]);
1184
+ async function handleAsset(req, res) {
1185
+ if (!getProxySecret()) {
1186
+ res.status(503).json({
1187
+ error: "Proxy disabled"
1188
+ });
1189
+ return;
1190
+ }
1191
+ const token = req.params.token;
1192
+ if (typeof token !== "string" || !token) {
1193
+ res.status(404).end();
1194
+ return;
1195
+ }
1196
+ const payload = verifyToken(token);
1197
+ if (!payload || !isAllowedProxyTarget(payload.url)) {
1198
+ res.status(404).end();
1199
+ return;
1200
+ }
1201
+ let upstream;
1202
+ try {
1203
+ upstream = await fetch(payload.url, {
1204
+ signal: AbortSignal.timeout(PROXY_FETCH_TIMEOUT_MS),
1205
+ redirect: "error"
1206
+ });
1207
+ } catch (err) {
1208
+ process.stderr.write(`mcp asset proxy fetch failed: ${String(err)}
1209
+ `);
1210
+ res.status(502).json({
1211
+ error: "Upstream fetch failed"
1212
+ });
1213
+ return;
1214
+ }
1215
+ if (!upstream.ok) {
1216
+ res.status(upstream.status === 404 ? 404 : 502).json({
1217
+ error: `Upstream HTTP ${upstream.status}`
1218
+ });
1219
+ return;
1220
+ }
1221
+ const rawCt = upstream.headers.get("content-type") ?? "application/octet-stream";
1222
+ const ct = rawCt.split(";")[0]?.trim() ?? "application/octet-stream";
1223
+ const cl = upstream.headers.get("content-length");
1224
+ const disposition = INLINE_RENDERABLE_MIMES.has(ct) ? "inline" : "attachment";
1225
+ res.status(200);
1226
+ res.setHeader("Content-Type", ct);
1227
+ if (cl) res.setHeader("Content-Length", cl);
1228
+ res.setHeader("Cache-Control", "private, max-age=300");
1229
+ res.setHeader("Content-Disposition", `${disposition}; filename="${sanitizeContentDispositionFilename(payload.name)}"`);
1230
+ res.setHeader("X-Content-Type-Options", "nosniff");
1231
+ if (!upstream.body) {
1232
+ res.end();
1233
+ return;
1234
+ }
1235
+ try {
1236
+ await pipeline(Readable.fromWeb(upstream.body), res);
1237
+ } catch (err) {
1238
+ process.stderr.write(`mcp asset proxy stream failed: ${String(err)}
1239
+ `);
1240
+ if (!res.writableEnded) res.end();
1241
+ }
1242
+ }
1243
+ __name(handleAsset, "handleAsset");
850
1244
  function createApp(opts) {
851
1245
  const app = express();
852
1246
  app.set("trust proxy", 1);
@@ -871,13 +1265,19 @@ function createApp(opts) {
871
1265
  legacyHeaders: false
872
1266
  }));
873
1267
  app.use(express.json({
874
- limit: "10mb"
1268
+ limit: "1mb"
875
1269
  }));
876
1270
  app.get("/healthz", (_req, res) => {
877
1271
  res.json({
878
1272
  ok: true
879
1273
  });
880
1274
  });
1275
+ app.get("/assets/:token/:filename?", rateLimit({
1276
+ windowMs: 6e4,
1277
+ max: 600,
1278
+ standardHeaders: true,
1279
+ legacyHeaders: false
1280
+ }), handleAsset);
881
1281
  app.post("/mcp", async (req, res) => {
882
1282
  const apiKey = extractApiKey(req);
883
1283
  if (!apiKey) {
@@ -887,7 +1287,12 @@ function createApp(opts) {
887
1287
  return;
888
1288
  }
889
1289
  try {
890
- const server = createServer(apiKey, opts.apiBaseUrl);
1290
+ const server = createServer({
1291
+ apiKey,
1292
+ apiBaseUrl: opts.apiBaseUrl,
1293
+ proxyBaseUrl: opts.proxyBaseUrl ?? null,
1294
+ transport: "http"
1295
+ });
891
1296
  const transport = new StreamableHTTPServerTransport({
892
1297
  sessionIdGenerator: void 0
893
1298
  });
@@ -907,15 +1312,24 @@ __name(createApp, "createApp");
907
1312
  if (process.env.MCP_HTTP_AUTOSTART !== "false") {
908
1313
  const port = Number(process.env.PORT ?? 8080);
909
1314
  const apiBaseUrl = process.env.PM_API_URL ?? "https://api.productmaker.app";
1315
+ const proxyBaseUrl = process.env.MCP_PROXY_BASE_URL ?? "https://mcp.productmaker.app";
1316
+ try {
1317
+ assertSafePublicBaseUrl("PM_API_URL", apiBaseUrl);
1318
+ } catch (e) {
1319
+ process.stderr.write(`${e.message}
1320
+ `);
1321
+ process.exit(1);
1322
+ }
910
1323
  try {
911
- assertSafeApiBaseUrl(apiBaseUrl);
1324
+ assertSafePublicBaseUrl("MCP_PROXY_BASE_URL", proxyBaseUrl);
912
1325
  } catch (e) {
913
1326
  process.stderr.write(`${e.message}
914
1327
  `);
915
1328
  process.exit(1);
916
1329
  }
917
1330
  createApp({
918
- apiBaseUrl
1331
+ apiBaseUrl,
1332
+ proxyBaseUrl
919
1333
  }).listen(port, () => {
920
1334
  process.stdout.write(`mcp http listening on ${port}
921
1335
  `);