@productmaker/mcp 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/http.js +210 -94
- package/dist/stdio.js +208 -93
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,16 @@ Header: `Authorization: Bearer pm_live_...`
|
|
|
45
45
|
|
|
46
46
|
Creative tools accept an `images` array of **1–5 photos of the same product**. The first entry is the hero; the rest are alternate angles. Don't mix different products in one call — that's a separate task each time.
|
|
47
47
|
|
|
48
|
-
Each entry
|
|
48
|
+
Each entry has **exactly one** of:
|
|
49
|
+
|
|
50
|
+
- `{ "url": "https://..." }` — public HTTPS URL (Shopify CDN, Cloudinary, etc.). Use this whenever the photo is already hosted.
|
|
51
|
+
- `{ "path": "/Users/maria/Desktop/foto.jpg" }` — local filesystem path. The MCP process reads the file from disk and uploads its bytes. `~/` and `%USERPROFILE%` are expanded. **Only works with the local stdio MCP (npm).** The hosted `mcp.productmaker.app` endpoint rejects `path` because Cloud Run has no access to your filesystem.
|
|
52
|
+
|
|
53
|
+
Mixed sources in the same call are fine: `[{ "path": "~/Desktop/hero.jpg" }, { "url": "https://..." }]`.
|
|
54
|
+
|
|
55
|
+
### Breaking change in 0.2.0
|
|
56
|
+
|
|
57
|
+
The `base64` field was removed. MCP JSON-RPC transports truncate large tool arguments (~1 MB) and host-attached chat images never reach the model as raw bytes — base64 in practice never worked. Use `path` (local MCP) or `url` (hosted MCP) instead.
|
|
49
58
|
|
|
50
59
|
## Downloading results
|
|
51
60
|
|
package/dist/http.js
CHANGED
|
@@ -38,14 +38,14 @@ var ApiClient = class {
|
|
|
38
38
|
Authorization: `Bearer ${this.apiKey}`
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
-
async getJson(
|
|
42
|
-
const res = await fetch(`${this.baseUrl}${
|
|
41
|
+
async getJson(path2) {
|
|
42
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
43
43
|
headers: this.authHeaders()
|
|
44
44
|
});
|
|
45
45
|
return this.parse(res);
|
|
46
46
|
}
|
|
47
|
-
async postJson(
|
|
48
|
-
const res = await fetch(`${this.baseUrl}${
|
|
47
|
+
async postJson(path2, body) {
|
|
48
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
49
49
|
method: "POST",
|
|
50
50
|
headers: {
|
|
51
51
|
...this.authHeaders(),
|
|
@@ -55,8 +55,8 @@ var ApiClient = class {
|
|
|
55
55
|
});
|
|
56
56
|
return this.parse(res);
|
|
57
57
|
}
|
|
58
|
-
async patchJson(
|
|
59
|
-
const res = await fetch(`${this.baseUrl}${
|
|
58
|
+
async patchJson(path2, body) {
|
|
59
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
60
60
|
method: "PATCH",
|
|
61
61
|
headers: {
|
|
62
62
|
...this.authHeaders(),
|
|
@@ -66,14 +66,14 @@ var ApiClient = class {
|
|
|
66
66
|
});
|
|
67
67
|
return this.parse(res);
|
|
68
68
|
}
|
|
69
|
-
async deleteJson(
|
|
70
|
-
const res = await fetch(`${this.baseUrl}${
|
|
69
|
+
async deleteJson(path2) {
|
|
70
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
71
71
|
method: "DELETE",
|
|
72
72
|
headers: this.authHeaders()
|
|
73
73
|
});
|
|
74
74
|
return this.parse(res);
|
|
75
75
|
}
|
|
76
|
-
async postMultipart(
|
|
76
|
+
async postMultipart(path2, files, fields) {
|
|
77
77
|
const form = new FormData();
|
|
78
78
|
for (const [name, value] of Object.entries(files)) {
|
|
79
79
|
const parts = Array.isArray(value) ? value : [
|
|
@@ -91,7 +91,7 @@ var ApiClient = class {
|
|
|
91
91
|
for (const [k, v] of Object.entries(fields)) {
|
|
92
92
|
if (v !== void 0) form.append(k, v);
|
|
93
93
|
}
|
|
94
|
-
const res = await fetch(`${this.baseUrl}${
|
|
94
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
95
95
|
method: "POST",
|
|
96
96
|
headers: this.authHeaders(),
|
|
97
97
|
body: form
|
|
@@ -113,19 +113,40 @@ var ApiClient = class {
|
|
|
113
113
|
};
|
|
114
114
|
|
|
115
115
|
// src/index.ts
|
|
116
|
-
var MCP_VERSION = "0.
|
|
116
|
+
var MCP_VERSION = "0.2.0";
|
|
117
117
|
|
|
118
118
|
// src/tools/tasks.ts
|
|
119
119
|
import { z as z2 } from "zod";
|
|
120
120
|
|
|
121
121
|
// src/resolve-image.ts
|
|
122
122
|
import { lookup as dnsLookup } from "dns/promises";
|
|
123
|
+
import { promises as fs, createReadStream } from "fs";
|
|
124
|
+
import * as path from "path";
|
|
125
|
+
import * as os from "os";
|
|
123
126
|
var MAX_BYTES = 20 * 1024 * 1024;
|
|
124
|
-
var
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
var IMAGE_FORMATS = [
|
|
128
|
+
{
|
|
129
|
+
mime: "image/jpeg",
|
|
130
|
+
exts: [
|
|
131
|
+
".jpg",
|
|
132
|
+
".jpeg"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
mime: "image/png",
|
|
137
|
+
exts: [
|
|
138
|
+
".png"
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
mime: "image/webp",
|
|
143
|
+
exts: [
|
|
144
|
+
".webp"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
];
|
|
148
|
+
var ALLOWED_MIMES = new Set(IMAGE_FORMATS.map((f) => f.mime));
|
|
149
|
+
var ALLOWED_EXTS = new Set(IMAGE_FORMATS.flatMap((f) => f.exts));
|
|
129
150
|
var ResolveImageError = class extends Error {
|
|
130
151
|
static {
|
|
131
152
|
__name(this, "ResolveImageError");
|
|
@@ -192,69 +213,139 @@ async function assertHostResolvesPublic(hostname) {
|
|
|
192
213
|
}
|
|
193
214
|
}
|
|
194
215
|
__name(assertHostResolvesPublic, "assertHostResolvesPublic");
|
|
195
|
-
|
|
216
|
+
function expandUserPath(raw) {
|
|
217
|
+
let s = raw.trim();
|
|
218
|
+
if (s === "~") return os.homedir();
|
|
219
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) s = path.join(os.homedir(), s.slice(2));
|
|
220
|
+
s = s.replace(/^\$HOME(?=$|[/\\])/, os.homedir());
|
|
221
|
+
s = s.replace(/^%USERPROFILE%(?=$|[/\\])/i, os.homedir());
|
|
222
|
+
return s;
|
|
223
|
+
}
|
|
224
|
+
__name(expandUserPath, "expandUserPath");
|
|
225
|
+
async function readFileWithCap(absPath, max) {
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let total = 0;
|
|
228
|
+
let exceeded = false;
|
|
229
|
+
const stream = createReadStream(absPath);
|
|
230
|
+
try {
|
|
231
|
+
for await (const chunk of stream) {
|
|
232
|
+
total += chunk.length;
|
|
233
|
+
if (total > max) {
|
|
234
|
+
exceeded = true;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
chunks.push(chunk);
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {
|
|
240
|
+
const code = e.code;
|
|
241
|
+
if (code === "ENOENT") throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", "file disappeared during read");
|
|
242
|
+
if (code === "EACCES" || code === "EPERM") throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot read file (${code})`);
|
|
243
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", e.message);
|
|
244
|
+
} finally {
|
|
245
|
+
if (!stream.destroyed) stream.destroy();
|
|
246
|
+
}
|
|
247
|
+
if (exceeded) throw new ResolveImageError("IMAGE_TOO_LARGE", `file exceeds ${max} bytes`);
|
|
248
|
+
return Buffer.concat(chunks, total);
|
|
249
|
+
}
|
|
250
|
+
__name(readFileWithCap, "readFileWithCap");
|
|
251
|
+
async function resolvePath(rawPath) {
|
|
252
|
+
const expanded = expandUserPath(rawPath);
|
|
253
|
+
const abs = path.resolve(expanded);
|
|
254
|
+
const ext = path.extname(abs).toLowerCase();
|
|
255
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
256
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `path extension ${ext || "(none)"} is not in the image allowlist (.jpg .jpeg .png .webp)`);
|
|
257
|
+
}
|
|
258
|
+
let stat;
|
|
259
|
+
try {
|
|
260
|
+
stat = await fs.stat(abs);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
const code = e.code;
|
|
263
|
+
if (code === "ENOENT") {
|
|
264
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", `no such file: ${abs}`);
|
|
265
|
+
}
|
|
266
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
267
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot access ${abs} (${code})`);
|
|
268
|
+
}
|
|
269
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `stat failed: ${e.message}`);
|
|
270
|
+
}
|
|
271
|
+
if (!stat.isFile()) {
|
|
272
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_A_FILE", `${abs} is not a regular file`);
|
|
273
|
+
}
|
|
274
|
+
if (stat.size > MAX_BYTES) {
|
|
275
|
+
throw new ResolveImageError("IMAGE_TOO_LARGE", `${stat.size} bytes (cap ${MAX_BYTES})`);
|
|
276
|
+
}
|
|
277
|
+
const buf = await readFileWithCap(abs, MAX_BYTES);
|
|
278
|
+
const sniffed = sniffMime(buf);
|
|
279
|
+
if (!sniffed) {
|
|
280
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes do not match PNG/JPEG/WEBP for ${abs}`);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
buffer: buf,
|
|
284
|
+
mimetype: sniffed
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
__name(resolvePath, "resolvePath");
|
|
288
|
+
async function resolveImageUrl(rawUrl) {
|
|
289
|
+
let u;
|
|
290
|
+
try {
|
|
291
|
+
u = new URL(rawUrl);
|
|
292
|
+
} catch {
|
|
293
|
+
throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
|
|
294
|
+
}
|
|
295
|
+
if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
|
|
296
|
+
if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
|
|
297
|
+
await assertHostResolvesPublic(u.hostname);
|
|
298
|
+
let res;
|
|
299
|
+
try {
|
|
300
|
+
res = await fetch(rawUrl, {
|
|
301
|
+
signal: AbortSignal.timeout(3e4),
|
|
302
|
+
redirect: "error"
|
|
303
|
+
});
|
|
304
|
+
} catch (e) {
|
|
305
|
+
throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
|
|
306
|
+
}
|
|
307
|
+
if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
|
|
308
|
+
const cl = res.headers.get("content-length");
|
|
309
|
+
if (cl && Number(cl) > MAX_BYTES) {
|
|
310
|
+
throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
|
|
311
|
+
}
|
|
312
|
+
const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
|
|
313
|
+
if (!ALLOWED_MIMES.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
|
|
314
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
315
|
+
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
316
|
+
const sniffed = sniffMime(buf);
|
|
317
|
+
if (!sniffed || sniffed !== ct) {
|
|
318
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
buffer: buf,
|
|
322
|
+
mimetype: sniffed
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
__name(resolveImageUrl, "resolveImageUrl");
|
|
326
|
+
async function resolveImages(images, opts = {
|
|
327
|
+
transport: "http"
|
|
328
|
+
}) {
|
|
196
329
|
if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
|
|
197
330
|
if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
|
|
198
|
-
return Promise.all(images.map((ref) =>
|
|
199
|
-
imageUrl: ref.url,
|
|
200
|
-
imageBase64: ref.base64
|
|
201
|
-
})));
|
|
331
|
+
return Promise.all(images.map((ref) => resolveOne(ref, opts.transport)));
|
|
202
332
|
}
|
|
203
333
|
__name(resolveImages, "resolveImages");
|
|
204
|
-
async function
|
|
205
|
-
|
|
206
|
-
|
|
334
|
+
async function resolveOne(ref, transport) {
|
|
335
|
+
const hasUrl = Boolean(ref.url);
|
|
336
|
+
const hasPath = Boolean(ref.path);
|
|
337
|
+
if (hasUrl === hasPath) {
|
|
338
|
+
throw new ResolveImageError("INVALID_INPUT", "each image entry must have exactly one of `url` or `path`");
|
|
207
339
|
}
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
u = new URL(input.imageUrl);
|
|
212
|
-
} catch {
|
|
213
|
-
throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
|
|
214
|
-
}
|
|
215
|
-
if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
|
|
216
|
-
if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
|
|
217
|
-
await assertHostResolvesPublic(u.hostname);
|
|
218
|
-
let res;
|
|
219
|
-
try {
|
|
220
|
-
res = await fetch(input.imageUrl, {
|
|
221
|
-
signal: AbortSignal.timeout(3e4),
|
|
222
|
-
redirect: "error"
|
|
223
|
-
});
|
|
224
|
-
} catch (e) {
|
|
225
|
-
throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
|
|
226
|
-
}
|
|
227
|
-
if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
|
|
228
|
-
const cl = res.headers.get("content-length");
|
|
229
|
-
if (cl && Number(cl) > MAX_BYTES) {
|
|
230
|
-
throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
|
|
231
|
-
}
|
|
232
|
-
const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
|
|
233
|
-
if (!ALLOWED.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
|
|
234
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
235
|
-
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
236
|
-
const sniffed = sniffMime(buf);
|
|
237
|
-
if (!sniffed || sniffed !== ct) {
|
|
238
|
-
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
|
|
340
|
+
if (hasPath) {
|
|
341
|
+
if (transport === "http") {
|
|
342
|
+
throw new ResolveImageError("PATH_NOT_SUPPORTED_IN_HTTP_MODE", "Local file paths are only available when running the MCP locally (npm). For the hosted endpoint at mcp.productmaker.app, pass a public HTTPS `url` instead, or ask the user to host the photo first.");
|
|
239
343
|
}
|
|
240
|
-
return
|
|
241
|
-
buffer: buf,
|
|
242
|
-
mimetype: sniffed
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
if (input.imageBase64) {
|
|
246
|
-
const buf = Buffer.from(input.imageBase64, "base64");
|
|
247
|
-
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
248
|
-
const mime = sniffMime(buf);
|
|
249
|
-
if (!mime) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", "cannot detect image type");
|
|
250
|
-
return {
|
|
251
|
-
buffer: buf,
|
|
252
|
-
mimetype: mime
|
|
253
|
-
};
|
|
344
|
+
return resolvePath(ref.path);
|
|
254
345
|
}
|
|
255
|
-
|
|
346
|
+
return resolveImageUrl(ref.url);
|
|
256
347
|
}
|
|
257
|
-
__name(
|
|
348
|
+
__name(resolveOne, "resolveOne");
|
|
258
349
|
|
|
259
350
|
// src/status-poller.ts
|
|
260
351
|
var TERMINAL = /* @__PURE__ */ new Set([
|
|
@@ -282,14 +373,14 @@ async function pollStatus(fetcher, waitSeconds, signal, opts = {}) {
|
|
|
282
373
|
}
|
|
283
374
|
__name(pollStatus, "pollStatus");
|
|
284
375
|
function sleep(ms, signal) {
|
|
285
|
-
return new Promise((
|
|
376
|
+
return new Promise((resolve2, reject) => {
|
|
286
377
|
const onAbort = /* @__PURE__ */ __name(() => {
|
|
287
378
|
clearTimeout(t);
|
|
288
379
|
reject(new Error("aborted"));
|
|
289
380
|
}, "onAbort");
|
|
290
381
|
const t = setTimeout(() => {
|
|
291
382
|
signal?.removeEventListener("abort", onAbort);
|
|
292
|
-
|
|
383
|
+
resolve2();
|
|
293
384
|
}, ms);
|
|
294
385
|
signal?.addEventListener("abort", onAbort, {
|
|
295
386
|
once: true
|
|
@@ -308,6 +399,10 @@ var McpErrorCode = {
|
|
|
308
399
|
IMAGE_DOWNLOAD_FAILED: "IMAGE_DOWNLOAD_FAILED",
|
|
309
400
|
IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
|
|
310
401
|
IMAGE_TYPE_UNSUPPORTED: "IMAGE_TYPE_UNSUPPORTED",
|
|
402
|
+
IMAGE_PATH_NOT_FOUND: "IMAGE_PATH_NOT_FOUND",
|
|
403
|
+
IMAGE_PATH_NOT_READABLE: "IMAGE_PATH_NOT_READABLE",
|
|
404
|
+
IMAGE_PATH_NOT_A_FILE: "IMAGE_PATH_NOT_A_FILE",
|
|
405
|
+
PATH_NOT_SUPPORTED_IN_HTTP_MODE: "PATH_NOT_SUPPORTED_IN_HTTP_MODE",
|
|
311
406
|
TASK_NOT_FOUND: "TASK_NOT_FOUND",
|
|
312
407
|
TASK_NOT_READY: "TASK_NOT_READY",
|
|
313
408
|
NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
|
|
@@ -321,7 +416,11 @@ var McpErrorCode = {
|
|
|
321
416
|
var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
|
|
322
417
|
McpErrorCode.INVALID_INPUT,
|
|
323
418
|
McpErrorCode.INVALID_API_KEY,
|
|
324
|
-
McpErrorCode.REVOKED_API_KEY
|
|
419
|
+
McpErrorCode.REVOKED_API_KEY,
|
|
420
|
+
McpErrorCode.IMAGE_PATH_NOT_FOUND,
|
|
421
|
+
McpErrorCode.IMAGE_PATH_NOT_READABLE,
|
|
422
|
+
McpErrorCode.IMAGE_PATH_NOT_A_FILE,
|
|
423
|
+
McpErrorCode.PATH_NOT_SUPPORTED_IN_HTTP_MODE
|
|
325
424
|
]);
|
|
326
425
|
function mcp(code, message, extra = {}) {
|
|
327
426
|
const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
|
|
@@ -436,14 +535,13 @@ var ImageCreativeAspectRatioEnum = z.enum([
|
|
|
436
535
|
"9:16",
|
|
437
536
|
"16:9"
|
|
438
537
|
]);
|
|
439
|
-
var ImageBase64 = z.string().max(75e5);
|
|
440
538
|
var ImageRefSchema = z.object({
|
|
441
|
-
url: z.string().url().optional().describe(
|
|
442
|
-
|
|
443
|
-
}).refine((v) => Boolean(v.url) !== Boolean(v.
|
|
444
|
-
message: "each image entry must have exactly one of `url` or `
|
|
539
|
+
url: z.string().url().optional().describe('URL p\xFAblica HTTPS de la foto. Ej: una URL de Shopify CDN, Cloudinary, Imgur. \xDAsala cuando el usuario te diga "est\xE1 en X.com/foto.jpg" o cuando la foto ya est\xE9 hospedada.'),
|
|
540
|
+
path: z.string().min(1).optional().describe('Ruta del archivo en el computador del usuario. Ej: "/Users/maria/Desktop/foto.jpg", "~/Pictures/camiseta.png", "C:\\Users\\Maria\\Desktop\\foto.jpg". El MCP local la lee del disco y la sube. SOLO funciona cuando el MCP corre local (stdio). NO funciona en mcp.productmaker.app (HTTP) \u2014 ah\xED usa `url`.')
|
|
541
|
+
}).refine((v) => Boolean(v.url) !== Boolean(v.path), {
|
|
542
|
+
message: "each image entry must have exactly one of `url` or `path`"
|
|
445
543
|
});
|
|
446
|
-
var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe(
|
|
544
|
+
var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe('1 a 5 fotos del MISMO producto. La PRIMERA es la principal (hero); las dem\xE1s son \xE1ngulos adicionales. NO mezcles productos distintos en una sola llamada.\n\nCada entrada tiene EXACTAMENTE uno de:\n\u2022 `url` \u2014 URL p\xFAblica HTTPS (Shopify CDN, Cloudinary). Prefi\xE9rela si la foto ya est\xE1 hospedada.\n\u2022 `path` \u2014 Ruta del archivo en el computador del usuario (ej. "/Users/maria/Desktop/foto.jpg", "~/Downloads/camiseta.png"). \xDAsala cuando el usuario te dijo d\xF3nde guard\xF3 la foto. Solo MCP local \u2014 el cliente web rechaza paths.\n\nSi el usuario te peg\xF3 la foto en el chat pero no te dio ni URL ni ruta: PREG\xDANTALE d\xF3nde la tiene guardada (Escritorio, Descargas, etc.). Si el `path` falla con IMAGE_PATH_NOT_FOUND, prueba 1-2 ubicaciones obvias alternativas (~/Desktop, ~/Downloads) antes de molestar al usuario. Tras 3 intentos fallidos, p\xEDdele la ruta completa.');
|
|
447
545
|
function asText(value) {
|
|
448
546
|
return {
|
|
449
547
|
content: [
|
|
@@ -468,7 +566,9 @@ function flatten(obj) {
|
|
|
468
566
|
__name(flatten, "flatten");
|
|
469
567
|
async function submitCreative(api, opts) {
|
|
470
568
|
const { images, ...rest } = opts.input;
|
|
471
|
-
const resolved = await resolveImages(images
|
|
569
|
+
const resolved = await resolveImages(images, {
|
|
570
|
+
transport: opts.transport
|
|
571
|
+
});
|
|
472
572
|
const parts = resolved.map((img, i) => ({
|
|
473
573
|
...img,
|
|
474
574
|
filename: `image-${i}.png`
|
|
@@ -726,10 +826,12 @@ var EditTaskDraftInput = z2.object({
|
|
|
726
826
|
scriptNotes: z2.string().max(2e3).optional()
|
|
727
827
|
})
|
|
728
828
|
});
|
|
729
|
-
function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
829
|
+
function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
|
|
730
830
|
server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo)\n- multiAngleVideos (booleano)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
|
|
731
831
|
try {
|
|
732
|
-
const imgs = await resolveImages(input.images
|
|
832
|
+
const imgs = await resolveImages(input.images, {
|
|
833
|
+
transport
|
|
834
|
+
});
|
|
733
835
|
const parts = imgs.map((img, i) => ({
|
|
734
836
|
...img,
|
|
735
837
|
filename: `image-${i}.png`
|
|
@@ -798,8 +900,8 @@ function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
|
798
900
|
if (input.limit != null) qs.set("limit", String(input.limit));
|
|
799
901
|
if (input.offset != null) qs.set("offset", String(input.offset));
|
|
800
902
|
if (input.search) qs.set("search", input.search);
|
|
801
|
-
const
|
|
802
|
-
const r = await api.getJson(
|
|
903
|
+
const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
|
|
904
|
+
const r = await api.getJson(path2);
|
|
803
905
|
return asText(r);
|
|
804
906
|
} catch (e) {
|
|
805
907
|
throw translateError(e);
|
|
@@ -888,13 +990,14 @@ var LandingInput = z3.object({
|
|
|
888
990
|
paymentMethods: z3.string().optional().describe('Opcional. M\xE9todos de pago a mostrar (lista libre). Ej. "Tarjeta, PSE, Nequi, Contraentrega". Si se omite, la IA usa los m\xE1s comunes del pa\xEDs.'),
|
|
889
991
|
isCombo: CreativeRefs.isCombo
|
|
890
992
|
});
|
|
891
|
-
function registerCreativeTools(server, api) {
|
|
993
|
+
function registerCreativeTools(server, api, { transport = "http" } = {}) {
|
|
892
994
|
server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, narrativeStyle, videoStyle, videoModel, features, cta, scriptNotes.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.', VideoInput.shape, async (input) => {
|
|
893
995
|
try {
|
|
894
996
|
return await submitCreative(api, {
|
|
895
997
|
path: "/v1/creatives/videos",
|
|
896
998
|
estimatedSeconds: 300,
|
|
897
|
-
input
|
|
999
|
+
input,
|
|
1000
|
+
transport
|
|
898
1001
|
});
|
|
899
1002
|
} catch (e) {
|
|
900
1003
|
throw translateError(e);
|
|
@@ -905,7 +1008,8 @@ function registerCreativeTools(server, api) {
|
|
|
905
1008
|
return await submitCreative(api, {
|
|
906
1009
|
path: "/v1/creatives/images",
|
|
907
1010
|
estimatedSeconds: 60,
|
|
908
|
-
input
|
|
1011
|
+
input,
|
|
1012
|
+
transport
|
|
909
1013
|
});
|
|
910
1014
|
} catch (e) {
|
|
911
1015
|
throw translateError(e);
|
|
@@ -916,7 +1020,8 @@ function registerCreativeTools(server, api) {
|
|
|
916
1020
|
return await submitCreative(api, {
|
|
917
1021
|
path: "/v1/creatives/landing",
|
|
918
1022
|
estimatedSeconds: 60,
|
|
919
|
-
input
|
|
1023
|
+
input,
|
|
1024
|
+
transport
|
|
920
1025
|
});
|
|
921
1026
|
} catch (e) {
|
|
922
1027
|
throw translateError(e);
|
|
@@ -998,7 +1103,8 @@ function registerPublishTools(server, api) {
|
|
|
998
1103
|
__name(registerPublishTools, "registerPublishTools");
|
|
999
1104
|
|
|
1000
1105
|
// src/server.ts
|
|
1001
|
-
function createServer(
|
|
1106
|
+
function createServer(opts) {
|
|
1107
|
+
const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
|
|
1002
1108
|
const server = new McpServer({
|
|
1003
1109
|
name: "productmaker",
|
|
1004
1110
|
version: MCP_VERSION
|
|
@@ -1011,8 +1117,13 @@ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
|
|
|
1011
1117
|
}
|
|
1012
1118
|
});
|
|
1013
1119
|
const api = new ApiClient(apiBaseUrl, apiKey);
|
|
1014
|
-
registerTaskTools(server, api,
|
|
1015
|
-
|
|
1120
|
+
registerTaskTools(server, api, {
|
|
1121
|
+
proxyBaseUrl,
|
|
1122
|
+
transport
|
|
1123
|
+
});
|
|
1124
|
+
registerCreativeTools(server, api, {
|
|
1125
|
+
transport
|
|
1126
|
+
});
|
|
1016
1127
|
registerConnectionTools(server, api);
|
|
1017
1128
|
registerPublishTools(server, api);
|
|
1018
1129
|
return server;
|
|
@@ -1154,7 +1265,7 @@ function createApp(opts) {
|
|
|
1154
1265
|
legacyHeaders: false
|
|
1155
1266
|
}));
|
|
1156
1267
|
app.use(express.json({
|
|
1157
|
-
limit: "
|
|
1268
|
+
limit: "1mb"
|
|
1158
1269
|
}));
|
|
1159
1270
|
app.get("/healthz", (_req, res) => {
|
|
1160
1271
|
res.json({
|
|
@@ -1176,7 +1287,12 @@ function createApp(opts) {
|
|
|
1176
1287
|
return;
|
|
1177
1288
|
}
|
|
1178
1289
|
try {
|
|
1179
|
-
const server = createServer(
|
|
1290
|
+
const server = createServer({
|
|
1291
|
+
apiKey,
|
|
1292
|
+
apiBaseUrl: opts.apiBaseUrl,
|
|
1293
|
+
proxyBaseUrl: opts.proxyBaseUrl ?? null,
|
|
1294
|
+
transport: "http"
|
|
1295
|
+
});
|
|
1180
1296
|
const transport = new StreamableHTTPServerTransport({
|
|
1181
1297
|
sessionIdGenerator: void 0
|
|
1182
1298
|
});
|
package/dist/stdio.js
CHANGED
|
@@ -35,14 +35,14 @@ var ApiClient = class {
|
|
|
35
35
|
Authorization: `Bearer ${this.apiKey}`
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
-
async getJson(
|
|
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,14 +63,14 @@ 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
75
|
for (const [name, value] of Object.entries(files)) {
|
|
76
76
|
const parts = Array.isArray(value) ? value : [
|
|
@@ -88,7 +88,7 @@ var ApiClient = class {
|
|
|
88
88
|
for (const [k, v] of Object.entries(fields)) {
|
|
89
89
|
if (v !== void 0) form.append(k, v);
|
|
90
90
|
}
|
|
91
|
-
const res = await fetch(`${this.baseUrl}${
|
|
91
|
+
const res = await fetch(`${this.baseUrl}${path2}`, {
|
|
92
92
|
method: "POST",
|
|
93
93
|
headers: this.authHeaders(),
|
|
94
94
|
body: form
|
|
@@ -110,19 +110,40 @@ var ApiClient = class {
|
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
// src/index.ts
|
|
113
|
-
var MCP_VERSION = "0.
|
|
113
|
+
var MCP_VERSION = "0.2.0";
|
|
114
114
|
|
|
115
115
|
// src/tools/tasks.ts
|
|
116
116
|
import { z as z2 } from "zod";
|
|
117
117
|
|
|
118
118
|
// src/resolve-image.ts
|
|
119
119
|
import { lookup as dnsLookup } from "dns/promises";
|
|
120
|
+
import { promises as fs, createReadStream } from "fs";
|
|
121
|
+
import * as path from "path";
|
|
122
|
+
import * as os from "os";
|
|
120
123
|
var MAX_BYTES = 20 * 1024 * 1024;
|
|
121
|
-
var
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
var IMAGE_FORMATS = [
|
|
125
|
+
{
|
|
126
|
+
mime: "image/jpeg",
|
|
127
|
+
exts: [
|
|
128
|
+
".jpg",
|
|
129
|
+
".jpeg"
|
|
130
|
+
]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
mime: "image/png",
|
|
134
|
+
exts: [
|
|
135
|
+
".png"
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
mime: "image/webp",
|
|
140
|
+
exts: [
|
|
141
|
+
".webp"
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
];
|
|
145
|
+
var ALLOWED_MIMES = new Set(IMAGE_FORMATS.map((f) => f.mime));
|
|
146
|
+
var ALLOWED_EXTS = new Set(IMAGE_FORMATS.flatMap((f) => f.exts));
|
|
126
147
|
var ResolveImageError = class extends Error {
|
|
127
148
|
static {
|
|
128
149
|
__name(this, "ResolveImageError");
|
|
@@ -189,69 +210,139 @@ async function assertHostResolvesPublic(hostname) {
|
|
|
189
210
|
}
|
|
190
211
|
}
|
|
191
212
|
__name(assertHostResolvesPublic, "assertHostResolvesPublic");
|
|
192
|
-
|
|
213
|
+
function expandUserPath(raw) {
|
|
214
|
+
let s = raw.trim();
|
|
215
|
+
if (s === "~") return os.homedir();
|
|
216
|
+
if (s.startsWith("~/") || s.startsWith("~\\")) s = path.join(os.homedir(), s.slice(2));
|
|
217
|
+
s = s.replace(/^\$HOME(?=$|[/\\])/, os.homedir());
|
|
218
|
+
s = s.replace(/^%USERPROFILE%(?=$|[/\\])/i, os.homedir());
|
|
219
|
+
return s;
|
|
220
|
+
}
|
|
221
|
+
__name(expandUserPath, "expandUserPath");
|
|
222
|
+
async function readFileWithCap(absPath, max) {
|
|
223
|
+
const chunks = [];
|
|
224
|
+
let total = 0;
|
|
225
|
+
let exceeded = false;
|
|
226
|
+
const stream = createReadStream(absPath);
|
|
227
|
+
try {
|
|
228
|
+
for await (const chunk of stream) {
|
|
229
|
+
total += chunk.length;
|
|
230
|
+
if (total > max) {
|
|
231
|
+
exceeded = true;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
chunks.push(chunk);
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
const code = e.code;
|
|
238
|
+
if (code === "ENOENT") throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", "file disappeared during read");
|
|
239
|
+
if (code === "EACCES" || code === "EPERM") throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot read file (${code})`);
|
|
240
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", e.message);
|
|
241
|
+
} finally {
|
|
242
|
+
if (!stream.destroyed) stream.destroy();
|
|
243
|
+
}
|
|
244
|
+
if (exceeded) throw new ResolveImageError("IMAGE_TOO_LARGE", `file exceeds ${max} bytes`);
|
|
245
|
+
return Buffer.concat(chunks, total);
|
|
246
|
+
}
|
|
247
|
+
__name(readFileWithCap, "readFileWithCap");
|
|
248
|
+
async function resolvePath(rawPath) {
|
|
249
|
+
const expanded = expandUserPath(rawPath);
|
|
250
|
+
const abs = path.resolve(expanded);
|
|
251
|
+
const ext = path.extname(abs).toLowerCase();
|
|
252
|
+
if (!ALLOWED_EXTS.has(ext)) {
|
|
253
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `path extension ${ext || "(none)"} is not in the image allowlist (.jpg .jpeg .png .webp)`);
|
|
254
|
+
}
|
|
255
|
+
let stat;
|
|
256
|
+
try {
|
|
257
|
+
stat = await fs.stat(abs);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
const code = e.code;
|
|
260
|
+
if (code === "ENOENT") {
|
|
261
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_FOUND", `no such file: ${abs}`);
|
|
262
|
+
}
|
|
263
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
264
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `cannot access ${abs} (${code})`);
|
|
265
|
+
}
|
|
266
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_READABLE", `stat failed: ${e.message}`);
|
|
267
|
+
}
|
|
268
|
+
if (!stat.isFile()) {
|
|
269
|
+
throw new ResolveImageError("IMAGE_PATH_NOT_A_FILE", `${abs} is not a regular file`);
|
|
270
|
+
}
|
|
271
|
+
if (stat.size > MAX_BYTES) {
|
|
272
|
+
throw new ResolveImageError("IMAGE_TOO_LARGE", `${stat.size} bytes (cap ${MAX_BYTES})`);
|
|
273
|
+
}
|
|
274
|
+
const buf = await readFileWithCap(abs, MAX_BYTES);
|
|
275
|
+
const sniffed = sniffMime(buf);
|
|
276
|
+
if (!sniffed) {
|
|
277
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes do not match PNG/JPEG/WEBP for ${abs}`);
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
buffer: buf,
|
|
281
|
+
mimetype: sniffed
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
__name(resolvePath, "resolvePath");
|
|
285
|
+
async function resolveImageUrl(rawUrl) {
|
|
286
|
+
let u;
|
|
287
|
+
try {
|
|
288
|
+
u = new URL(rawUrl);
|
|
289
|
+
} catch {
|
|
290
|
+
throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
|
|
291
|
+
}
|
|
292
|
+
if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
|
|
293
|
+
if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
|
|
294
|
+
await assertHostResolvesPublic(u.hostname);
|
|
295
|
+
let res;
|
|
296
|
+
try {
|
|
297
|
+
res = await fetch(rawUrl, {
|
|
298
|
+
signal: AbortSignal.timeout(3e4),
|
|
299
|
+
redirect: "error"
|
|
300
|
+
});
|
|
301
|
+
} catch (e) {
|
|
302
|
+
throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
|
|
303
|
+
}
|
|
304
|
+
if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
|
|
305
|
+
const cl = res.headers.get("content-length");
|
|
306
|
+
if (cl && Number(cl) > MAX_BYTES) {
|
|
307
|
+
throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
|
|
308
|
+
}
|
|
309
|
+
const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
|
|
310
|
+
if (!ALLOWED_MIMES.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
|
|
311
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
312
|
+
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
313
|
+
const sniffed = sniffMime(buf);
|
|
314
|
+
if (!sniffed || sniffed !== ct) {
|
|
315
|
+
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
buffer: buf,
|
|
319
|
+
mimetype: sniffed
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
__name(resolveImageUrl, "resolveImageUrl");
|
|
323
|
+
async function resolveImages(images, opts = {
|
|
324
|
+
transport: "http"
|
|
325
|
+
}) {
|
|
193
326
|
if (!images.length) throw new ResolveImageError("INVALID_INPUT", "images: at least one entry required");
|
|
194
327
|
if (images.length > 5) throw new ResolveImageError("INVALID_INPUT", "images: max 5 per request");
|
|
195
|
-
return Promise.all(images.map((ref) =>
|
|
196
|
-
imageUrl: ref.url,
|
|
197
|
-
imageBase64: ref.base64
|
|
198
|
-
})));
|
|
328
|
+
return Promise.all(images.map((ref) => resolveOne(ref, opts.transport)));
|
|
199
329
|
}
|
|
200
330
|
__name(resolveImages, "resolveImages");
|
|
201
|
-
async function
|
|
202
|
-
|
|
203
|
-
|
|
331
|
+
async function resolveOne(ref, transport) {
|
|
332
|
+
const hasUrl = Boolean(ref.url);
|
|
333
|
+
const hasPath = Boolean(ref.path);
|
|
334
|
+
if (hasUrl === hasPath) {
|
|
335
|
+
throw new ResolveImageError("INVALID_INPUT", "each image entry must have exactly one of `url` or `path`");
|
|
204
336
|
}
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
u = new URL(input.imageUrl);
|
|
209
|
-
} catch {
|
|
210
|
-
throw new ResolveImageError("INVALID_INPUT", "imageUrl is not a valid URL");
|
|
337
|
+
if (hasPath) {
|
|
338
|
+
if (transport === "http") {
|
|
339
|
+
throw new ResolveImageError("PATH_NOT_SUPPORTED_IN_HTTP_MODE", "Local file paths are only available when running the MCP locally (npm). For the hosted endpoint at mcp.productmaker.app, pass a public HTTPS `url` instead, or ask the user to host the photo first.");
|
|
211
340
|
}
|
|
212
|
-
|
|
213
|
-
if (isPrivateHost(u.hostname)) throw new ResolveImageError("INVALID_INPUT", "imageUrl host not allowed");
|
|
214
|
-
await assertHostResolvesPublic(u.hostname);
|
|
215
|
-
let res;
|
|
216
|
-
try {
|
|
217
|
-
res = await fetch(input.imageUrl, {
|
|
218
|
-
signal: AbortSignal.timeout(3e4),
|
|
219
|
-
redirect: "error"
|
|
220
|
-
});
|
|
221
|
-
} catch (e) {
|
|
222
|
-
throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", e.message);
|
|
223
|
-
}
|
|
224
|
-
if (!res.ok) throw new ResolveImageError("IMAGE_DOWNLOAD_FAILED", `HTTP ${res.status}`);
|
|
225
|
-
const cl = res.headers.get("content-length");
|
|
226
|
-
if (cl && Number(cl) > MAX_BYTES) {
|
|
227
|
-
throw new ResolveImageError("IMAGE_TOO_LARGE", `content-length ${cl}`);
|
|
228
|
-
}
|
|
229
|
-
const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? "";
|
|
230
|
-
if (!ALLOWED.has(ct)) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `content-type ${ct}`);
|
|
231
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
232
|
-
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
233
|
-
const sniffed = sniffMime(buf);
|
|
234
|
-
if (!sniffed || sniffed !== ct) {
|
|
235
|
-
throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", `magic bytes (${sniffed ?? "unknown"}) do not match content-type (${ct})`);
|
|
236
|
-
}
|
|
237
|
-
return {
|
|
238
|
-
buffer: buf,
|
|
239
|
-
mimetype: sniffed
|
|
240
|
-
};
|
|
341
|
+
return resolvePath(ref.path);
|
|
241
342
|
}
|
|
242
|
-
|
|
243
|
-
const buf = Buffer.from(input.imageBase64, "base64");
|
|
244
|
-
if (buf.length > MAX_BYTES) throw new ResolveImageError("IMAGE_TOO_LARGE", `${buf.length} bytes`);
|
|
245
|
-
const mime = sniffMime(buf);
|
|
246
|
-
if (!mime) throw new ResolveImageError("IMAGE_TYPE_UNSUPPORTED", "cannot detect image type");
|
|
247
|
-
return {
|
|
248
|
-
buffer: buf,
|
|
249
|
-
mimetype: mime
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
throw new ResolveImageError("INVALID_INPUT", "imageUrl or imageBase64 required");
|
|
343
|
+
return resolveImageUrl(ref.url);
|
|
253
344
|
}
|
|
254
|
-
__name(
|
|
345
|
+
__name(resolveOne, "resolveOne");
|
|
255
346
|
|
|
256
347
|
// src/status-poller.ts
|
|
257
348
|
var TERMINAL = /* @__PURE__ */ new Set([
|
|
@@ -279,14 +370,14 @@ async function pollStatus(fetcher, waitSeconds, signal, opts = {}) {
|
|
|
279
370
|
}
|
|
280
371
|
__name(pollStatus, "pollStatus");
|
|
281
372
|
function sleep(ms, signal) {
|
|
282
|
-
return new Promise((
|
|
373
|
+
return new Promise((resolve2, reject) => {
|
|
283
374
|
const onAbort = /* @__PURE__ */ __name(() => {
|
|
284
375
|
clearTimeout(t);
|
|
285
376
|
reject(new Error("aborted"));
|
|
286
377
|
}, "onAbort");
|
|
287
378
|
const t = setTimeout(() => {
|
|
288
379
|
signal?.removeEventListener("abort", onAbort);
|
|
289
|
-
|
|
380
|
+
resolve2();
|
|
290
381
|
}, ms);
|
|
291
382
|
signal?.addEventListener("abort", onAbort, {
|
|
292
383
|
once: true
|
|
@@ -305,6 +396,10 @@ var McpErrorCode = {
|
|
|
305
396
|
IMAGE_DOWNLOAD_FAILED: "IMAGE_DOWNLOAD_FAILED",
|
|
306
397
|
IMAGE_TOO_LARGE: "IMAGE_TOO_LARGE",
|
|
307
398
|
IMAGE_TYPE_UNSUPPORTED: "IMAGE_TYPE_UNSUPPORTED",
|
|
399
|
+
IMAGE_PATH_NOT_FOUND: "IMAGE_PATH_NOT_FOUND",
|
|
400
|
+
IMAGE_PATH_NOT_READABLE: "IMAGE_PATH_NOT_READABLE",
|
|
401
|
+
IMAGE_PATH_NOT_A_FILE: "IMAGE_PATH_NOT_A_FILE",
|
|
402
|
+
PATH_NOT_SUPPORTED_IN_HTTP_MODE: "PATH_NOT_SUPPORTED_IN_HTTP_MODE",
|
|
308
403
|
TASK_NOT_FOUND: "TASK_NOT_FOUND",
|
|
309
404
|
TASK_NOT_READY: "TASK_NOT_READY",
|
|
310
405
|
NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
|
|
@@ -318,7 +413,11 @@ var McpErrorCode = {
|
|
|
318
413
|
var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
|
|
319
414
|
McpErrorCode.INVALID_INPUT,
|
|
320
415
|
McpErrorCode.INVALID_API_KEY,
|
|
321
|
-
McpErrorCode.REVOKED_API_KEY
|
|
416
|
+
McpErrorCode.REVOKED_API_KEY,
|
|
417
|
+
McpErrorCode.IMAGE_PATH_NOT_FOUND,
|
|
418
|
+
McpErrorCode.IMAGE_PATH_NOT_READABLE,
|
|
419
|
+
McpErrorCode.IMAGE_PATH_NOT_A_FILE,
|
|
420
|
+
McpErrorCode.PATH_NOT_SUPPORTED_IN_HTTP_MODE
|
|
322
421
|
]);
|
|
323
422
|
function mcp(code, message, extra = {}) {
|
|
324
423
|
const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
|
|
@@ -433,14 +532,13 @@ var ImageCreativeAspectRatioEnum = z.enum([
|
|
|
433
532
|
"9:16",
|
|
434
533
|
"16:9"
|
|
435
534
|
]);
|
|
436
|
-
var ImageBase64 = z.string().max(75e5);
|
|
437
535
|
var ImageRefSchema = z.object({
|
|
438
|
-
url: z.string().url().optional().describe(
|
|
439
|
-
|
|
440
|
-
}).refine((v) => Boolean(v.url) !== Boolean(v.
|
|
441
|
-
message: "each image entry must have exactly one of `url` or `
|
|
536
|
+
url: z.string().url().optional().describe('URL p\xFAblica HTTPS de la foto. Ej: una URL de Shopify CDN, Cloudinary, Imgur. \xDAsala cuando el usuario te diga "est\xE1 en X.com/foto.jpg" o cuando la foto ya est\xE9 hospedada.'),
|
|
537
|
+
path: z.string().min(1).optional().describe('Ruta del archivo en el computador del usuario. Ej: "/Users/maria/Desktop/foto.jpg", "~/Pictures/camiseta.png", "C:\\Users\\Maria\\Desktop\\foto.jpg". El MCP local la lee del disco y la sube. SOLO funciona cuando el MCP corre local (stdio). NO funciona en mcp.productmaker.app (HTTP) \u2014 ah\xED usa `url`.')
|
|
538
|
+
}).refine((v) => Boolean(v.url) !== Boolean(v.path), {
|
|
539
|
+
message: "each image entry must have exactly one of `url` or `path`"
|
|
442
540
|
});
|
|
443
|
-
var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe(
|
|
541
|
+
var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe('1 a 5 fotos del MISMO producto. La PRIMERA es la principal (hero); las dem\xE1s son \xE1ngulos adicionales. NO mezcles productos distintos en una sola llamada.\n\nCada entrada tiene EXACTAMENTE uno de:\n\u2022 `url` \u2014 URL p\xFAblica HTTPS (Shopify CDN, Cloudinary). Prefi\xE9rela si la foto ya est\xE1 hospedada.\n\u2022 `path` \u2014 Ruta del archivo en el computador del usuario (ej. "/Users/maria/Desktop/foto.jpg", "~/Downloads/camiseta.png"). \xDAsala cuando el usuario te dijo d\xF3nde guard\xF3 la foto. Solo MCP local \u2014 el cliente web rechaza paths.\n\nSi el usuario te peg\xF3 la foto en el chat pero no te dio ni URL ni ruta: PREG\xDANTALE d\xF3nde la tiene guardada (Escritorio, Descargas, etc.). Si el `path` falla con IMAGE_PATH_NOT_FOUND, prueba 1-2 ubicaciones obvias alternativas (~/Desktop, ~/Downloads) antes de molestar al usuario. Tras 3 intentos fallidos, p\xEDdele la ruta completa.');
|
|
444
542
|
function asText(value) {
|
|
445
543
|
return {
|
|
446
544
|
content: [
|
|
@@ -465,7 +563,9 @@ function flatten(obj) {
|
|
|
465
563
|
__name(flatten, "flatten");
|
|
466
564
|
async function submitCreative(api, opts) {
|
|
467
565
|
const { images, ...rest } = opts.input;
|
|
468
|
-
const resolved = await resolveImages(images
|
|
566
|
+
const resolved = await resolveImages(images, {
|
|
567
|
+
transport: opts.transport
|
|
568
|
+
});
|
|
469
569
|
const parts = resolved.map((img, i) => ({
|
|
470
570
|
...img,
|
|
471
571
|
filename: `image-${i}.png`
|
|
@@ -682,10 +782,12 @@ var EditTaskDraftInput = z2.object({
|
|
|
682
782
|
scriptNotes: z2.string().max(2e3).optional()
|
|
683
783
|
})
|
|
684
784
|
});
|
|
685
|
-
function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
785
|
+
function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
|
|
686
786
|
server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo)\n- multiAngleVideos (booleano)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
|
|
687
787
|
try {
|
|
688
|
-
const imgs = await resolveImages(input.images
|
|
788
|
+
const imgs = await resolveImages(input.images, {
|
|
789
|
+
transport
|
|
790
|
+
});
|
|
689
791
|
const parts = imgs.map((img, i) => ({
|
|
690
792
|
...img,
|
|
691
793
|
filename: `image-${i}.png`
|
|
@@ -754,8 +856,8 @@ function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
|
754
856
|
if (input.limit != null) qs.set("limit", String(input.limit));
|
|
755
857
|
if (input.offset != null) qs.set("offset", String(input.offset));
|
|
756
858
|
if (input.search) qs.set("search", input.search);
|
|
757
|
-
const
|
|
758
|
-
const r = await api.getJson(
|
|
859
|
+
const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
|
|
860
|
+
const r = await api.getJson(path2);
|
|
759
861
|
return asText(r);
|
|
760
862
|
} catch (e) {
|
|
761
863
|
throw translateError(e);
|
|
@@ -844,13 +946,14 @@ var LandingInput = z3.object({
|
|
|
844
946
|
paymentMethods: z3.string().optional().describe('Opcional. M\xE9todos de pago a mostrar (lista libre). Ej. "Tarjeta, PSE, Nequi, Contraentrega". Si se omite, la IA usa los m\xE1s comunes del pa\xEDs.'),
|
|
845
947
|
isCombo: CreativeRefs.isCombo
|
|
846
948
|
});
|
|
847
|
-
function registerCreativeTools(server, api) {
|
|
949
|
+
function registerCreativeTools(server, api, { transport = "http" } = {}) {
|
|
848
950
|
server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, narrativeStyle, videoStyle, videoModel, features, cta, scriptNotes.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.', VideoInput.shape, async (input) => {
|
|
849
951
|
try {
|
|
850
952
|
return await submitCreative(api, {
|
|
851
953
|
path: "/v1/creatives/videos",
|
|
852
954
|
estimatedSeconds: 300,
|
|
853
|
-
input
|
|
955
|
+
input,
|
|
956
|
+
transport
|
|
854
957
|
});
|
|
855
958
|
} catch (e) {
|
|
856
959
|
throw translateError(e);
|
|
@@ -861,7 +964,8 @@ function registerCreativeTools(server, api) {
|
|
|
861
964
|
return await submitCreative(api, {
|
|
862
965
|
path: "/v1/creatives/images",
|
|
863
966
|
estimatedSeconds: 60,
|
|
864
|
-
input
|
|
967
|
+
input,
|
|
968
|
+
transport
|
|
865
969
|
});
|
|
866
970
|
} catch (e) {
|
|
867
971
|
throw translateError(e);
|
|
@@ -872,7 +976,8 @@ function registerCreativeTools(server, api) {
|
|
|
872
976
|
return await submitCreative(api, {
|
|
873
977
|
path: "/v1/creatives/landing",
|
|
874
978
|
estimatedSeconds: 60,
|
|
875
|
-
input
|
|
979
|
+
input,
|
|
980
|
+
transport
|
|
876
981
|
});
|
|
877
982
|
} catch (e) {
|
|
878
983
|
throw translateError(e);
|
|
@@ -954,7 +1059,8 @@ function registerPublishTools(server, api) {
|
|
|
954
1059
|
__name(registerPublishTools, "registerPublishTools");
|
|
955
1060
|
|
|
956
1061
|
// src/server.ts
|
|
957
|
-
function createServer(
|
|
1062
|
+
function createServer(opts) {
|
|
1063
|
+
const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
|
|
958
1064
|
const server = new McpServer({
|
|
959
1065
|
name: "productmaker",
|
|
960
1066
|
version: MCP_VERSION
|
|
@@ -967,8 +1073,13 @@ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
|
|
|
967
1073
|
}
|
|
968
1074
|
});
|
|
969
1075
|
const api = new ApiClient(apiBaseUrl, apiKey);
|
|
970
|
-
registerTaskTools(server, api,
|
|
971
|
-
|
|
1076
|
+
registerTaskTools(server, api, {
|
|
1077
|
+
proxyBaseUrl,
|
|
1078
|
+
transport
|
|
1079
|
+
});
|
|
1080
|
+
registerCreativeTools(server, api, {
|
|
1081
|
+
transport
|
|
1082
|
+
});
|
|
972
1083
|
registerConnectionTools(server, api);
|
|
973
1084
|
registerPublishTools(server, api);
|
|
974
1085
|
return server;
|
|
@@ -1010,7 +1121,11 @@ async function main() {
|
|
|
1010
1121
|
console.error(e.message);
|
|
1011
1122
|
process.exit(1);
|
|
1012
1123
|
}
|
|
1013
|
-
const server = createServer(
|
|
1124
|
+
const server = createServer({
|
|
1125
|
+
apiKey,
|
|
1126
|
+
apiBaseUrl,
|
|
1127
|
+
transport: "stdio"
|
|
1128
|
+
});
|
|
1014
1129
|
await server.connect(new StdioServerTransport());
|
|
1015
1130
|
}
|
|
1016
1131
|
__name(main, "main");
|