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