@productmaker/mcp 0.1.6 → 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.
package/README.md CHANGED
@@ -45,7 +45,16 @@ Header: `Authorization: Bearer pm_live_...`
45
45
 
46
46
  Creative tools accept an `images` array of **1–5 photos of the same product**. The first entry is the hero; the rest are alternate angles. Don't mix different products in one call — that's a separate task each time.
47
47
 
48
- Each entry uses `{ "url": "https://..." }` (preferred — public HTTPS) or `{ "base64": "..." }` (only for local files <500 KB; clients truncate large arguments).
48
+ Each entry has **exactly one** of:
49
+
50
+ - `{ "url": "https://..." }` — public HTTPS URL (Shopify CDN, Cloudinary, etc.). Use this whenever the photo is already hosted.
51
+ - `{ "path": "/Users/maria/Desktop/foto.jpg" }` — local filesystem path. The MCP process reads the file from disk and uploads its bytes. `~/` and `%USERPROFILE%` are expanded. **Only works with the local stdio MCP (npm).** The hosted `mcp.productmaker.app` endpoint rejects `path` because Cloud Run has no access to your filesystem.
52
+
53
+ Mixed sources in the same call are fine: `[{ "path": "~/Desktop/hero.jpg" }, { "url": "https://..." }]`.
54
+
55
+ ### Breaking change in 0.2.0
56
+
57
+ The `base64` field was removed. MCP JSON-RPC transports truncate large tool arguments (~1 MB) and host-attached chat images never reach the model as raw bytes — base64 in practice never worked. Use `path` (local MCP) or `url` (hosted MCP) instead.
49
58
 
50
59
  ## Downloading results
51
60
 
package/dist/http.js CHANGED
@@ -38,14 +38,14 @@ var ApiClient = class {
38
38
  Authorization: `Bearer ${this.apiKey}`
39
39
  };
40
40
  }
