@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/http.js
CHANGED
|
@@ -4,6 +4,8 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
|
|
|
4
4
|
// src/http.ts
|
|
5
5
|
import express from "express";
|
|
6
6
|
import rateLimit from "express-rate-limit";
|
|
7
|
+
import { Readable } from "stream";
|
|
8
|
+
import { pipeline } from "stream/promises";
|
|
7
9
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
8
10
|
|
|
9
11
|
// src/server.ts
|
|
@@ -36,14 +38,14 @@ var ApiClient = class {
|
|
|
36
38
|
Authorization: `Bearer ${this.apiKey}`
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
|
-
async getJson(
|
|
40
|
-
const res = await fetch(`${this.baseUrl}${
|
|
41
|
+
async getJson(path2) {
|
|
42
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
41
43
|
headers: this.authHeaders()
|
|
42
44
|
});
|
|
43
45
|
return this.parse(res);
|
|
44
46
|
}
|
|
45
|
-
async postJson(
|
|
46
|
-
const res = await fetch(`${this.baseUrl}${
|
|
47
|
+
async postJson(path2, body) {
|
|
48
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
47
49
|
method: "POST",
|
|
48
50
|
headers: {
|
|
49
51
|
...this.authHeaders(),
|
|
@@ -53,8 +55,8 @@ var ApiClient = class {
|
|
|
53
55
|
});
|
|
54
56
|
return this.parse(res);
|
|
55
57
|
}
|
|
56
|
-
async patchJson(
|
|
57
|
-
const res = await fetch(`${this.baseUrl}${
|
|
58
|
+
async patchJson(path2, body) {
|
|
59
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
58
60
|
method: "PATCH",
|
|
59
61
|
headers: {
|
|
60
62
|
...this.authHeaders(),
|
|
@@ -64,27 +66,32 @@ var ApiClient = class {
|
|
|
64
66
|
});
|
|
65
67
|
return this.parse(res);
|
|
66
68
|
}
|
|
67
|
-
async deleteJson(
|
|
68
|
-
const res = await fetch(`${this.baseUrl}${
|
|
69
|
+
async deleteJson(path2) {
|
|
70
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
69
71
|
method: "DELETE",
|
|
70
72
|
headers: this.authHeaders()
|
|
71
73
|
});
|
|
72
74
|
return this.parse(res);
|
|
73
75
|
}
|
|
74
|
-
async postMultipart(
|
|
76
|
+
async postMultipart(path2, 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);
|
|
86
93
|
}
|
|
87
|
-
const res = await fetch(`${this.baseUrl}${
|
|
94
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
88
95
|
method: "POST",
|
|
89
96
|
headers: this.authHeaders(),
|
|
90
97
|
body: form
|
|
@@ -106,18 +113,40 @@ var ApiClient = class {
|
|
|
106
113
|
};
|
|
107
114
|
|
|
108
115
|
// src/index.ts
|
|
109
|
-
var MCP_VERSION = "0.
|
|
116
|
+
var MCP_VERSION = "0.2.0";
|
|
110
117
|
|
|
111
118
|
// src/tools/tasks.ts
|
|
112
119
|
import { z as z2 } from "zod";
|
|
113
120
|
|
|
114
121
|
// src/resolve-image.ts
|
|
122
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
123
|
+
import { promises as fs, createReadStream } from "fs";
|
|
124
|
+
import * as path from "path";
|
|
125
|
+
import * as os from "os";
|
|
115
126
|
var MAX_BYTES = 20 * 1024 * 1024;
|
|
116
|
-
var
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
var IMAGE_FORMATS = [
|
|
128
|
+
{
|
|
129
|
+
mime: "image/jpeg",
|
|
130
|
+
exts: [
|
|
131
|
+
".jpg",
|
|
132
|
+
".jpeg"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
mime: "image/png",
|
|
137
|
+
exts: [
|
|
138
|
+
".png"
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
mime: "image/webp",
|
|
143
|
+
exts: [
|
|
144
|
+
".webp"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
];
|
|
148
|
+
var ALLOWED_MIMES = new Set(IMAGE_FORMATS.map((f) => f.mime));
|
|
149
|
+
var ALLOWED_EXTS = new Set(IMAGE_FORMATS.flatMap((f) => f.exts));
|
|
121
150
|
var ResolveImageError = class extends Error {
|
|
122
151
|
static {
|
|
123
152
|
__name(this, "ResolveImageError");
|
|
@@ -146,64 +175,177 @@ function isPrivateHost(host) {
|
|
|
146
175
|
if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true;
|
|
147
176
|
if (host.startsWith("169.254.")) return true;
|
|
148
177
|
if (host === "[::1]" || host === "::1") return true;
|
|
178
|
+
if (/^\[?0*:(?:0*:){6}0*1\]?$/i.test(host)) return true;
|
|
179
|
+
const ipv4Mapped = host.match(/^\[?::ffff:([0-9a-f.:]+)\]?$/i);
|
|
180
|
+
if (ipv4Mapped && ipv4Mapped[1]) {
|
|
181
|
+
const inner = ipv4Mapped[1];
|
|
182
|
+
if (inner.includes(".")) return isPrivateHost(inner);
|
|
183
|
+
const parts = inner.split(":").map((h) => parseInt(h, 16));
|
|
184
|
+
if (parts.length === 2 && parts.every((n) => !Number.isNaN(n))) {
|
|
185
|
+
const bytes = [
|
|
186
|
+
parts[0] >> 8,
|
|
187
|
+
parts[0] & 255,
|
|
188
|
+
parts[1] >> 8,
|
|
189
|
+
parts[1] & 255
|
|
190
|
+
];
|
|
191
|
+
return isPrivateHost(bytes.join("."));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
149
194
|
if (/^\[?f[cd][0-9a-f]{2}:/i.test(host)) return true;
|
|
150
195
|
if (/^\[?fe[89ab][0-9a-f]:/i.test(host)) return true;
|
|
151
196
|
return false;
|
|
152
197
|
}
|
|
153
198
|
__name(isPrivateHost, "isPrivateHost");
|
|
154
|
-
async function
|
|
155
|
-
|
|
156
|
-
|
|
199
|
+
async function assertHostResolvesPublic(hostname) {
|
|
200
|
+
let records;
|
|
201
|
+
try {
|
|
202
|
+
records = await dnsLookup(hostname, {
|
|
203
|
+
all: true
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
if (process.env.NODE_ENV === "test" || process.env.VITEST) return;
|
|
207
|
+
throw new ResolveImageError("INVALID_INPUT", `imageUrl DNS lookup failed for ${hostname}`);
|
|
157
208
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
u = new URL(input.imageUrl);
|
|
162
|
-
} catch {
|
|
163
|
-
throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
|
|
209
|
+
for (const { address } of records) {
|
|
210
|
+
if (isPrivateHost(address)) {
|
|
211
|
+
throw new ResolveImageError("INVALID_INPUT", `imageUrl host resolves to a private address (${address})`);
|
|
164
212
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
__name(assertHostResolvesPublic, "assertHostResolvesPublic");
|
|
216
|
+
function expandUserPath(raw) {
|
|
217
|
+
let s = raw.trim();
|
|
218
|
+
if (s === "~") return os.homedir();
|
|
219
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) s = path.join(os.homedir(), s.slice(2));
|
|
220
|
+
s = s.replace(/^\$HOME(?=$|[/\\])/, os.homedir());
|
|
221
|
+
s = s.replace(/^%USERPROFILE%(?=$|[/\\])/i, os.homedir());
|
|
222
|
+
return s;
|
|
223
|
+
}
|
|
224
|
+
__name(expandUserPath, "expandUserPath");
|
|
225
|
+
async function readFileWithCap(absPath, max) {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let total = 0;
|
|
228
|
+
let exceeded = false;
|
|
229
|
+
const stream = createReadStream(absPath);
|
|
230
|
+
try {
|
|
231
|
+
for await (const chunk of stream) {
|
|
232
|
+
total += chunk.length;
|
|
233
|
+
if (total > max) {
|
|
234
|
+
exceeded = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
chunks.push(chunk);
|
|
175
238
|
}
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
if (
|
|
179
|
-
|
|
239
|
+
} catch (e) {
|
|
240
|
+
const code = e.code;
|
|
241
|
+
if (code === "ENOENT") throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", "file disappeared during read");
|
|
242
|
+
if (code === "EACCES" || code === "EPERM") throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot read file (${code})`);
|
|
243
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", e.message);
|
|
244
|
+
} finally {
|
|
245
|
+
if (!stream.destroyed) stream.destroy();
|
|
246
|
+
}
|
|
247
|
+
if (exceeded) throw new ResolveImageError("IMAGE_TOO_LARGE", `file exceeds ${max} bytes`);
|
|
248
|
+
return Buffer.concat(chunks, total);
|
|
249
|
+
}
|
|
250
|
+
__name(readFileWithCap, "readFileWithCap");
|
|
251
|
+
async function resolvePath(rawPath) {
|
|
252
|
+
const expanded = expandUserPath(rawPath);
|
|
253
|
+
const abs = path.resolve(expanded);
|
|
254
|
+
const ext = path.extname(abs).toLowerCase();
|
|
255
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
256
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `path extension ${ext || "(none)"} is not in the image allowlist (.jpg .jpeg .png .webp)`);
|
|
257
|
+
}
|
|
258
|
+
let stat;
|
|
259
|
+
try {
|
|
260
|
+
stat = await fs.stat(abs);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
const code = e.code;
|
|
263
|
+
if (code === "ENOENT") {
|
|
264
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", `no such file: ${abs}`);
|
|
180
265
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
184
|
-
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
185
|
-
const sniffed = sniffMime(buf);
|
|
186
|
-
if (!sniffed || sniffed !== ct) {
|
|
187
|
-
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
|
|
266
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
267
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot access ${abs} (${code})`);
|
|
188
268
|
}
|
|
189
|
-
|
|
190
|
-
buffer: buf,
|
|
191
|
-
mimetype: sniffed
|
|
192
|
-
};
|
|
269
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `stat failed: ${e.message}`);
|
|
193
270
|
}
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
271
|
+
if (!stat.isFile()) {
|
|
272
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_A_FILE", `${abs} is not a regular file`);
|
|
273
|
+
}
|
|
274
|
+
if (stat.size > MAX_BYTES) {
|
|
275
|
+
throw new ResolveImageError("IMAGE_TOO_LARGE", `${stat.size} bytes (cap ${MAX_BYTES})`);
|
|
276
|
+
}
|
|
277
|
+
const buf = await readFileWithCap(abs, MAX_BYTES);
|
|
278
|
+
const sniffed = sniffMime(buf);
|
|
279
|
+
if (!sniffed) {
|
|
280
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes do not match PNG/JPEG/WEBP for ${abs}`);
|
|
203
281
|
}
|
|
204
|
-
|
|
282
|
+
return {
|
|
283
|
+
buffer: buf,
|
|
284
|
+
mimetype: sniffed
|
|
285
|
+
};
|
|
205
286
|
}
|
|
206
|
-
__name(
|
|
287
|
+
__name(resolvePath, "resolvePath");
|
|
288
|
+
async function resolveImageUrl(rawUrl) {
|
|
289
|
+
let u;
|
|
290
|
+
try {
|
|
291
|
+
u = new URL(rawUrl);
|
|
292
|
+
} catch {
|
|
293
|
+
throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
|
|
294
|
+
}
|
|
295
|
+
if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
|
|
296
|
+
if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
|
|
297
|
+
await assertHostResolvesPublic(u.hostname);
|
|
298
|
+
let res;
|
|
299
|
+
try {
|
|
300
|
+
res = await fetch(rawUrl, {
|
|
301
|
+
signal: AbortSignal.timeout(3e4),
|
|
302
|
+
redirect: "error"
|
|
303
|
+
});
|
|
304
|
+
} catch (e) {
|
|
305
|
+
throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
|
|
306
|
+
}
|
|
307
|
+
if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
|
|
308
|
+
const cl = res.headers.get("content-length");
|
|
309
|
+
if (cl && Number(cl) > MAX_BYTES) {
|
|
310
|
+
throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
|
|
311
|
+
}
|
|
312
|
+
const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
|
|
313
|
+
if (!ALLOWED_MIMES.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
|
|
314
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
315
|
+
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
316
|
+
const sniffed = sniffMime(buf);
|
|
317
|
+
if (!sniffed || sniffed !== ct) {
|
|
318
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
buffer: buf,
|
|
322
|
+
mimetype: sniffed
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
__name(resolveImageUrl, "resolveImageUrl");
|
|
326
|
+
async function resolveImages(images, opts = {
|
|
327
|
+
transport: "http"
|
|
328
|
+
}) {
|
|
329
|
+
if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
|
|
330
|
+
if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
|
|
331
|
+
return Promise.all(images.map((ref) => resolveOne(ref, opts.transport)));
|
|
332
|
+
}
|
|
333
|
+
__name(resolveImages, "resolveImages");
|
|
334
|
+
async function resolveOne(ref, transport) {
|
|
335
|
+
const hasUrl = Boolean(ref.url);
|
|
336
|
+
const hasPath = Boolean(ref.path);
|
|
337
|
+
if (hasUrl === hasPath) {
|
|
338
|
+
throw new ResolveImageError("INVALID_INPUT", "each image entry must have exactly one of `url` or `path`");
|
|
339
|
+
}
|
|
340
|
+
if (hasPath) {
|
|
341
|
+
if (transport === "http") {
|
|
342
|
+
throw new ResolveImageError("PATH_NOT_SUPPORTED_IN_HTTP_MODE", "Local file paths are only available when running the MCP locally (npm). For the hosted endpoint at mcp.productmaker.app, pass a public HTTPS `url` instead, or ask the user to host the photo first.");
|
|
343
|
+
}
|
|
344
|
+
return resolvePath(ref.path);
|
|
345
|
+
}
|
|
346
|
+
return resolveImageUrl(ref.url);
|
|
347
|
+
}
|
|
348
|
+
__name(resolveOne, "resolveOne");
|
|
207
349
|
|
|
208
350
|
// src/status-poller.ts
|
|
209
351
|
var TERMINAL = /* @__PURE__ */ new Set([
|
|
@@ -231,14 +373,14 @@ async function pollStatus(fetcher, waitSeconds, signal, opts = {}) {
|
|
|
231
373
|
}
|
|
232
374
|
__name(pollStatus, "pollStatus");
|
|
233
375
|
function sleep(ms, signal) {
|
|
234
|
-
return new Promise((
|
|
376
|
+
return new Promise((resolve2, reject) => {
|
|
235
377
|
const onAbort = /* @__PURE__ */ __name(() => {
|
|
236
378
|
clearTimeout(t);
|
|
237
379
|
reject(new Error("aborted"));
|
|
238
380
|
}, "onAbort");
|
|
239
381
|
const t = setTimeout(() => {
|
|
240
382
|
signal?.removeEventListener("abort", onAbort);
|
|
241
|
-
|
|
383
|
+
resolve2();
|
|
242
384
|
}, ms);
|
|
243
385
|
signal?.addEventListener("abort", onAbort, {
|
|
244
386
|
once: true
|
|
@@ -257,6 +399,10 @@ var McpErrorCode = {
|
|
|
257
399
|
IMAGE_DOWNLOAD_FAILED: "IMAGE_DOWNLOAD_FAILED",
|
|
258
400
|
IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
|
|
259
401
|
IMAGE_TYPE_UNSUPPORTED: "IMAGE_TYPE_UNSUPPORTED",
|
|
402
|
+
IMAGE_PATH_NOT_FOUND: "IMAGE_PATH_NOT_FOUND",
|
|
403
|
+
IMAGE_PATH_NOT_READABLE: "IMAGE_PATH_NOT_READABLE",
|
|
404
|
+
IMAGE_PATH_NOT_A_FILE: "IMAGE_PATH_NOT_A_FILE",
|
|
405
|
+
PATH_NOT_SUPPORTED_IN_HTTP_MODE: "PATH_NOT_SUPPORTED_IN_HTTP_MODE",
|
|
260
406
|
TASK_NOT_FOUND: "TASK_NOT_FOUND",
|
|
261
407
|
TASK_NOT_READY: "TASK_NOT_READY",
|
|
262
408
|
NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
|
|
@@ -270,7 +416,11 @@ var McpErrorCode = {
|
|
|
270
416
|
var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
|
|
271
417
|
McpErrorCode.INVALID_INPUT,
|
|
272
418
|
McpErrorCode.INVALID_API_KEY,
|
|
273
|
-
McpErrorCode.REVOKED_API_KEY
|
|
419
|
+
McpErrorCode.REVOKED_API_KEY,
|
|
420
|
+
McpErrorCode.IMAGE_PATH_NOT_FOUND,
|
|
421
|
+
McpErrorCode.IMAGE_PATH_NOT_READABLE,
|
|
422
|
+
McpErrorCode.IMAGE_PATH_NOT_A_FILE,
|
|
423
|
+
McpErrorCode.PATH_NOT_SUPPORTED_IN_HTTP_MODE
|
|
274
424
|
]);
|
|
275
425
|
function mcp(code, message, extra = {}) {
|
|
276
426
|
const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
|
|
@@ -360,7 +510,38 @@ var VideoModelEnum = z.enum([
|
|
|
360
510
|
"grok",
|
|
361
511
|
"kling"
|
|
362
512
|
]);
|
|
363
|
-
var
|
|
513
|
+
var ImageCreativeStyleEnum = z.enum([
|
|
514
|
+
"studio",
|
|
515
|
+
"floating",
|
|
516
|
+
"ingredients",
|
|
517
|
+
"in_use"
|
|
518
|
+
]);
|
|
519
|
+
var ImageCreativeArchetypeEnum = z.enum([
|
|
520
|
+
"direct_response_product_ad",
|
|
521
|
+
"hero_outcome",
|
|
522
|
+
"offer_value_prop",
|
|
523
|
+
"social_proof_trust",
|
|
524
|
+
"product_clarity",
|
|
525
|
+
"problem_solution",
|
|
526
|
+
"lifestyle_identity"
|
|
527
|
+
]);
|
|
528
|
+
var ImageCreativeVisualTreatmentEnum = z.enum([
|
|
529
|
+
"performance_product_ad",
|
|
530
|
+
"premium_dtc_carousel"
|
|
531
|
+
]);
|
|
532
|
+
var ImageCreativeAspectRatioEnum = z.enum([
|
|
533
|
+
"1:1",
|
|
534
|
+
"4:5",
|
|
535
|
+
"9:16",
|
|
536
|
+
"16:9"
|
|
537
|
+
]);
|
|
538
|
+
var ImageRefSchema = z.object({
|
|
539
|
+
url: z.string().url().optional().describe('URL p\xFAblica HTTPS de la foto. Ej: una URL de Shopify CDN, Cloudinary, Imgur. \xDAsala cuando el usuario te diga "est\xE1 en X.com/foto.jpg" o cuando la foto ya est\xE9 hospedada.'),
|
|
540
|
+
path: z.string().min(1).optional().describe('Ruta del archivo en el computador del usuario. Ej: "/Users/maria/Desktop/foto.jpg", "~/Pictures/camiseta.png", "C:\\Users\\Maria\\Desktop\\foto.jpg". El MCP local la lee del disco y la sube. SOLO funciona cuando el MCP corre local (stdio). NO funciona en mcp.productmaker.app (HTTP) \u2014 ah\xED usa `url`.')
|
|
541
|
+
}).refine((v) => Boolean(v.url) !== Boolean(v.path), {
|
|
542
|
+
message: "each image entry must have exactly one of `url` or `path`"
|
|
543
|
+
});
|
|
544
|
+
var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe('1 a 5 fotos del MISMO producto. La PRIMERA es la principal (hero); las dem\xE1s son \xE1ngulos adicionales. NO mezcles productos distintos en una sola llamada.\n\nCada entrada tiene EXACTAMENTE uno de:\n\u2022 `url` \u2014 URL p\xFAblica HTTPS (Shopify CDN, Cloudinary). Prefi\xE9rela si la foto ya est\xE1 hospedada.\n\u2022 `path` \u2014 Ruta del archivo en el computador del usuario (ej. "/Users/maria/Desktop/foto.jpg", "~/Downloads/camiseta.png"). \xDAsala cuando el usuario te dijo d\xF3nde guard\xF3 la foto. Solo MCP local \u2014 el cliente web rechaza paths.\n\nSi el usuario te peg\xF3 la foto en el chat pero no te dio ni URL ni ruta: PREG\xDANTALE d\xF3nde la tiene guardada (Escritorio, Descargas, etc.). Si el `path` falla con IMAGE_PATH_NOT_FOUND, prueba 1-2 ubicaciones obvias alternativas (~/Desktop, ~/Downloads) antes de molestar al usuario. Tras 3 intentos fallidos, p\xEDdele la ruta completa.');
|
|
364
545
|
function asText(value) {
|
|
365
546
|
return {
|
|
366
547
|
content: [
|
|
@@ -384,16 +565,16 @@ function flatten(obj) {
|
|
|
384
565
|
}
|
|
385
566
|
__name(flatten, "flatten");
|
|
386
567
|
async function submitCreative(api, opts) {
|
|
387
|
-
const {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
imageBase64
|
|
568
|
+
const { images, ...rest } = opts.input;
|
|
569
|
+
const resolved = await resolveImages(images, {
|
|
570
|
+
transport: opts.transport
|
|
391
571
|
});
|
|
572
|
+
const parts = resolved.map((img, i) => ({
|
|
573
|
+
...img,
|
|
574
|
+
filename: `image-${i}.png`
|
|
575
|
+
}));
|
|
392
576
|
const r = await api.postMultipart(opts.path, {
|
|
393
|
-
|
|
394
|
-
...img,
|
|
395
|
-
filename: "image.png"
|
|
396
|
-
}
|
|
577
|
+
images: parts
|
|
397
578
|
}, flatten(rest));
|
|
398
579
|
return asText({
|
|
399
580
|
taskId: r.taskId,
|
|
@@ -403,8 +584,135 @@ async function submitCreative(api, opts) {
|
|
|
403
584
|
}
|
|
404
585
|
__name(submitCreative, "submitCreative");
|
|
405
586
|
|
|
587
|
+
// src/asset-proxy.ts
|
|
588
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
589
|
+
var TOKEN_TTL_SECONDS = 3600;
|
|
590
|
+
var NO_SECRET = "";
|
|
591
|
+
function b64url(buf) {
|
|
592
|
+
return buf.toString("base64url");
|
|
593
|
+
}
|
|
594
|
+
__name(b64url, "b64url");
|
|
595
|
+
function fromB64url(s) {
|
|
596
|
+
return Buffer.from(s, "base64url");
|
|
597
|
+
}
|
|
598
|
+
__name(fromB64url, "fromB64url");
|
|
599
|
+
var ALLOWED_PROXY_HOSTS = /\.r2\.cloudflarestorage\.com$/i;
|
|
600
|
+
function isAllowedProxyTarget(rawUrl) {
|
|
601
|
+
try {
|
|
602
|
+
const u = new URL(rawUrl);
|
|
603
|
+
if (u.protocol !== "https:") return false;
|
|
604
|
+
return ALLOWED_PROXY_HOSTS.test(u.hostname);
|
|
605
|
+
} catch {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
__name(isAllowedProxyTarget, "isAllowedProxyTarget");
|
|
610
|
+
function getProxySecret() {
|
|
611
|
+
const s = process.env.PM_PROXY_SECRET ?? NO_SECRET;
|
|
612
|
+
return s.length >= 32 ? s : NO_SECRET;
|
|
613
|
+
}
|
|
614
|
+
__name(getProxySecret, "getProxySecret");
|
|
615
|
+
function isProxyEnabled() {
|
|
616
|
+
return getProxySecret().length >= 32;
|
|
617
|
+
}
|
|
618
|
+
__name(isProxyEnabled, "isProxyEnabled");
|
|
619
|
+
var FILENAME_SAFE = /[^a-zA-Z0-9._-]+/g;
|
|
620
|
+
function sanitizeContentDispositionFilename(name) {
|
|
621
|
+
return name.replace(FILENAME_SAFE, "_").slice(0, 96) || "asset";
|
|
622
|
+
}
|
|
623
|
+
__name(sanitizeContentDispositionFilename, "sanitizeContentDispositionFilename");
|
|
624
|
+
function mintToken(payload, secret = getProxySecret()) {
|
|
625
|
+
if (secret.length < 32) throw new Error("PM_PROXY_SECRET too short");
|
|
626
|
+
if (!isAllowedProxyTarget(payload.url)) throw new Error("asset URL is outside the proxy allowlist");
|
|
627
|
+
const body = {
|
|
628
|
+
url: payload.url,
|
|
629
|
+
name: payload.name,
|
|
630
|
+
exp: Math.floor(Date.now() / 1e3) + TOKEN_TTL_SECONDS
|
|
631
|
+
};
|
|
632
|
+
const json = Buffer.from(JSON.stringify(body), "utf8");
|
|
633
|
+
const sig = createHmac("sha256", secret).update(json).digest();
|
|
634
|
+
return `${b64url(json)}.${b64url(sig)}`;
|
|
635
|
+
}
|
|
636
|
+
__name(mintToken, "mintToken");
|
|
637
|
+
function verifyToken(token, secret = getProxySecret()) {
|
|
638
|
+
if (secret.length < 32) return null;
|
|
639
|
+
const parts = token.split(".");
|
|
640
|
+
if (parts.length !== 2) return null;
|
|
641
|
+
let json;
|
|
642
|
+
let sig;
|
|
643
|
+
try {
|
|
644
|
+
json = fromB64url(parts[0]);
|
|
645
|
+
sig = fromB64url(parts[1]);
|
|
646
|
+
} catch {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const expected = createHmac("sha256", secret).update(json).digest();
|
|
650
|
+
if (expected.length !== sig.length) return null;
|
|
651
|
+
if (!timingSafeEqual(expected, sig)) return null;
|
|
652
|
+
let raw;
|
|
653
|
+
try {
|
|
654
|
+
raw = JSON.parse(json.toString("utf8"));
|
|
655
|
+
} catch {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
if (!isAssetPayload(raw)) return null;
|
|
659
|
+
if (raw.exp < Math.floor(Date.now() / 1e3)) return null;
|
|
660
|
+
return raw;
|
|
661
|
+
}
|
|
662
|
+
__name(verifyToken, "verifyToken");
|
|
663
|
+
function isAssetPayload(v) {
|
|
664
|
+
if (!v || typeof v !== "object") return false;
|
|
665
|
+
const o = v;
|
|
666
|
+
return typeof o.url === "string" && typeof o.name === "string" && typeof o.exp === "number";
|
|
667
|
+
}
|
|
668
|
+
__name(isAssetPayload, "isAssetPayload");
|
|
669
|
+
function buildProxyUrl(opts) {
|
|
670
|
+
const secret = getProxySecret();
|
|
671
|
+
if (!opts.proxyBaseUrl || !secret) return null;
|
|
672
|
+
if (!isAllowedProxyTarget(opts.r2Url)) return null;
|
|
673
|
+
const token = mintToken({
|
|
674
|
+
url: opts.r2Url,
|
|
675
|
+
name: opts.filename
|
|
676
|
+
}, secret);
|
|
677
|
+
return `${opts.proxyBaseUrl.replace(/\/+$/, "")}/assets/${token}/${encodeURIComponent(opts.filename)}`;
|
|
678
|
+
}
|
|
679
|
+
__name(buildProxyUrl, "buildProxyUrl");
|
|
680
|
+
|
|
406
681
|
// src/tools/task-outputs.ts
|
|
407
682
|
var WEBAPP_BASE = "https://productmaker.app";
|
|
683
|
+
function proxifyOutputs(outputs, proxyBaseUrl) {
|
|
684
|
+
if (!proxyBaseUrl || !isProxyEnabled()) return outputs;
|
|
685
|
+
const rewrite = /* @__PURE__ */ __name((url, filename) => buildProxyUrl({
|
|
686
|
+
r2Url: url,
|
|
687
|
+
filename,
|
|
688
|
+
proxyBaseUrl
|
|
689
|
+
}) ?? url, "rewrite");
|
|
690
|
+
return {
|
|
691
|
+
webappUrl: outputs.webappUrl,
|
|
692
|
+
images: outputs.images.map((img, i) => ({
|
|
693
|
+
...img,
|
|
694
|
+
url: rewrite(img.url, `image-${i}.png`)
|
|
695
|
+
})),
|
|
696
|
+
landing: {
|
|
697
|
+
sections: outputs.landing.sections.map((s) => ({
|
|
698
|
+
...s,
|
|
699
|
+
url: rewrite(s.url, `landing-${sanitizeFilename(s.role)}.png`)
|
|
700
|
+
}))
|
|
701
|
+
},
|
|
702
|
+
videos: outputs.videos.map((v) => ({
|
|
703
|
+
...v,
|
|
704
|
+
variants: v.variants.map((variant) => ({
|
|
705
|
+
...variant,
|
|
706
|
+
url: rewrite(variant.url, `video-${sanitizeFilename(v.angleKey)}-${sanitizeFilename(variant.kind)}.mp4`)
|
|
707
|
+
}))
|
|
708
|
+
}))
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
__name(proxifyOutputs, "proxifyOutputs");
|
|
712
|
+
function sanitizeFilename(s) {
|
|
713
|
+
return s.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64) || "asset";
|
|
714
|
+
}
|
|
715
|
+
__name(sanitizeFilename, "sanitizeFilename");
|
|
408
716
|
async function fetchTaskOutputs(api, taskId) {
|
|
409
717
|
const raw = await api.getJson(`/v1/results/${encodeURIComponent(taskId)}?signedUrls=true`);
|
|
410
718
|
const videos = (raw.videoGenerators ?? []).map((vg) => {
|
|
@@ -450,7 +758,7 @@ async function fetchTaskOutputs(api, taskId) {
|
|
|
450
758
|
}
|
|
451
759
|
__name(fetchTaskOutputs, "fetchTaskOutputs");
|
|
452
760
|
function escapeMdLabel(s) {
|
|
453
|
-
return s.replace(/[\[\]()]/g, "");
|
|
761
|
+
return s.replace(/[\[\]\(\)\{\}`<>\r\n\\]/g, " ").replace(/\s+/g, " ").trim();
|
|
454
762
|
}
|
|
455
763
|
__name(escapeMdLabel, "escapeMdLabel");
|
|
456
764
|
function renderOutputsMarkdown(outputs) {
|
|
@@ -479,13 +787,12 @@ __name(renderOutputsMarkdown, "renderOutputsMarkdown");
|
|
|
479
787
|
|
|
480
788
|
// src/tools/tasks.ts
|
|
481
789
|
var CreateProductTaskInput = z2.object({
|
|
482
|
-
|
|
483
|
-
imageBase64: ImageBase64.optional().describe("Imagen del producto en base64 (sin prefijo data:). Provee este O imageUrl (uno solo)."),
|
|
790
|
+
images: ImagesInput,
|
|
484
791
|
productUrl: z2.string().url().optional().describe("Opcional. URL de la p\xE1gina del producto (Shopify, AliExpress, Amazon, etc.) para enriquecer el contexto. Si se omite, la IA infiere todo desde la imagen."),
|
|
485
|
-
language: Lang.describe('C\xF3digo de idioma para el contenido generado. Ejemplos: "es"
|
|
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('
|
|
792
|
+
language: Lang.describe('C\xF3digo de idioma para el contenido generado. Ejemplos: "es", "en", "pt". REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas "es" por default.'),
|
|
793
|
+
country: Country.describe('C\xF3digo ISO del pa\xEDs objetivo. Ejemplos: "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago locales y tono. REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat.'),
|
|
794
|
+
price: z2.string().optional().describe('Precio que se mostrar\xE1 en la landing y video. Formato libre \u2014 ej. "$89.900", "USD 29.99", "MXN 599". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si te dice "t\xFA elige", om\xEDtelo y el backend sugiere uno.'),
|
|
795
|
+
offer: z2.string().optional().describe('Oferta o promoci\xF3n a destacar. Ej. "2x1", "Env\xEDo gratis hoy", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si te dice "t\xFA elige", om\xEDtelo.'),
|
|
489
796
|
videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual del video. "ugc" = creador hablando a c\xE1mara (default, mejor CTR LATAM). "cartoon_3d" = animaci\xF3n 3D. "product_only" = sin actor. "ai_dynamic" = cinematogr\xE1fico AI. Si se omite, default "ugc".'),
|
|
490
797
|
narrativeStyle: NarrativeStyleEnum.optional().describe('Opcional. Estructura narrativa. "review" = testimonial (default, alta conversi\xF3n). "problem_product_cta" = problema\u2192soluci\xF3n. "product_showcase" = features. "metaphor_product_cta" = met\xE1fora visual. "wearable_showcase" = para ropa/accesorios. Si se omite, default "review".'),
|
|
491
798
|
videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). "bytedance"/"seedance" = TikTok-style. "kling"/"wan" = alternativos. "infinitalk" = lipsync largo. "grok" = grok-imagine. Si se omite, el sistema escoge seg\xFAn costo/calidad.'),
|
|
@@ -519,10 +826,16 @@ var EditTaskDraftInput = z2.object({
|
|
|
519
826
|
scriptNotes: z2.string().max(2e3).optional()
|
|
520
827
|
})
|
|
521
828
|
});
|
|
522
|
-
function registerTaskTools(server, api) {
|
|
523
|
-
server.tool("create_product_task", 'Crea una tarea de producto desde una
|
|
829
|
+
function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
|
|
830
|
+
server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo)\n- multiAngleVideos (booleano)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
|
|
524
831
|
try {
|
|
525
|
-
const
|
|
832
|
+
const imgs = await resolveImages(input.images, {
|
|
833
|
+
transport
|
|
834
|
+
});
|
|
835
|
+
const parts = imgs.map((img, i) => ({
|
|
836
|
+
...img,
|
|
837
|
+
filename: `image-${i}.png`
|
|
838
|
+
}));
|
|
526
839
|
const fields = flatten({
|
|
527
840
|
"options.languageCode": input.language,
|
|
528
841
|
"options.country": input.country,
|
|
@@ -536,10 +849,7 @@ function registerTaskTools(server, api) {
|
|
|
536
849
|
url: input.productUrl
|
|
537
850
|
});
|
|
538
851
|
const r = await api.postMultipart("/v1/ingest", {
|
|
539
|
-
|
|
540
|
-
...img,
|
|
541
|
-
filename: "image.png"
|
|
542
|
-
}
|
|
852
|
+
images: parts
|
|
543
853
|
}, fields);
|
|
544
854
|
return asText({
|
|
545
855
|
taskId: r.taskId,
|
|
@@ -557,7 +867,8 @@ function registerTaskTools(server, api) {
|
|
|
557
867
|
const snapshot = await pollStatus(fetcher, input.waitSeconds ?? 0, signal);
|
|
558
868
|
if (snapshot.status !== "done") return asText(snapshot);
|
|
559
869
|
try {
|
|
560
|
-
const
|
|
870
|
+
const rawOutputs = await fetchTaskOutputs(api, input.taskId);
|
|
871
|
+
const outputs = proxifyOutputs(rawOutputs, proxyBaseUrl);
|
|
561
872
|
return {
|
|
562
873
|
content: [
|
|
563
874
|
{
|
|
@@ -589,8 +900,8 @@ function registerTaskTools(server, api) {
|
|
|
589
900
|
if (input.limit != null) qs.set("limit", String(input.limit));
|
|
590
901
|
if (input.offset != null) qs.set("offset", String(input.offset));
|
|
591
902
|
if (input.search) qs.set("search", input.search);
|
|
592
|
-
const
|
|
593
|
-
const r = await api.getJson(
|
|
903
|
+
const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
|
|
904
|
+
const r = await api.getJson(path2);
|
|
594
905
|
return asText(r);
|
|
595
906
|
} catch (e) {
|
|
596
907
|
throw translateError(e);
|
|
@@ -611,21 +922,21 @@ __name(registerTaskTools, "registerTaskTools");
|
|
|
611
922
|
|
|
612
923
|
// src/tools/creatives.ts
|
|
613
924
|
import { z as z3 } from "zod";
|
|
614
|
-
var
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
925
|
+
var CreativeRefs = {
|
|
926
|
+
images: ImagesInput,
|
|
927
|
+
language: Lang.describe('Idioma del contenido. Ej. "es", "en", "pt". REQUERIDO \u2014 si el usuario no lo dijo, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas un default.'),
|
|
928
|
+
country: Country.describe('Pa\xEDs objetivo ISO. Ej. "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago y tono. REQUERIDO \u2014 si el usuario no lo dijo, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat ni asumas un default.'),
|
|
929
|
+
price: z3.string().optional().describe('Precio en formato libre \u2014 ej. "$89.900", "USD 29.99". Opcional pero MUY IMPACTANTE para conversi\xF3n: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si el usuario te dice "t\xFA elige", omite el campo y la IA del backend sugiere uno.'),
|
|
930
|
+
offer: z3.string().optional().describe('Oferta a destacar \u2014 ej. "2x1", "Env\xEDo gratis", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si el usuario te dice "t\xFA elige", omite el campo.'),
|
|
931
|
+
buyerPersona: z3.string().optional().describe('Opcional. Descripci\xF3n del comprador objetivo. Ej. "Mujeres 35-50, profesionales urbanas". Si se omite, la IA infiere desde el \xE1ngulo y producto.'),
|
|
932
|
+
isCombo: z3.boolean().optional().describe("Opcional. Si true, el creativo presenta el producto como combo/pack. Default false.")
|
|
621
933
|
};
|
|
622
934
|
var VideoInput = z3.object({
|
|
623
|
-
|
|
624
|
-
imageBase64: ImageInputRefs.imageBase64,
|
|
935
|
+
images: CreativeRefs.images,
|
|
625
936
|
title: z3.string().min(1).describe('T\xEDtulo corto del producto. Aparece en la primera vista del video. Ej. "Crema Anti-Edad Premium".'),
|
|
626
937
|
primaryAngle: z3.string().min(1).describe('\xC1ngulo de venta principal (frase del beneficio). Ej. "Reduce arrugas en 14 d\xEDas" o "Para quienes odian las cremas grasosas".'),
|
|
627
|
-
language:
|
|
628
|
-
country:
|
|
938
|
+
language: CreativeRefs.language,
|
|
939
|
+
country: CreativeRefs.country,
|
|
629
940
|
aspectRatio: z3.enum([
|
|
630
941
|
"9:16",
|
|
631
942
|
"16:9",
|
|
@@ -636,71 +947,81 @@ var VideoInput = z3.object({
|
|
|
636
947
|
videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual. "ugc" (creador hablando, default), "cartoon_3d", "product_only", "ai_dynamic".'),
|
|
637
948
|
videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). Ver lista de valores v\xE1lidos.'),
|
|
638
949
|
features: z3.array(z3.string()).optional().describe('Opcional. Lista de features/beneficios clave a mencionar. Ej. ["resistente al agua", "bater\xEDa 24h", "carga inal\xE1mbrica"].'),
|
|
639
|
-
price:
|
|
640
|
-
offer:
|
|
950
|
+
price: CreativeRefs.price,
|
|
951
|
+
offer: CreativeRefs.offer,
|
|
641
952
|
cta: z3.string().optional().describe('Opcional. Call-to-action final del video. Ej. "Compra ahora", "Pide el tuyo hoy", "Link en bio". Si se omite, la IA escribe uno.'),
|
|
642
953
|
scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion. Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico".')
|
|
643
954
|
});
|
|
955
|
+
var imageStyleList = ImageCreativeStyleEnum.options.join(" | ");
|
|
956
|
+
var archetypeList = ImageCreativeArchetypeEnum.options.join(" | ");
|
|
957
|
+
var visualTreatmentList = ImageCreativeVisualTreatmentEnum.options.join(" | ");
|
|
958
|
+
var aspectRatioList = ImageCreativeAspectRatioEnum.options.join(" | ");
|
|
644
959
|
var ImageInput = z3.object({
|
|
645
|
-
|
|
646
|
-
imageBase64: ImageInputRefs.imageBase64,
|
|
960
|
+
images: CreativeRefs.images,
|
|
647
961
|
productTitle: z3.string().min(1).describe('Nombre del producto. Ej. "Crema Anti-Edad Premium".'),
|
|
648
962
|
primaryAngle: z3.string().min(1).describe('\xC1ngulo de venta principal. Ej. "Reduce arrugas en 14 d\xEDas".'),
|
|
649
|
-
|
|
650
|
-
|
|
963
|
+
buyerPersona: CreativeRefs.buyerPersona,
|
|
964
|
+
language: CreativeRefs.language,
|
|
965
|
+
country: CreativeRefs.country,
|
|
651
966
|
nVariants: z3.number().int().min(1).max(6).optional().describe("Opcional. N\xFAmero de variantes a generar (1-6). Default 3."),
|
|
652
967
|
imageModel: z3.string().optional().describe('Opcional. Modelo de imagen. Ej. "gpt-image-2", "nano-banana-2". Om\xEDtelo para usar el default optimizado por costo/calidad.'),
|
|
653
|
-
imageStyle:
|
|
968
|
+
imageStyle: ImageCreativeStyleEnum.optional().describe(`Opcional. Estilo del shot del producto. Valores: ${imageStyleList}. Si se omite, la IA escoge seg\xFAn el archetype.`),
|
|
969
|
+
creativeArchetype: ImageCreativeArchetypeEnum.optional().describe(`Opcional. Arquetipo CRO de la composici\xF3n. Valores: ${archetypeList}. Si se omite, la IA escoge seg\xFAn \xE1ngulo+oferta (ej. "offer_value_prop" cuando hay descuento, "social_proof_trust" cuando el \xE1ngulo es testimonial).`),
|
|
970
|
+
visualTreatment: ImageCreativeVisualTreatmentEnum.optional().describe(`Opcional. Tratamiento gr\xE1fico. Valores: ${visualTreatmentList} ("performance_product_ad" = dealer-style alto CTR para Meta/TikTok ads; "premium_dtc_carousel" = fotograf\xEDa limpia estilo Allbirds/Glossier). Si se omite, la IA escoge.`),
|
|
971
|
+
styleMix: z3.boolean().optional().describe("Opcional. Si true, las N variantes mezclan distintos imageStyle para A/B test. Si false/omitido, todas comparten estilo. Default false."),
|
|
972
|
+
aspectRatio: ImageCreativeAspectRatioEnum.optional().describe(`Opcional. Relaci\xF3n de aspecto. Valores: ${aspectRatioList}. Default "4:5" (vertical Instagram feed).`),
|
|
654
973
|
generateAdCopy: z3.boolean().optional().describe("Opcional. Si true, tambi\xE9n genera headline/body/CTA para el ad. Default false."),
|
|
974
|
+
pauseForAngleReview: z3.boolean().optional().describe("Opcional. Si true, pausa despu\xE9s de generar el \xE1ngulo y antes de las im\xE1genes para que el usuario edite v\xEDa edit_task_draft. Default false."),
|
|
655
975
|
features: z3.array(z3.string()).optional().describe('Opcional. Features/beneficios a destacar visualmente. Ej. ["resistente al agua", "ultra liviano"].'),
|
|
656
976
|
brandColors: z3.array(z3.string()).optional().describe('Opcional. Paleta de marca en hex. Ej. ["#FF6B35", "#004E89"]. Si se omite, la IA elige seg\xFAn el producto.'),
|
|
657
|
-
price:
|
|
658
|
-
|
|
977
|
+
price: CreativeRefs.price,
|
|
978
|
+
compareAtPrice: z3.string().optional().describe('Opcional. Precio "antes" de la oferta \u2014 solo para im\xE1genes con descuento. Ej. "$129.000" cuando price="$89.900". \xDAtil con creativeArchetype="offer_value_prop".'),
|
|
979
|
+
offer: CreativeRefs.offer,
|
|
980
|
+
isCombo: CreativeRefs.isCombo
|
|
659
981
|
});
|
|
660
982
|
var LandingInput = z3.object({
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
country: ImageInputRefs.country,
|
|
983
|
+
images: CreativeRefs.images,
|
|
984
|
+
language: CreativeRefs.language,
|
|
985
|
+
country: CreativeRefs.country,
|
|
665
986
|
salesAngle: z3.string().optional().describe('Opcional. \xC1ngulo de venta principal. Ej. "Reduce arrugas en 14 d\xEDas". Si se omite, la IA lo deduce de la imagen.'),
|
|
666
|
-
buyerPersona:
|
|
667
|
-
price:
|
|
668
|
-
offer:
|
|
987
|
+
buyerPersona: CreativeRefs.buyerPersona,
|
|
988
|
+
price: CreativeRefs.price,
|
|
989
|
+
offer: CreativeRefs.offer,
|
|
669
990
|
paymentMethods: z3.string().optional().describe('Opcional. M\xE9todos de pago a mostrar (lista libre). Ej. "Tarjeta, PSE, Nequi, Contraentrega". Si se omite, la IA usa los m\xE1s comunes del pa\xEDs.'),
|
|
670
|
-
isCombo:
|
|
991
|
+
isCombo: CreativeRefs.isCombo
|
|
671
992
|
});
|
|
672
|
-
function registerCreativeTools(server, api) {
|
|
673
|
-
server.tool("generate_video_creative",
|
|
993
|
+
function registerCreativeTools(server, api, { transport = "http" } = {}) {
|
|
994
|
+
server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, narrativeStyle, videoStyle, videoModel, features, cta, scriptNotes.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.', VideoInput.shape, async (input) => {
|
|
674
995
|
try {
|
|
675
996
|
return await submitCreative(api, {
|
|
676
997
|
path: "/v1/creatives/videos",
|
|
677
|
-
fileField: "media",
|
|
678
998
|
estimatedSeconds: 300,
|
|
679
|
-
input
|
|
999
|
+
input,
|
|
1000
|
+
transport
|
|
680
1001
|
});
|
|
681
1002
|
} catch (e) {
|
|
682
1003
|
throw translateError(e);
|
|
683
1004
|
}
|
|
684
1005
|
});
|
|
685
|
-
server.tool("generate_image_creatives",
|
|
1006
|
+
server.tool("generate_image_creatives", 'Genera N variantes de imagen creativa (ads est\xE1ticos). Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), productTitle, primaryAngle, language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario:\n- price y offer (se renderizan en la imagen \u2014 nunca los asumas; si dice "t\xFA elige", om\xEDtelos).\n- compareAtPrice si hay descuento (ej. "$129.000" para tachar).\n- creativeArchetype y visualTreatment si quiere un look espec\xEDfico (performance vs premium DTC). Si no le importa, om\xEDtelos.\n\nNO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente.\n\nOTROS OPCIONALES: buyerPersona, nVariants (default 3), imageModel, imageStyle, styleMix, aspectRatio (default 4:5), generateAdCopy, pauseForAngleReview, features, brandColors, isCombo.', ImageInput.shape, async (input) => {
|
|
686
1007
|
try {
|
|
687
1008
|
return await submitCreative(api, {
|
|
688
1009
|
path: "/v1/creatives/images",
|
|
689
|
-
fileField: "image",
|
|
690
1010
|
estimatedSeconds: 60,
|
|
691
|
-
input
|
|
1011
|
+
input,
|
|
1012
|
+
transport
|
|
692
1013
|
});
|
|
693
1014
|
} catch (e) {
|
|
694
1015
|
throw translateError(e);
|
|
695
1016
|
}
|
|
696
1017
|
});
|
|
697
|
-
server.tool("generate_landing",
|
|
1018
|
+
server.tool("generate_landing", 'Genera una landing page lista para Shopify. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price, offer y paymentMethods si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para esos campos, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults): salesAngle, buyerPersona, isCombo.', LandingInput.shape, async (input) => {
|
|
698
1019
|
try {
|
|
699
1020
|
return await submitCreative(api, {
|
|
700
1021
|
path: "/v1/creatives/landing",
|
|
701
|
-
fileField: "image",
|
|
702
1022
|
estimatedSeconds: 60,
|
|
703
|
-
input
|
|
1023
|
+
input,
|
|
1024
|
+
transport
|
|
704
1025
|
});
|
|
705
1026
|
} catch (e) {
|
|
706
1027
|
throw translateError(e);
|
|
@@ -782,7 +1103,8 @@ function registerPublishTools(server, api) {
|
|
|
782
1103
|
__name(registerPublishTools, "registerPublishTools");
|
|
783
1104
|
|
|
784
1105
|
// src/server.ts
|
|
785
|
-
function createServer(
|
|
1106
|
+
function createServer(opts) {
|
|
1107
|
+
const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
|
|
786
1108
|
const server = new McpServer({
|
|
787
1109
|
name: "productmaker",
|
|
788
1110
|
version: MCP_VERSION
|
|
@@ -795,8 +1117,13 @@ function createServer(apiKey, apiBaseUrl) {
|
|
|
795
1117
|
}
|
|
796
1118
|
});
|
|
797
1119
|
const api = new ApiClient(apiBaseUrl, apiKey);
|
|
798
|
-
registerTaskTools(server, api
|
|
799
|
-
|
|
1120
|
+
registerTaskTools(server, api, {
|
|
1121
|
+
proxyBaseUrl,
|
|
1122
|
+
transport
|
|
1123
|
+
});
|
|
1124
|
+
registerCreativeTools(server, api, {
|
|
1125
|
+
transport
|
|
1126
|
+
});
|
|
800
1127
|
registerConnectionTools(server, api);
|
|
801
1128
|
registerPublishTools(server, api);
|
|
802
1129
|
return server;
|
|
@@ -804,21 +1131,21 @@ function createServer(apiKey, apiBaseUrl) {
|
|
|
804
1131
|
__name(createServer, "createServer");
|
|
805
1132
|
|
|
806
1133
|
// src/validate-api-url.ts
|
|
807
|
-
function
|
|
1134
|
+
function assertSafePublicBaseUrl(envName, url) {
|
|
808
1135
|
let u;
|
|
809
1136
|
try {
|
|
810
1137
|
u = new URL(url);
|
|
811
1138
|
} catch {
|
|
812
|
-
throw new Error(
|
|
1139
|
+
throw new Error(`${envName} is not a valid URL: ${url}`);
|
|
813
1140
|
}
|
|
814
1141
|
if (u.protocol !== "https:") {
|
|
815
|
-
throw new Error(
|
|
1142
|
+
throw new Error(`${envName} must use https (got ${u.protocol})`);
|
|
816
1143
|
}
|
|
817
1144
|
if (isPrivateHost(u.hostname)) {
|
|
818
|
-
throw new Error(
|
|
1145
|
+
throw new Error(`${envName} must not point to a private/loopback host: ${u.hostname}`);
|
|
819
1146
|
}
|
|
820
1147
|
}
|
|
821
|
-
__name(
|
|
1148
|
+
__name(assertSafePublicBaseUrl, "assertSafePublicBaseUrl");
|
|
822
1149
|
|
|
823
1150
|
// src/http.ts
|
|
824
1151
|
var PM_API_KEY_PREFIX = "pm_live_";
|
|
@@ -847,6 +1174,73 @@ function extractApiKey(req) {
|
|
|
847
1174
|
return null;
|
|
848
1175
|
}
|
|
849
1176
|
__name(extractApiKey, "extractApiKey");
|
|
1177
|
+
var PROXY_FETCH_TIMEOUT_MS = 3e4;
|
|
1178
|
+
var INLINE_RENDERABLE_MIMES = /* @__PURE__ */ new Set([
|
|
1179
|
+
"image/jpeg",
|
|
1180
|
+
"image/png",
|
|
1181
|
+
"image/webp",
|
|
1182
|
+
"video/mp4"
|
|
1183
|
+
]);
|
|
1184
|
+
async function handleAsset(req, res) {
|
|
1185
|
+
if (!getProxySecret()) {
|
|
1186
|
+
res.status(503).json({
|
|
1187
|
+
error: "Proxy disabled"
|
|
1188
|
+
});
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const token = req.params.token;
|
|
1192
|
+
if (typeof token !== "string" || !token) {
|
|
1193
|
+
res.status(404).end();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const payload = verifyToken(token);
|
|
1197
|
+
if (!payload || !isAllowedProxyTarget(payload.url)) {
|
|
1198
|
+
res.status(404).end();
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
let upstream;
|
|
1202
|
+
try {
|
|
1203
|
+
upstream = await fetch(payload.url, {
|
|
1204
|
+
signal: AbortSignal.timeout(PROXY_FETCH_TIMEOUT_MS),
|
|
1205
|
+
redirect: "error"
|
|
1206
|
+
});
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
process.stderr.write(`mcp asset proxy fetch failed: ${String(err)}
|
|
1209
|
+
`);
|
|
1210
|
+
res.status(502).json({
|
|
1211
|
+
error: "Upstream fetch failed"
|
|
1212
|
+
});
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (!upstream.ok) {
|
|
1216
|
+
res.status(upstream.status === 404 ? 404 : 502).json({
|
|
1217
|
+
error: `Upstream HTTP ${upstream.status}`
|
|
1218
|
+
});
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
const rawCt = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
1222
|
+
const ct = rawCt.split(";")[0]?.trim() ?? "application/octet-stream";
|
|
1223
|
+
const cl = upstream.headers.get("content-length");
|
|
1224
|
+
const disposition = INLINE_RENDERABLE_MIMES.has(ct) ? "inline" : "attachment";
|
|
1225
|
+
res.status(200);
|
|
1226
|
+
res.setHeader("Content-Type", ct);
|
|
1227
|
+
if (cl) res.setHeader("Content-Length", cl);
|
|
1228
|
+
res.setHeader("Cache-Control", "private, max-age=300");
|
|
1229
|
+
res.setHeader("Content-Disposition", `${disposition}; filename="${sanitizeContentDispositionFilename(payload.name)}"`);
|
|
1230
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1231
|
+
if (!upstream.body) {
|
|
1232
|
+
res.end();
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
try {
|
|
1236
|
+
await pipeline(Readable.fromWeb(upstream.body), res);
|
|
1237
|
+
} catch (err) {
|
|
1238
|
+
process.stderr.write(`mcp asset proxy stream failed: ${String(err)}
|
|
1239
|
+
`);
|
|
1240
|
+
if (!res.writableEnded) res.end();
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
__name(handleAsset, "handleAsset");
|
|
850
1244
|
function createApp(opts) {
|
|
851
1245
|
const app = express();
|
|
852
1246
|
app.set("trust proxy", 1);
|
|
@@ -871,13 +1265,19 @@ function createApp(opts) {
|
|
|
871
1265
|
legacyHeaders: false
|
|
872
1266
|
}));
|
|
873
1267
|
app.use(express.json({
|
|
874
|
-
limit: "
|
|
1268
|
+
limit: "1mb"
|
|
875
1269
|
}));
|
|
876
1270
|
app.get("/healthz", (_req, res) => {
|
|
877
1271
|
res.json({
|
|
878
1272
|
ok: true
|
|
879
1273
|
});
|
|
880
1274
|
});
|
|
1275
|
+
app.get("/assets/:token/:filename?", rateLimit({
|
|
1276
|
+
windowMs: 6e4,
|
|
1277
|
+
max: 600,
|
|
1278
|
+
standardHeaders: true,
|
|
1279
|
+
legacyHeaders: false
|
|
1280
|
+
}), handleAsset);
|
|
881
1281
|
app.post("/mcp", async (req, res) => {
|
|
882
1282
|
const apiKey = extractApiKey(req);
|
|
883
1283
|
if (!apiKey) {
|
|
@@ -887,7 +1287,12 @@ function createApp(opts) {
|
|
|
887
1287
|
return;
|
|
888
1288
|
}
|
|
889
1289
|
try {
|
|
890
|
-
const server = createServer(
|
|
1290
|
+
const server = createServer({
|
|
1291
|
+
apiKey,
|
|
1292
|
+
apiBaseUrl: opts.apiBaseUrl,
|
|
1293
|
+
proxyBaseUrl: opts.proxyBaseUrl ?? null,
|
|
1294
|
+
transport: "http"
|
|
1295
|
+
});
|
|
891
1296
|
const transport = new StreamableHTTPServerTransport({
|
|
892
1297
|
sessionIdGenerator: void 0
|
|
893
1298
|
});
|
|
@@ -907,15 +1312,24 @@ __name(createApp, "createApp");
|
|
|
907
1312
|
if (process.env.MCP_HTTP_AUTOSTART !== "false") {
|
|
908
1313
|
const port = Number(process.env.PORT ?? 8080);
|
|
909
1314
|
const apiBaseUrl = process.env.PM_API_URL ?? "https://api.productmaker.app";
|
|
1315
|
+
const proxyBaseUrl = process.env.MCP_PROXY_BASE_URL ?? "https://mcp.productmaker.app";
|
|
1316
|
+
try {
|
|
1317
|
+
assertSafePublicBaseUrl("PM_API_URL", apiBaseUrl);
|
|
1318
|
+
} catch (e) {
|
|
1319
|
+
process.stderr.write(`${e.message}
|
|
1320
|
+
`);
|
|
1321
|
+
process.exit(1);
|
|
1322
|
+
}
|
|
910
1323
|
try {
|
|
911
|
-
|
|
1324
|
+
assertSafePublicBaseUrl("MCP_PROXY_BASE_URL", proxyBaseUrl);
|
|
912
1325
|
} catch (e) {
|
|
913
1326
|
process.stderr.write(`${e.message}
|
|
914
1327
|
`);
|
|
915
1328
|
process.exit(1);
|
|
916
1329
|
}
|
|
917
1330
|
createApp({
|
|
918
|
-
apiBaseUrl
|
|
1331
|
+
apiBaseUrl,
|
|
1332
|
+
proxyBaseUrl
|
|
919
1333
|
}).listen(port, () => {
|
|
920
1334
|
process.stdout.write(`mcp http listening on ${port}
|
|
921
1335
|
`);
|