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