41
- async getJson(path) {
42
- const res = await fetch(`${this.baseUrl}${path}`, {
41
+ async getJson(path2) {
42
+ const res = await fetch(`${this.baseUrl}${path2}`, {
43
43
  headers: this.authHeaders()
44
44
  });
45
45
  return this.parse(res);
46
46
  }
47
- async postJson(path, body) {
48
- const res = await fetch(`${this.baseUrl}${path}`, {
47
+ async postJson(path2, body) {
48
+ const res = await fetch(`${this.baseUrl}${path2}`, {
49
49
  method: "POST",
50
50
  headers: {
51
51
  ...this.authHeaders(),
@@ -55,8 +55,8 @@ var ApiClient = class {
55
55
  });
56
56
  return this.parse(res);
57
57
  }
58
- async patchJson(path, body) {
59
- const res = await fetch(`${this.baseUrl}${path}`, {
58
+ async patchJson(path2, body) {
59
+ const res = await fetch(`${this.baseUrl}${path2}`, {
60
60
  method: "PATCH",
61
61
  headers: {
62
62
  ...this.authHeaders(),
@@ -66,14 +66,14 @@ var ApiClient = class {
66
66
  });
67
67
  return this.parse(res);
68
68
  }
69
- async deleteJson(path) {
70
- const res = await fetch(`${this.baseUrl}${path}`, {
69
+ async deleteJson(path2) {
70
+ const res = await fetch(`${this.baseUrl}${path2}`, {
71
71
  method: "DELETE",
72
72
  headers: this.authHeaders()
73
73
  });
74
74
  return this.parse(res);
75
75
  }
76
- async postMultipart(path, files, fields) {
76
+ async postMultipart(path2, files, fields) {
77
77
  const form = new FormData();
78
78
  for (const [name, value] of Object.entries(files)) {
79
79
  const parts = Array.isArray(value) ? value : [
@@ -91,7 +91,7 @@ var ApiClient = class {
91
91
  for (const [k, v] of Object.entries(fields)) {
92
92
  if (v !== void 0) form.append(k, v);
93
93
  }
94
- const res = await fetch(`${this.baseUrl}${path}`, {
94
+ const res = await fetch(`${this.baseUrl}${path2}`, {
95
95
  method: "POST",
96
96
  headers: this.authHeaders(),
97
97
  body: form
@@ -113,19 +113,40 @@ var ApiClient = class {
113
113
  };
114
114
 
115
115
  // src/index.ts
116
- var MCP_VERSION = "0.1.0";
116
+ var MCP_VERSION = "0.2.0";
117
117
 
118
118
  // src/tools/tasks.ts
119
119
  import { z as z2 } from "zod";
120
120
 
121
121
  // src/resolve-image.ts
122
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";
123
126
  var MAX_BYTES = 20 * 1024 * 1024;
124
- var ALLOWED = /* @__PURE__ */ new Set([
125
- "image/jpeg",
126
- "image/png",
127
- "image/webp"
128
- ]);
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));
129
150
  var ResolveImageError = class extends Error {
130
151
  static {
131
152
  __name(this, "ResolveImageError");
@@ -192,69 +213,139 @@ async function assertHostResolvesPublic(hostname) {
192
213
  }
193
214
  }
194
215
  __name(assertHostResolvesPublic, "assertHostResolvesPublic");
195
- async function resolveImages(images) {
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);
238
+ }
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}`);
265
+ }
266
+ if (code === "EACCES" || code === "EPERM") {
267
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot access ${abs} (${code})`);
268
+ }
269
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `stat failed: ${e.message}`);
270
+ }
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}`);
281
+ }
282
+ return {
283
+ buffer: buf,
284
+ mimetype: sniffed
285
+ };
286
+ }
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
+ }) {
196
329
  if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
197
330
  if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
198
- return Promise.all(images.map((ref) => resolveImage({
199
- imageUrl: ref.url,
200
- imageBase64: ref.base64
201
- })));
331
+ return Promise.all(images.map((ref) => resolveOne(ref, opts.transport)));
202
332
  }
203
333
  __name(resolveImages, "resolveImages");
204
- async function resolveImage(input) {
205
- if (input.imageUrl && input.imageBase64) {
206
- throw new ResolveImageError("INVALID_INPUT", "Provide only one of imageUrl or imageBase64");
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`");
207
339
  }
208
- if (input.imageUrl) {
209
- let u;
210
- try {
211
- u = new URL(input.imageUrl);
212
- } catch {
213
- throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
214
- }
215
- if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
216
- if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
217
- await assertHostResolvesPublic(u.hostname);
218
- let res;
219
- try {
220
- res = await fetch(input.imageUrl, {
221
- signal: AbortSignal.timeout(3e4),
222
- redirect: "error"
223
- });
224
- } catch (e) {
225
- throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
226
- }
227
- if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
228
- const cl = res.headers.get("content-length");
229
- if (cl && Number(cl) > MAX_BYTES) {
230
- throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
231
- }
232
- const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
233
- if (!ALLOWED.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
234
- const buf = Buffer.from(await res.arrayBuffer());
235
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
236
- const sniffed = sniffMime(buf);
237
- if (!sniffed || sniffed !== ct) {
238
- throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
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.");
239
343
  }
240
- return {
241
- buffer: buf,
242
- mimetype: sniffed
243
- };
244
- }
245
- if (input.imageBase64) {
246
- const buf = Buffer.from(input.imageBase64, "base64");
247
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
248
- const mime = sniffMime(buf);
249
- if (!mime) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", "cannot detect image type");
250
- return {
251
- buffer: buf,
252
- mimetype: mime
253
- };
344
+ return resolvePath(ref.path);
254
345
  }
255
- throw new ResolveImageError("INVALID_INPUT", "imageUrl or imageBase64 required");
346
+ return resolveImageUrl(ref.url);
256
347
  }
257
- __name(resolveImage, "resolveImage");
348
+ __name(resolveOne, "resolveOne");
258
349
 
259
350
  // src/status-poller.ts
260
351
  var TERMINAL = /* @__PURE__ */ new Set([
@@ -282,14 +373,14 @@ async function pollStatus(fetcher, waitSeconds, signal, opts = {}) {
282
373
  }
283
374
  __name(pollStatus, "pollStatus");
284
375
  function sleep(ms, signal) {
285
- return new Promise((resolve, reject) => {
376
+ return new Promise((resolve2, reject) => {
286
377
  const onAbort = /* @__PURE__ */ __name(() => {
287
378
  clearTimeout(t);
288
379
  reject(new Error("aborted"));
289
380
  }, "onAbort");
290
381
  const t = setTimeout(() => {
291
382
  signal?.removeEventListener("abort", onAbort);
292
- resolve();
383
+ resolve2();
293
384
  }, ms);
294
385
  signal?.addEventListener("abort", onAbort, {
295
386
  once: true
@@ -308,6 +399,10 @@ var McpErrorCode = {
308
399
  IMAGE_DOWNLOAD_FAILED: "IMAGE_DOWNLOAD_FAILED",
309
400
  IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
310
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",
311
406
  TASK_NOT_FOUND: "TASK_NOT_FOUND",
312
407
  TASK_NOT_READY: "TASK_NOT_READY",
313
408
  NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
@@ -321,7 +416,11 @@ var McpErrorCode = {
321
416
  var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
322
417
  McpErrorCode.INVALID_INPUT,
323
418
  McpErrorCode.INVALID_API_KEY,
324
- 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
325
424
  ]);
326
425
  function mcp(code, message, extra = {}) {
327
426
  const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
@@ -436,14 +535,13 @@ var ImageCreativeAspectRatioEnum = z.enum([
436
535
  "9:16",
437
536
  "16:9"
438
537
  ]);
439
- var ImageBase64 = z.string().max(75e5);
440
538
  var ImageRefSchema = z.object({
441
- url: z.string().url().optional().describe("URL p\xFAblica HTTPS de la foto."),
442
- base64: ImageBase64.optional().describe("Bytes de la foto en base64 (sin prefijo data:). \xDAsalo SOLO para archivos locales <500 KB.")
443
- }).refine((v) => Boolean(v.url) !== Boolean(v.base64), {
444
- message: "each image entry must have exactly one of `url` or `base64`"
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`"
445
543
  });
446
- var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe("1 a 5 fotos del MISMO producto. La PRIMERA es la principal (hero); las dem\xE1s son \xE1ngulos adicionales. Cada entrada usa `url` (preferido \u2014 p\xFAblico HTTPS) o `base64` (solo para archivos locales <500 KB; clientes MCP truncan argumentos >1 MB). PREFIERE siempre `url` si el usuario te dio una. NO mezcles productos distintos en una sola llamada.");
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.');
447
545
  function asText(value) {
448
546
  return {
449
547
  content: [
@@ -468,7 +566,9 @@ function flatten(obj) {
468
566
  __name(flatten, "flatten");
469
567
  async function submitCreative(api, opts) {
470
568
  const { images, ...rest } = opts.input;
471
- const resolved = await resolveImages(images);
569
+ const resolved = await resolveImages(images, {
570
+ transport: opts.transport
571
+ });
472
572
  const parts = resolved.map((img, i) => ({
473
573
  ...img,
474
574
  filename: `image-${i}.png`
@@ -726,10 +826,12 @@ var EditTaskDraftInput = z2.object({
726
826
  scriptNotes: z2.string().max(2e3).optional()
727
827
  })
728
828
  });
729
- function registerTaskTools(server, api, proxyBaseUrl = null) {
829
+ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
730
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) => {
731
831
  try {
732
- const imgs = await resolveImages(input.images);
832
+ const imgs = await resolveImages(input.images, {
833
+ transport
834
+ });
733
835
  const parts = imgs.map((img, i) => ({
734
836
  ...img,
735
837
  filename: `image-${i}.png`
@@ -798,8 +900,8 @@ function registerTaskTools(server, api, proxyBaseUrl = null) {
798
900
  if (input.limit != null) qs.set("limit", String(input.limit));
799
901
  if (input.offset != null) qs.set("offset", String(input.offset));
800
902
  if (input.search) qs.set("search", input.search);
801
- const path = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
802
- const r = await api.getJson(path);
903
+ const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
904
+ const r = await api.getJson(path2);
803
905
  return asText(r);
804
906
  } catch (e) {
805
907
  throw translateError(e);
@@ -888,13 +990,14 @@ var LandingInput = z3.object({
888
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.'),
889
991
  isCombo: CreativeRefs.isCombo
890
992
  });
891
- function registerCreativeTools(server, api) {
993
+ function registerCreativeTools(server, api, { transport = "http" } = {}) {
892
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) => {
893
995
  try {
894
996
  return await submitCreative(api, {
895
997
  path: "/v1/creatives/videos",
896
998
  estimatedSeconds: 300,
897
- input
999
+ input,
1000
+ transport
898
1001
  });
899
1002
  } catch (e) {
900
1003
  throw translateError(e);
@@ -905,7 +1008,8 @@ function registerCreativeTools(server, api) {
905
1008
  return await submitCreative(api, {
906
1009
  path: "/v1/creatives/images",
907
1010
  estimatedSeconds: 60,
908
- input
1011
+ input,
1012
+ transport
909
1013
  });
910
1014
  } catch (e) {
911
1015
  throw translateError(e);
@@ -916,7 +1020,8 @@ function registerCreativeTools(server, api) {
916
1020
  return await submitCreative(api, {
917
1021
  path: "/v1/creatives/landing",
918
1022
  estimatedSeconds: 60,
919
- input
1023
+ input,
1024
+ transport
920
1025
  });
921
1026
  } catch (e) {
922
1027
  throw translateError(e);
@@ -998,7 +1103,8 @@ function registerPublishTools(server, api) {
998
1103
  __name(registerPublishTools, "registerPublishTools");
999
1104
 
1000
1105
  // src/server.ts
1001
- function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
1106
+ function createServer(opts) {
1107
+ const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
1002
1108
  const server = new McpServer({
1003
1109
  name: "productmaker",
1004
1110
  version: MCP_VERSION
@@ -1011,8 +1117,13 @@ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
1011
1117
  }
1012
1118
  });
1013
1119
  const api = new ApiClient(apiBaseUrl, apiKey);
1014
- registerTaskTools(server, api, proxyBaseUrl);
1015
- registerCreativeTools(server, api);
1120
+ registerTaskTools(server, api, {
1121
+ proxyBaseUrl,
1122
+ transport
1123
+ });
1124
+ registerCreativeTools(server, api, {
1125
+ transport
1126
+ });
1016
1127
  registerConnectionTools(server, api);
1017
1128
  registerPublishTools(server, api);
1018
1129
  return server;
@@ -1154,7 +1265,7 @@ function createApp(opts) {
1154
1265
  legacyHeaders: false
1155
1266
  }));
1156
1267
  app.use(express.json({
1157
- limit: "10mb"
1268
+ limit: "1mb"
1158
1269
  }));
1159
1270
  app.get("/healthz", (_req, res) => {
1160
1271
  res.json({
@@ -1176,7 +1287,12 @@ function createApp(opts) {
1176
1287
  return;
1177
1288
  }
1178
1289
  try {
1179
- const server = createServer(apiKey, opts.apiBaseUrl, opts.proxyBaseUrl ?? null);
1290
+ const server = createServer({
1291
+ apiKey,
1292
+ apiBaseUrl: opts.apiBaseUrl,
1293
+ proxyBaseUrl: opts.proxyBaseUrl ?? null,
1294
+ transport: "http"
1295
+ });
1180
1296
  const transport = new StreamableHTTPServerTransport({
1181
1297
  sessionIdGenerator: void 0
1182
1298
  });
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,14 +63,14 @@ 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
75
  for (const [name, value] of Object.entries(files)) {
76
76
  const parts = Array.isArray(value) ? value : [
@@ -88,7 +88,7 @@ var ApiClient = class {
88
88
  for (const [k, v] of Object.entries(fields)) {
89
89
  if (v !== void 0) form.append(k, v);
90
90
  }
91
- const res = await fetch(`${this.baseUrl}${path}`, {
91
+ const res = await fetch(`${this.baseUrl}${path2}`, {
92
92
  method: "POST",
93
93
  headers: this.authHeaders(),
94
94
  body: form
@@ -110,19 +110,40 @@ var ApiClient = class {
110
110
  };
111
111
 
112
112
  // src/index.ts
113
- var MCP_VERSION = "0.1.0";
113
+ var MCP_VERSION = "0.2.0";
114
114
 
115
115
  // src/tools/tasks.ts
116
116
  import { z as z2 } from "zod";
117
117
 
118
118
  // src/resolve-image.ts
119
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";
120
123
  var MAX_BYTES = 20 * 1024 * 1024;
121
- var ALLOWED = /* @__PURE__ */ new Set([
122
- "image/jpeg",
123
- "image/png",
124
- "image/webp"
125
- ]);
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));
126
147
  var ResolveImageError = class extends Error {
127
148
  static {
128
149
  __name(this, "ResolveImageError");
@@ -189,69 +210,139 @@ async function assertHostResolvesPublic(hostname) {
189
210
  }
190
211
  }
191
212
  __name(assertHostResolvesPublic, "assertHostResolvesPublic");
192
- async function resolveImages(images) {
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);
235
+ }
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}`);
262
+ }
263
+ if (code === "EACCES" || code === "EPERM") {
264
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot access ${abs} (${code})`);
265
+ }
266
+ throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `stat failed: ${e.message}`);
267
+ }
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}`);
278
+ }
279
+ return {
280
+ buffer: buf,
281
+ mimetype: sniffed
282
+ };
283
+ }
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
+ }) {
193
326
  if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
194
327
  if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
195
- return Promise.all(images.map((ref) => resolveImage({
196
- imageUrl: ref.url,
197
- imageBase64: ref.base64
198
- })));
328
+ return Promise.all(images.map((ref) => resolveOne(ref, opts.transport)));
199
329
  }
200
330
  __name(resolveImages, "resolveImages");
201
- async function resolveImage(input) {
202
- if (input.imageUrl && input.imageBase64) {
203
- throw new ResolveImageError("INVALID_INPUT", "Provide only one of imageUrl or imageBase64");
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`");
204
336
  }
205
- if (input.imageUrl) {
206
- let u;
207
- try {
208
- u = new URL(input.imageUrl);
209
- } catch {
210
- throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
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.");
211
340
  }
212
- if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
213
- if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
214
- await assertHostResolvesPublic(u.hostname);
215
- let res;
216
- try {
217
- res = await fetch(input.imageUrl, {
218
- signal: AbortSignal.timeout(3e4),
219
- redirect: "error"
220
- });
221
- } catch (e) {
222
- throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
223
- }
224
- if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
225
- const cl = res.headers.get("content-length");
226
- if (cl && Number(cl) > MAX_BYTES) {
227
- throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
228
- }
229
- const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
230
- if (!ALLOWED.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
231
- const buf = Buffer.from(await res.arrayBuffer());
232
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
233
- const sniffed = sniffMime(buf);
234
- if (!sniffed || sniffed !== ct) {
235
- throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
236
- }
237
- return {
238
- buffer: buf,
239
- mimetype: sniffed
240
- };
341
+ return resolvePath(ref.path);
241
342
  }
242
- if (input.imageBase64) {
243
- const buf = Buffer.from(input.imageBase64, "base64");
244
- if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
245
- const mime = sniffMime(buf);
246
- if (!mime) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", "cannot detect image type");
247
- return {
248
- buffer: buf,
249
- mimetype: mime
250
- };
251
- }
252
- throw new ResolveImageError("INVALID_INPUT", "imageUrl or imageBase64 required");
343
+ return resolveImageUrl(ref.url);
253
344
  }
254
- __name(resolveImage, "resolveImage");
345
+ __name(resolveOne, "resolveOne");
255
346
 
256
347
  // src/status-poller.ts
257
348
  var TERMINAL = /* @__PURE__ */ new Set([
@@ -279,14 +370,14 @@ async function pollStatus(fetcher, waitSeconds, signal, opts = {}) {
279
370
  }
280
371
  __name(pollStatus, "pollStatus");
281
372
  function sleep(ms, signal) {
282
- return new Promise((resolve, reject) => {
373
+ return new Promise((resolve2, reject) => {
283
374
  const onAbort = /* @__PURE__ */ __name(() => {
284
375
  clearTimeout(t);
285
376
  reject(new Error("aborted"));
286
377
  }, "onAbort");
287
378
  const t = setTimeout(() => {
288
379
  signal?.removeEventListener("abort", onAbort);
289
- resolve();
380
+ resolve2();
290
381
  }, ms);
291
382
  signal?.addEventListener("abort", onAbort, {
292
383
  once: true
@@ -305,6 +396,10 @@ var McpErrorCode = {
305
396
  IMAGE_DOWNLOAD_FAILED: "IMAGE_DOWNLOAD_FAILED",
306
397
  IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
307
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",
308
403
  TASK_NOT_FOUND: "TASK_NOT_FOUND",
309
404
  TASK_NOT_READY: "TASK_NOT_READY",
310
405
  NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
@@ -318,7 +413,11 @@ var McpErrorCode = {
318
413
  var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
319
414
  McpErrorCode.INVALID_INPUT,
320
415
  McpErrorCode.INVALID_API_KEY,
321
- 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
322
421
  ]);
323
422
  function mcp(code, message, extra = {}) {
324
423
  const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
@@ -433,14 +532,13 @@ var ImageCreativeAspectRatioEnum = z.enum([
433
532
  "9:16",
434
533
  "16:9"
435
534
  ]);
436
- var ImageBase64 = z.string().max(75e5);
437
535
  var ImageRefSchema = z.object({
438
- url: z.string().url().optional().describe("URL p\xFAblica HTTPS de la foto."),
439
- base64: ImageBase64.optional().describe("Bytes de la foto en base64 (sin prefijo data:). \xDAsalo SOLO para archivos locales <500 KB.")
440
- }).refine((v) => Boolean(v.url) !== Boolean(v.base64), {
441
- message: "each image entry must have exactly one of `url` or `base64`"
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`"
442
540
  });
443
- var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe("1 a 5 fotos del MISMO producto. La PRIMERA es la principal (hero); las dem\xE1s son \xE1ngulos adicionales. Cada entrada usa `url` (preferido \u2014 p\xFAblico HTTPS) o `base64` (solo para archivos locales <500 KB; clientes MCP truncan argumentos >1 MB). PREFIERE siempre `url` si el usuario te dio una. NO mezcles productos distintos en una sola llamada.");
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.');
444
542
  function asText(value) {
445
543
  return {
446
544
  content: [
@@ -465,7 +563,9 @@ function flatten(obj) {
465
563
  __name(flatten, "flatten");
466
564
  async function submitCreative(api, opts) {
467
565
  const { images, ...rest } = opts.input;
468
- const resolved = await resolveImages(images);
566
+ const resolved = await resolveImages(images, {
567
+ transport: opts.transport
568
+ });
469
569
  const parts = resolved.map((img, i) => ({
470
570
  ...img,
471
571
  filename: `image-${i}.png`
@@ -682,10 +782,12 @@ var EditTaskDraftInput = z2.object({
682
782
  scriptNotes: z2.string().max(2e3).optional()
683
783
  })
684
784
  });
685
- function registerTaskTools(server, api, proxyBaseUrl = null) {
785
+ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
686
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) => {
687
787
  try {
688
- const imgs = await resolveImages(input.images);
788
+ const imgs = await resolveImages(input.images, {
789
+ transport
790
+ });
689
791
  const parts = imgs.map((img, i) => ({
690
792
  ...img,
691
793
  filename: `image-${i}.png`
@@ -754,8 +856,8 @@ function registerTaskTools(server, api, proxyBaseUrl = null) {
754
856
  if (input.limit != null) qs.set("limit", String(input.limit));
755
857
  if (input.offset != null) qs.set("offset", String(input.offset));
756
858
  if (input.search) qs.set("search", input.search);
757
- const path = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
758
- const r = await api.getJson(path);
859
+ const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
860
+ const r = await api.getJson(path2);
759
861
  return asText(r);
760
862
  } catch (e) {
761
863
  throw translateError(e);
@@ -844,13 +946,14 @@ var LandingInput = z3.object({
844
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.'),
845
947
  isCombo: CreativeRefs.isCombo
846
948
  });
847
- function registerCreativeTools(server, api) {
949
+ function registerCreativeTools(server, api, { transport = "http" } = {}) {
848
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) => {
849
951
  try {
850
952
  return await submitCreative(api, {
851
953
  path: "/v1/creatives/videos",
852
954
  estimatedSeconds: 300,
853
- input
955
+ input,
956
+ transport
854
957
  });
855
958
  } catch (e) {
856
959
  throw translateError(e);
@@ -861,7 +964,8 @@ function registerCreativeTools(server, api) {
861
964
  return await submitCreative(api, {
862
965
  path: "/v1/creatives/images",
863
966
  estimatedSeconds: 60,
864
- input
967
+ input,
968
+ transport
865
969
  });
866
970
  } catch (e) {
867
971
  throw translateError(e);
@@ -872,7 +976,8 @@ function registerCreativeTools(server, api) {
872
976
  return await submitCreative(api, {
873
977
  path: "/v1/creatives/landing",
874
978
  estimatedSeconds: 60,
875
- input
979
+ input,
980
+ transport
876
981
  });
877
982
  } catch (e) {
878
983
  throw translateError(e);
@@ -954,7 +1059,8 @@ function registerPublishTools(server, api) {
954
1059
  __name(registerPublishTools, "registerPublishTools");
955
1060
 
956
1061
  // src/server.ts
957
- function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
1062
+ function createServer(opts) {
1063
+ const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
958
1064
  const server = new McpServer({
959
1065
  name: "productmaker",
960
1066
  version: MCP_VERSION
@@ -967,8 +1073,13 @@ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
967
1073
  }
968
1074
  });
969
1075
  const api = new ApiClient(apiBaseUrl, apiKey);
970
- registerTaskTools(server, api, proxyBaseUrl);
971
- registerCreativeTools(server, api);
1076
+ registerTaskTools(server, api, {
1077
+ proxyBaseUrl,
1078
+ transport
1079
+ });
1080
+ registerCreativeTools(server, api, {
1081
+ transport
1082
+ });
972
1083
  registerConnectionTools(server, api);
973
1084
  registerPublishTools(server, api);
974
1085
  return server;
@@ -1010,7 +1121,11 @@ async function main() {
1010
1121
  console.error(e.message);
1011
1122
  process.exit(1);
1012
1123
  }
1013
- const server = createServer(apiKey, apiBaseUrl);
1124
+ const server = createServer({
1125
+ apiKey,
1126
+ apiBaseUrl,
1127
+ transport: "stdio"
1128
+ });
1014
1129
  await server.connect(new StdioServerTransport());
1015
1130
  }
1016
1131
  __name(main, "main");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@productmaker/mcp",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "bin": {