@productmaker/mcp 0.1.6 → 0.3.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 +57 -15
- package/dist/http.js +450 -118
- package/dist/stdio.js +448 -117
- package/package.json +47 -22
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.3.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");
|
|
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.");
|
|
214
343
|
}
|
|
215
|
-
|
|
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})`);
|
|
239
|
-
}
|
|
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,12 +399,18 @@ 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",
|
|
408
|
+
CAMPAIGN_NOT_FOUND: "CAMPAIGN_NOT_FOUND",
|
|
313
409
|
NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
|
|
314
410
|
NO_META_AD_ACCOUNTS: "NO_META_AD_ACCOUNTS",
|
|
315
411
|
SHOPIFY_NOT_CONNECTED: "SHOPIFY_NOT_CONNECTED",
|
|
316
412
|
META_CAPABILITY_MISSING: "META_CAPABILITY_MISSING",
|
|
413
|
+
TIKTOK_NOT_CONNECTED: "TIKTOK_NOT_CONNECTED",
|
|
317
414
|
UPSTREAM_TIMEOUT: "UPSTREAM_TIMEOUT",
|
|
318
415
|
UPSTREAM_UNAVAILABLE: "UPSTREAM_UNAVAILABLE",
|
|
319
416
|
INTERNAL: "INTERNAL"
|
|
@@ -321,7 +418,15 @@ var McpErrorCode = {
|
|
|
321
418
|
var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
|
|
322
419
|
McpErrorCode.INVALID_INPUT,
|
|
323
420
|
McpErrorCode.INVALID_API_KEY,
|
|
324
|
-
McpErrorCode.REVOKED_API_KEY
|
|
421
|
+
McpErrorCode.REVOKED_API_KEY,
|
|
422
|
+
McpErrorCode.IMAGE_PATH_NOT_FOUND,
|
|
423
|
+
McpErrorCode.IMAGE_PATH_NOT_READABLE,
|
|
424
|
+
McpErrorCode.IMAGE_PATH_NOT_A_FILE,
|
|
425
|
+
McpErrorCode.PATH_NOT_SUPPORTED_IN_HTTP_MODE,
|
|
426
|
+
// The caller passed a campaignId that does not exist — semantically a
|
|
427
|
+
// bad parameter, not an internal failure. Matches the spec contract
|
|
428
|
+
// (mcp-server.spec.ts: "McpError INVALID_PARAMS code=CAMPAIGN_NOT_FOUND").
|
|
429
|
+
McpErrorCode.CAMPAIGN_NOT_FOUND
|
|
325
430
|
]);
|
|
326
431
|
function mcp(code, message, extra = {}) {
|
|
327
432
|
const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
|
|
@@ -336,7 +441,9 @@ var PASSTHROUGH_CODES = /* @__PURE__ */ new Set([
|
|
|
336
441
|
"NO_SHOPIFY_SHOPS",
|
|
337
442
|
"NO_META_AD_ACCOUNTS",
|
|
338
443
|
"SHOPIFY_NOT_CONNECTED",
|
|
339
|
-
"
|
|
444
|
+
"TIKTOK_NOT_CONNECTED",
|
|
445
|
+
"REVOKED_API_KEY",
|
|
446
|
+
"CAMPAIGN_NOT_FOUND"
|
|
340
447
|
]);
|
|
341
448
|
function isRecord(v) {
|
|
342
449
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -436,14 +543,13 @@ var ImageCreativeAspectRatioEnum = z.enum([
|
|
|
436
543
|
"9:16",
|
|
437
544
|
"16:9"
|
|
438
545
|
]);
|
|
439
|
-
var ImageBase64 = z.string().max(75e5);
|
|
440
546
|
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 `
|
|
547
|
+
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.'),
|
|
548
|
+
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`.')
|
|
549
|
+
}).refine((v) => Boolean(v.url) !== Boolean(v.path), {
|
|
550
|
+
message: "each image entry must have exactly one of `url` or `path`"
|
|
445
551
|
});
|
|
446
|
-
var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe(
|
|
552
|
+
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
553
|
function asText(value) {
|
|
448
554
|
return {
|
|
449
555
|
content: [
|
|
@@ -455,6 +561,10 @@ function asText(value) {
|
|
|
455
561
|
};
|
|
456
562
|
}
|
|
457
563
|
__name(asText, "asText");
|
|
564
|
+
function escapeMdLabel(s) {
|
|
565
|
+
return s.replace(/[\[\]\(\)\{\}`<>\r\n\\*_]/g, " ").replace(/\s+/g, " ").trim();
|
|
566
|
+
}
|
|
567
|
+
__name(escapeMdLabel, "escapeMdLabel");
|
|
458
568
|
function flatten(obj) {
|
|
459
569
|
const out = {};
|
|
460
570
|
for (const [k, v] of Object.entries(obj)) {
|
|
@@ -468,7 +578,9 @@ function flatten(obj) {
|
|
|
468
578
|
__name(flatten, "flatten");
|
|
469
579
|
async function submitCreative(api, opts) {
|
|
470
580
|
const { images, ...rest } = opts.input;
|
|
471
|
-
const resolved = await resolveImages(images
|
|
581
|
+
const resolved = await resolveImages(images, {
|
|
582
|
+
transport: opts.transport
|
|
583
|
+
});
|
|
472
584
|
const parts = resolved.map((img, i) => ({
|
|
473
585
|
...img,
|
|
474
586
|
filename: `image-${i}.png`
|
|
@@ -476,8 +588,9 @@ async function submitCreative(api, opts) {
|
|
|
476
588
|
const r = await api.postMultipart(opts.path, {
|
|
477
589
|
images: parts
|
|
478
590
|
}, flatten(rest));
|
|
591
|
+
const taskId = r.taskId ?? r.jobId;
|
|
479
592
|
return asText({
|
|
480
|
-
taskId
|
|
593
|
+
taskId,
|
|
481
594
|
status: "queued",
|
|
482
595
|
estimatedSeconds: opts.estimatedSeconds
|
|
483
596
|
});
|
|
@@ -616,18 +729,18 @@ __name(sanitizeFilename, "sanitizeFilename");
|
|
|
616
729
|
async function fetchTaskOutputs(api, taskId) {
|
|
617
730
|
const raw = await api.getJson(`/v1/results/${encodeURIComponent(taskId)}?signedUrls=true`);
|
|
618
731
|
const videos = (raw.videoGenerators ?? []).map((vg) => {
|
|
619
|
-
const
|
|
732
|
+
const variants2 = (vg.variants ?? []).filter((v) => Boolean(v.kind) && Boolean(v.url)).map((v) => ({
|
|
620
733
|
kind: v.kind,
|
|
621
734
|
url: v.url
|
|
622
735
|
}));
|
|
623
|
-
const fallback = !
|
|
736
|
+
const fallback = !variants2.length && Array.isArray(vg.resultUrls) && vg.resultUrls.length ? vg.resultUrls.map((url) => ({
|
|
624
737
|
kind: "mp4",
|
|
625
738
|
url
|
|
626
739
|
})) : [];
|
|
627
740
|
return {
|
|
628
741
|
angleKey: vg.angleKey ?? "",
|
|
629
742
|
angleIndex: vg.angleIndex ?? 0,
|
|
630
|
-
variants:
|
|
743
|
+
variants: variants2.length ? variants2 : fallback
|
|
631
744
|
};
|
|
632
745
|
}).filter((v) => v.angleKey && v.variants.length);
|
|
633
746
|
const sections = (raw.landing?.images ?? []).filter((img) => Boolean(img.role) && Boolean(img.url)).map((img) => ({
|
|
@@ -635,18 +748,21 @@ async function fetchTaskOutputs(api, taskId) {
|
|
|
635
748
|
url: img.url
|
|
636
749
|
}));
|
|
637
750
|
const images = [];
|
|
638
|
-
|
|
751
|
+
const variants = raw.adCreative?.variants ?? [];
|
|
752
|
+
const hasVariants = variants.some((v) => typeof v === "string");
|
|
753
|
+
if (hasVariants) {
|
|
754
|
+
for (const variant of variants) {
|
|
755
|
+
if (typeof variant === "string") images.push({
|
|
756
|
+
url: variant
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
} else if (raw.adCreative?.url) {
|
|
639
760
|
images.push({
|
|
640
761
|
url: raw.adCreative.url,
|
|
641
762
|
width: raw.adCreative.width,
|
|
642
763
|
height: raw.adCreative.height
|
|
643
764
|
});
|
|
644
765
|
}
|
|
645
|
-
for (const variant of raw.adCreative?.variants ?? []) {
|
|
646
|
-
if (typeof variant === "string") images.push({
|
|
647
|
-
url: variant
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
766
|
return {
|
|
651
767
|
webappUrl: `${WEBAPP_BASE}/tasks/${taskId}`,
|
|
652
768
|
videos,
|
|
@@ -657,21 +773,24 @@ async function fetchTaskOutputs(api, taskId) {
|
|
|
657
773
|
};
|
|
658
774
|
}
|
|
659
775
|
__name(fetchTaskOutputs, "fetchTaskOutputs");
|
|
660
|
-
function escapeMdLabel(s) {
|
|
661
|
-
return s.replace(/[\[\]\(\)\{\}`<>\r\n\\]/g, " ").replace(/\s+/g, " ").trim();
|
|
662
|
-
}
|
|
663
|
-
__name(escapeMdLabel, "escapeMdLabel");
|
|
664
776
|
function renderOutputsMarkdown(outputs) {
|
|
665
777
|
const parts = [];
|
|
666
778
|
parts.push(`\u2705 **Tu tarea est\xE1 lista** \u2014 [Abrir en Product Maker](${outputs.webappUrl})`);
|
|
667
779
|
if (outputs.images.length) {
|
|
668
|
-
parts.push(
|
|
669
|
-
|
|
780
|
+
parts.push(`
|
|
781
|
+
### Ad creative (${outputs.images.length} ${outputs.images.length === 1 ? "variante" : "variantes"})`);
|
|
782
|
+
outputs.images.forEach((img, i) => {
|
|
783
|
+
parts.push(``);
|
|
784
|
+
parts.push(`[\u2B07 Descargar v${i}](${img.url})`);
|
|
785
|
+
});
|
|
670
786
|
}
|
|
671
787
|
if (outputs.landing.sections.length) {
|
|
672
788
|
parts.push(`
|
|
673
789
|
### Landing (${outputs.landing.sections.length} secciones)`);
|
|
674
|
-
for (const s of outputs.landing.sections)
|
|
790
|
+
for (const s of outputs.landing.sections) {
|
|
791
|
+
parts.push(``);
|
|
792
|
+
parts.push(`[\u2B07 Descargar ${escapeMdLabel(s.role)}](${s.url})`);
|
|
793
|
+
}
|
|
675
794
|
}
|
|
676
795
|
if (outputs.videos.length) {
|
|
677
796
|
parts.push("\n### Video");
|
|
@@ -726,10 +845,12 @@ var EditTaskDraftInput = z2.object({
|
|
|
726
845
|
scriptNotes: z2.string().max(2e3).optional()
|
|
727
846
|
})
|
|
728
847
|
});
|
|
729
|
-
function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
848
|
+
function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
|
|
730
849
|
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
850
|
try {
|
|
732
|
-
const imgs = await resolveImages(input.images
|
|
851
|
+
const imgs = await resolveImages(input.images, {
|
|
852
|
+
transport
|
|
853
|
+
});
|
|
733
854
|
const parts = imgs.map((img, i) => ({
|
|
734
855
|
...img,
|
|
735
856
|
filename: `image-${i}.png`
|
|
@@ -798,8 +919,8 @@ function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
|
798
919
|
if (input.limit != null) qs.set("limit", String(input.limit));
|
|
799
920
|
if (input.offset != null) qs.set("offset", String(input.offset));
|
|
800
921
|
if (input.search) qs.set("search", input.search);
|
|
801
|
-
const
|
|
802
|
-
const r = await api.getJson(
|
|
922
|
+
const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
|
|
923
|
+
const r = await api.getJson(path2);
|
|
803
924
|
return asText(r);
|
|
804
925
|
} catch (e) {
|
|
805
926
|
throw translateError(e);
|
|
@@ -888,13 +1009,14 @@ var LandingInput = z3.object({
|
|
|
888
1009
|
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
1010
|
isCombo: CreativeRefs.isCombo
|
|
890
1011
|
});
|
|
891
|
-
function registerCreativeTools(server, api) {
|
|
1012
|
+
function registerCreativeTools(server, api, { transport = "http" } = {}) {
|
|
892
1013
|
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
1014
|
try {
|
|
894
1015
|
return await submitCreative(api, {
|
|
895
1016
|
path: "/v1/creatives/videos",
|
|
896
1017
|
estimatedSeconds: 300,
|
|
897
|
-
input
|
|
1018
|
+
input,
|
|
1019
|
+
transport
|
|
898
1020
|
});
|
|
899
1021
|
} catch (e) {
|
|
900
1022
|
throw translateError(e);
|
|
@@ -905,7 +1027,8 @@ function registerCreativeTools(server, api) {
|
|
|
905
1027
|
return await submitCreative(api, {
|
|
906
1028
|
path: "/v1/creatives/images",
|
|
907
1029
|
estimatedSeconds: 60,
|
|
908
|
-
input
|
|
1030
|
+
input,
|
|
1031
|
+
transport
|
|
909
1032
|
});
|
|
910
1033
|
} catch (e) {
|
|
911
1034
|
throw translateError(e);
|
|
@@ -916,7 +1039,8 @@ function registerCreativeTools(server, api) {
|
|
|
916
1039
|
return await submitCreative(api, {
|
|
917
1040
|
path: "/v1/creatives/landing",
|
|
918
1041
|
estimatedSeconds: 60,
|
|
919
|
-
input
|
|
1042
|
+
input,
|
|
1043
|
+
transport
|
|
920
1044
|
});
|
|
921
1045
|
} catch (e) {
|
|
922
1046
|
throw translateError(e);
|
|
@@ -944,6 +1068,14 @@ function registerConnectionTools(server, api) {
|
|
|
944
1068
|
throw translateError(e);
|
|
945
1069
|
}
|
|
946
1070
|
});
|
|
1071
|
+
server.tool("list_tiktok_advertisers", "Lista las cuentas de TikTok Ads conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar TikTok en productmaker.app/integrations.", z4.object({}).shape, async () => {
|
|
1072
|
+
try {
|
|
1073
|
+
const r = await api.getJson("/v1/tiktok/advertisers");
|
|
1074
|
+
return asText(r);
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
throw translateError(e);
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
947
1079
|
}
|
|
948
1080
|
__name(registerConnectionTools, "registerConnectionTools");
|
|
949
1081
|
|
|
@@ -954,14 +1086,38 @@ var ShopifyInput = z5.object({
|
|
|
954
1086
|
shopDomain: z5.string().min(1),
|
|
955
1087
|
numLandingImages: z5.number().int().min(1).max(7).optional(),
|
|
956
1088
|
variantIndex: z5.number().int().min(0).optional(),
|
|
1089
|
+
/** ASK THE USER before setting: "¿Tu producto tiene variantes (talla, color, etc.)?".
|
|
1090
|
+
* When true, the landing renders variant-picker pills; when false, it ships
|
|
1091
|
+
* a single-option product page. Defaults to false. */
|
|
957
1092
|
showVariantPicker: z5.boolean().optional()
|
|
958
1093
|
});
|
|
1094
|
+
var McpCampaignConfig = z5.object({
|
|
1095
|
+
dailyBudgetUsd: z5.number().positive().optional(),
|
|
1096
|
+
/** ISO-3166-1 alpha-2 country codes. Meta accepts max 1 (single-country
|
|
1097
|
+
* ad set). TikTok takes only the first entry. */
|
|
1098
|
+
targetCountries: z5.array(z5.string().length(2)).max(1).optional(),
|
|
1099
|
+
campaignName: z5.string().trim().min(1).max(100).optional()
|
|
1100
|
+
});
|
|
959
1101
|
var MetaInput = z5.object({
|
|
960
1102
|
taskId: z5.string().min(1),
|
|
961
|
-
adAccountId: z5.string().min(1)
|
|
1103
|
+
adAccountId: z5.string().min(1),
|
|
1104
|
+
pixelId: z5.string().min(1).optional(),
|
|
1105
|
+
landingUrl: z5.string().url().optional(),
|
|
1106
|
+
/** Select which angles to publish by 0-based index. Omit to publish all
|
|
1107
|
+
* successful angles. Example: a task with 3 angles → `[0,2]` publishes #0 + #2. */
|
|
1108
|
+
angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
|
|
1109
|
+
campaignConfig: McpCampaignConfig.optional()
|
|
1110
|
+
});
|
|
1111
|
+
var TikTokInput = z5.object({
|
|
1112
|
+
taskId: z5.string().min(1),
|
|
1113
|
+
advertiserId: z5.string().min(1),
|
|
1114
|
+
pixelId: z5.string().min(1).optional(),
|
|
1115
|
+
landingUrl: z5.string().url().optional(),
|
|
1116
|
+
angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
|
|
1117
|
+
campaignConfig: McpCampaignConfig.optional()
|
|
962
1118
|
});
|
|
963
1119
|
function registerPublishTools(server, api) {
|
|
964
|
-
server.tool("publish_to_shopify", "Publica la landing y producto generados en una tienda Shopify conectada. Precondici\xF3n: la tarea debe estar en status='complete'
|
|
1120
|
+
server.tool("publish_to_shopify", "Publica la landing y producto generados en una tienda Shopify conectada. Precondici\xF3n: la tarea debe estar en status='complete' \u2014 usa `get_task_status` antes. ANTES de llamar preg\xFAntale al usuario \"\xBFTu producto tiene variantes (talla, color, etc.)?\" y pasa `showVariantPicker: true` si responde que s\xED \u2014 eso renderiza los pills de variantes en la landing. Por defecto incluye 3 im\xE1genes del hero. La respuesta es markdown con el link a la landing y al producto.", ShopifyInput.shape, async (input) => {
|
|
965
1121
|
try {
|
|
966
1122
|
const numImages = input.numLandingImages ?? 3;
|
|
967
1123
|
const variantIndex = input.variantIndex ?? 0;
|
|
@@ -976,16 +1132,37 @@ function registerPublishTools(server, api) {
|
|
|
976
1132
|
selectedLandingImages,
|
|
977
1133
|
showVariantPicker: input.showVariantPicker
|
|
978
1134
|
});
|
|
979
|
-
return
|
|
1135
|
+
return renderShopifyPublishMarkdown(r);
|
|
980
1136
|
} catch (e) {
|
|
981
1137
|
throw translateError(e);
|
|
982
1138
|
}
|
|
983
1139
|
});
|
|
984
|
-
server.tool("publish_to_meta", "Crea una campa\xF1a de Meta Ads (Facebook/Instagram) desde la tarea. La campa\xF1a se crea SIEMPRE en estado PAUSED \u2014 el usuario
|
|
1140
|
+
server.tool("publish_to_meta", "Crea una campa\xF1a de Meta Ads (Facebook/Instagram) desde la tarea. La campa\xF1a se crea SIEMPRE en estado PAUSED \u2014 el usuario la activa manualmente en Meta Ads Manager. Use `list_meta_ad_accounts` antes para un adAccountId v\xE1lido. `pixelId` opcional pero recomendado para tracking. `angleIndexes` (opcional): selecciona qu\xE9 \xE1ngulos publicar \u2014 si la tarea gener\xF3 3 \xE1ngulos y el usuario solo quiere los #0 y #2, pasa [0,2]; omitir = publicar todos. `campaignConfig` (opcional): `dailyBudgetUsd` (presupuesto diario en USD, ej. 25), `targetCountries` (c\xF3digos ISO-2, m\xE1x 1 para Meta), `campaignName` (m\xE1x 100 chars). Despu\xE9s de publicar usa `get_meta_campaign_status` para chequear el estado.", MetaInput.shape, async ({ taskId, campaignConfig, ...rest }) => {
|
|
985
1141
|
try {
|
|
986
|
-
const
|
|
987
|
-
|
|
1142
|
+
const body = {
|
|
1143
|
+
...rest
|
|
1144
|
+
};
|
|
1145
|
+
if (campaignConfig) {
|
|
1146
|
+
body.campaignOverrides = toMetaCampaignOverrides(campaignConfig);
|
|
1147
|
+
}
|
|
1148
|
+
const r = await api.postJson(`/v1/facebook/campaigns/from-task/${encodeURIComponent(taskId)}`, body);
|
|
1149
|
+
return asText({
|
|
1150
|
+
...r,
|
|
1151
|
+
status: "PAUSED"
|
|
988
1152
|
});
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
throw translateError(e);
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
server.tool("publish_to_tiktok", "Crea una campa\xF1a de TikTok Ads desde la tarea. La campa\xF1a se crea SIEMPRE en estado PAUSED \u2014 el usuario la activa manualmente en TikTok Ads Manager. Use `list_tiktok_advertisers` antes para un advertiserId v\xE1lido. `pixelId` opcional pero recomendado para tracking. `angleIndexes` (opcional): selecciona qu\xE9 \xE1ngulos publicar \u2014 ej. [0,2]; omitir = publicar todos. `campaignConfig` (opcional): `dailyBudgetUsd` (presupuesto diario en USD, ej. 25), `targetCountries` (c\xF3digos ISO-2, primero se usa), `campaignName` (m\xE1x 100 chars). Despu\xE9s de publicar usa `get_tiktok_campaign_status` para chequear el estado.", TikTokInput.shape, async ({ taskId, campaignConfig, ...rest }) => {
|
|
1158
|
+
try {
|
|
1159
|
+
const body = {
|
|
1160
|
+
...rest
|
|
1161
|
+
};
|
|
1162
|
+
if (campaignConfig) {
|
|
1163
|
+
body.campaignConfig = toTikTokCampaignConfig(campaignConfig);
|
|
1164
|
+
}
|
|
1165
|
+
const r = await api.postJson(`/v1/tiktok/campaigns/from-task/${encodeURIComponent(taskId)}`, body);
|
|
989
1166
|
return asText({
|
|
990
1167
|
...r,
|
|
991
1168
|
status: "PAUSED"
|
|
@@ -996,9 +1173,153 @@ function registerPublishTools(server, api) {
|
|
|
996
1173
|
});
|
|
997
1174
|
}
|
|
998
1175
|
__name(registerPublishTools, "registerPublishTools");
|
|
1176
|
+
function renderShopifyPublishMarkdown(r) {
|
|
1177
|
+
const lines = [];
|
|
1178
|
+
const statusEmoji = r.status === "success" ? "\u2705" : r.status === "error" ? "\u274C" : "\u23F3";
|
|
1179
|
+
lines.push(`${statusEmoji} **Publicaci\xF3n en Shopify** \u2014 ${r.shopDomain}`);
|
|
1180
|
+
lines.push(`Estado: \`${r.status}\``);
|
|
1181
|
+
if (r.pageUrl) lines.push(`
|
|
1182
|
+
### Landing
|
|
1183
|
+
[Ver landing publicada](${r.pageUrl})`);
|
|
1184
|
+
if (r.productUrl) lines.push(`
|
|
1185
|
+
### Producto
|
|
1186
|
+
[Ver producto](${r.productUrl})`);
|
|
1187
|
+
if (!r.pageUrl && !r.productUrl && r.status !== "success") {
|
|
1188
|
+
lines.push("\n_La publicaci\xF3n a\xFAn est\xE1 proces\xE1ndose. Usa `get_task_status` para verificar progreso._");
|
|
1189
|
+
}
|
|
1190
|
+
return {
|
|
1191
|
+
content: [
|
|
1192
|
+
{
|
|
1193
|
+
type: "text",
|
|
1194
|
+
text: lines.join("\n")
|
|
1195
|
+
}
|
|
1196
|
+
]
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
__name(renderShopifyPublishMarkdown, "renderShopifyPublishMarkdown");
|
|
1200
|
+
function toMetaCampaignOverrides(cfg) {
|
|
1201
|
+
const out = {};
|
|
1202
|
+
if (cfg.dailyBudgetUsd !== void 0) out.dailyBudgetUsd = cfg.dailyBudgetUsd;
|
|
1203
|
+
if (cfg.targetCountries !== void 0) out.targetCountries = cfg.targetCountries;
|
|
1204
|
+
if (cfg.campaignName !== void 0) out.campaignName = cfg.campaignName;
|
|
1205
|
+
return out;
|
|
1206
|
+
}
|
|
1207
|
+
__name(toMetaCampaignOverrides, "toMetaCampaignOverrides");
|
|
1208
|
+
function toTikTokCampaignConfig(cfg) {
|
|
1209
|
+
const out = {};
|
|
1210
|
+
if (cfg.dailyBudgetUsd !== void 0) out.dailyBudget = Math.round(cfg.dailyBudgetUsd * 100);
|
|
1211
|
+
if (cfg.targetCountries && cfg.targetCountries.length > 0) out.targetCountry = cfg.targetCountries[0];
|
|
1212
|
+
if (cfg.campaignName !== void 0) out.campaignName = cfg.campaignName;
|
|
1213
|
+
return out;
|
|
1214
|
+
}
|
|
1215
|
+
__name(toTikTokCampaignConfig, "toTikTokCampaignConfig");
|
|
1216
|
+
|
|
1217
|
+
// src/tools/campaigns.ts
|
|
1218
|
+
import { z as z6 } from "zod";
|
|
1219
|
+
var MetaStatusInput = z6.object({
|
|
1220
|
+
campaignId: z6.string().min(1).describe("Meta campaign ID returned by publish_to_meta.")
|
|
1221
|
+
});
|
|
1222
|
+
var TikTokStatusInput = z6.object({
|
|
1223
|
+
campaignId: z6.string().min(1).describe("TikTok campaign ID returned by publish_to_tiktok."),
|
|
1224
|
+
advertiserId: z6.string().min(1).describe("The same advertiserId used to publish the campaign.")
|
|
1225
|
+
});
|
|
1226
|
+
function registerCampaignTools(server, api) {
|
|
1227
|
+
server.tool("get_meta_campaign_status", "Consulta el estado actual + m\xE9tricas lifetime (spend, impressions, clicks, reach) de una campa\xF1a Meta publicada. Llama esto DESPU\xC9S de `publish_to_meta` para confirmar que la campa\xF1a se cre\xF3 correctamente y para reportar performance al usuario. Es read-only \u2014 no modifica nada en Meta Ads Manager.", MetaStatusInput.shape, async (input) => {
|
|
1228
|
+
try {
|
|
1229
|
+
const r = await api.getJson(`/v1/facebook/campaigns/${encodeURIComponent(input.campaignId)}`);
|
|
1230
|
+
return renderMetaStatusMarkdown(r);
|
|
1231
|
+
} catch (e) {
|
|
1232
|
+
throw translateError(e);
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
server.tool("get_tiktok_campaign_status", "Consulta el estado actual + reporte lifetime (spend, impressions, clicks, reach) de una campa\xF1a TikTok publicada. Requiere `advertiserId` (el mismo con el que se cre\xF3). Llama esto DESPU\xC9S de `publish_to_tiktok` para confirmar creaci\xF3n correcta y reportar performance. Es read-only.", TikTokStatusInput.shape, async (input) => {
|
|
1236
|
+
try {
|
|
1237
|
+
const r = await api.getJson(`/v1/tiktok/campaigns/${encodeURIComponent(input.campaignId)}?advertiserId=${encodeURIComponent(input.advertiserId)}`);
|
|
1238
|
+
return renderTikTokStatusMarkdown(r);
|
|
1239
|
+
} catch (e) {
|
|
1240
|
+
throw translateError(e);
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
__name(registerCampaignTools, "registerCampaignTools");
|
|
1245
|
+
function renderCampaignStatusMarkdown(opts) {
|
|
1246
|
+
const lines = [
|
|
1247
|
+
`\u{1F4CA} **${escapeMdLabel(opts.name)}** (\`${opts.campaignId}\`)`
|
|
1248
|
+
];
|
|
1249
|
+
for (const [label, value] of opts.statusLines) {
|
|
1250
|
+
lines.push(`- ${label}: \`${value}\``);
|
|
1251
|
+
}
|
|
1252
|
+
if (opts.metrics) {
|
|
1253
|
+
lines.push("\n### M\xE9tricas (lifetime)");
|
|
1254
|
+
lines.push(`- Spend: ${opts.metrics.spend}`);
|
|
1255
|
+
lines.push(`- Impressions: ${opts.metrics.impressions}`);
|
|
1256
|
+
lines.push(`- Clicks: ${opts.metrics.clicks}`);
|
|
1257
|
+
lines.push(`- Reach: ${opts.metrics.reach}`);
|
|
1258
|
+
} else {
|
|
1259
|
+
lines.push(`
|
|
1260
|
+
_${opts.noMetricsHint ?? "A\xFAn no hay m\xE9tricas reportadas."}_`);
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
content: [
|
|
1264
|
+
{
|
|
1265
|
+
type: "text",
|
|
1266
|
+
text: lines.join("\n")
|
|
1267
|
+
}
|
|
1268
|
+
]
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
__name(renderCampaignStatusMarkdown, "renderCampaignStatusMarkdown");
|
|
1272
|
+
function renderMetaStatusMarkdown(r) {
|
|
1273
|
+
const statusLines = [
|
|
1274
|
+
[
|
|
1275
|
+
"Estado efectivo",
|
|
1276
|
+
r.effectiveStatus
|
|
1277
|
+
],
|
|
1278
|
+
[
|
|
1279
|
+
"Estado configurado",
|
|
1280
|
+
r.configuredStatus
|
|
1281
|
+
]
|
|
1282
|
+
];
|
|
1283
|
+
if (r.objective) statusLines.push([
|
|
1284
|
+
"Objetivo",
|
|
1285
|
+
r.objective
|
|
1286
|
+
]);
|
|
1287
|
+
return renderCampaignStatusMarkdown({
|
|
1288
|
+
name: r.name,
|
|
1289
|
+
campaignId: r.campaignId,
|
|
1290
|
+
statusLines,
|
|
1291
|
+
metrics: r.insights,
|
|
1292
|
+
noMetricsHint: "A\xFAn no hay m\xE9tricas reportadas (la campa\xF1a puede estar pausada o sin entregar)."
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
__name(renderMetaStatusMarkdown, "renderMetaStatusMarkdown");
|
|
1296
|
+
function renderTikTokStatusMarkdown(r) {
|
|
1297
|
+
const statusLines = [
|
|
1298
|
+
[
|
|
1299
|
+
"Estado de operaci\xF3n",
|
|
1300
|
+
r.operationStatus
|
|
1301
|
+
]
|
|
1302
|
+
];
|
|
1303
|
+
if (r.secondaryStatus) statusLines.push([
|
|
1304
|
+
"Estado secundario",
|
|
1305
|
+
r.secondaryStatus
|
|
1306
|
+
]);
|
|
1307
|
+
if (r.objectiveType) statusLines.push([
|
|
1308
|
+
"Objetivo",
|
|
1309
|
+
r.objectiveType
|
|
1310
|
+
]);
|
|
1311
|
+
return renderCampaignStatusMarkdown({
|
|
1312
|
+
name: r.name,
|
|
1313
|
+
campaignId: r.campaignId,
|
|
1314
|
+
statusLines,
|
|
1315
|
+
metrics: r.report
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
__name(renderTikTokStatusMarkdown, "renderTikTokStatusMarkdown");
|
|
999
1319
|
|
|
1000
1320
|
// src/server.ts
|
|
1001
|
-
function createServer(
|
|
1321
|
+
function createServer(opts) {
|
|
1322
|
+
const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
|
|
1002
1323
|
const server = new McpServer({
|
|
1003
1324
|
name: "productmaker",
|
|
1004
1325
|
version: MCP_VERSION
|
|
@@ -1011,10 +1332,16 @@ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
|
|
|
1011
1332
|
}
|
|
1012
1333
|
});
|
|
1013
1334
|
const api = new ApiClient(apiBaseUrl, apiKey);
|
|
1014
|
-
registerTaskTools(server, api,
|
|
1015
|
-
|
|
1335
|
+
registerTaskTools(server, api, {
|
|
1336
|
+
proxyBaseUrl,
|
|
1337
|
+
transport
|
|
1338
|
+
});
|
|
1339
|
+
registerCreativeTools(server, api, {
|
|
1340
|
+
transport
|
|
1341
|
+
});
|
|
1016
1342
|
registerConnectionTools(server, api);
|
|
1017
1343
|
registerPublishTools(server, api);
|
|
1344
|
+
registerCampaignTools(server, api);
|
|
1018
1345
|
return server;
|
|
1019
1346
|
}
|
|
1020
1347
|
__name(createServer, "createServer");
|
|
@@ -1154,7 +1481,7 @@ function createApp(opts) {
|
|
|
1154
1481
|
legacyHeaders: false
|
|
1155
1482
|
}));
|
|
1156
1483
|
app.use(express.json({
|
|
1157
|
-
limit: "
|
|
1484
|
+
limit: "1mb"
|
|
1158
1485
|
}));
|
|
1159
1486
|
app.get("/healthz", (_req, res) => {
|
|
1160
1487
|
res.json({
|
|
@@ -1176,7 +1503,12 @@ function createApp(opts) {
|
|
|
1176
1503
|
return;
|
|
1177
1504
|
}
|
|
1178
1505
|
try {
|
|
1179
|
-
const server = createServer(
|
|
1506
|
+
const server = createServer({
|
|
1507
|
+
apiKey,
|
|
1508
|
+
apiBaseUrl: opts.apiBaseUrl,
|
|
1509
|
+
proxyBaseUrl: opts.proxyBaseUrl ?? null,
|
|
1510
|
+
transport: "http"
|
|
1511
|
+
});
|
|
1180
1512
|
const transport = new StreamableHTTPServerTransport({
|
|
1181
1513
|
sessionIdGenerator: void 0
|
|
1182
1514
|
});
|