@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/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.3.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");
|
|
211
|
-
}
|
|
212
|
-
if (u.protocol !== "https:") throw new ResolveImageError("INVALID_INPUT", "imageUrl must be HTTPS");
|
|
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}`);
|
|
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.");
|
|
228
340
|
}
|
|
229
|
-
|
|
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
|
-
};
|
|
241
|
-
}
|
|
242
|
-
if (input.imageBase64) {
|
|
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
|
-
};
|
|
341
|
+
return resolvePath(ref.path);
|
|
251
342
|
}
|
|
252
|
-
|
|
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,12 +396,18 @@ 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",
|
|
405
|
+
CAMPAIGN_NOT_FOUND: "CAMPAIGN_NOT_FOUND",
|
|
310
406
|
NO_SHOPIFY_SHOPS: "NO_SHOPIFY_SHOPS",
|
|
311
407
|
NO_META_AD_ACCOUNTS: "NO_META_AD_ACCOUNTS",
|
|
312
408
|
SHOPIFY_NOT_CONNECTED: "SHOPIFY_NOT_CONNECTED",
|
|
313
409
|
META_CAPABILITY_MISSING: "META_CAPABILITY_MISSING",
|
|
410
|
+
TIKTOK_NOT_CONNECTED: "TIKTOK_NOT_CONNECTED",
|
|
314
411
|
UPSTREAM_TIMEOUT: "UPSTREAM_TIMEOUT",
|
|
315
412
|
UPSTREAM_UNAVAILABLE: "UPSTREAM_UNAVAILABLE",
|
|
316
413
|
INTERNAL: "INTERNAL"
|
|
@@ -318,7 +415,15 @@ var McpErrorCode = {
|
|
|
318
415
|
var INVALID_PARAM_CODES = /* @__PURE__ */ new Set([
|
|
319
416
|
McpErrorCode.INVALID_INPUT,
|
|
320
417
|
McpErrorCode.INVALID_API_KEY,
|
|
321
|
-
McpErrorCode.REVOKED_API_KEY
|
|
418
|
+
McpErrorCode.REVOKED_API_KEY,
|
|
419
|
+
McpErrorCode.IMAGE_PATH_NOT_FOUND,
|
|
420
|
+
McpErrorCode.IMAGE_PATH_NOT_READABLE,
|
|
421
|
+
McpErrorCode.IMAGE_PATH_NOT_A_FILE,
|
|
422
|
+
McpErrorCode.PATH_NOT_SUPPORTED_IN_HTTP_MODE,
|
|
423
|
+
// The caller passed a campaignId that does not exist — semantically a
|
|
424
|
+
// bad parameter, not an internal failure. Matches the spec contract
|
|
425
|
+
// (mcp-server.spec.ts: "McpError INVALID_PARAMS code=CAMPAIGN_NOT_FOUND").
|
|
426
|
+
McpErrorCode.CAMPAIGN_NOT_FOUND
|
|
322
427
|
]);
|
|
323
428
|
function mcp(code, message, extra = {}) {
|
|
324
429
|
const errCode = INVALID_PARAM_CODES.has(code) ? ErrorCode.InvalidParams : ErrorCode.InternalError;
|
|
@@ -333,7 +438,9 @@ var PASSTHROUGH_CODES = /* @__PURE__ */ new Set([
|
|
|
333
438
|
"NO_SHOPIFY_SHOPS",
|
|
334
439
|
"NO_META_AD_ACCOUNTS",
|
|
335
440
|
"SHOPIFY_NOT_CONNECTED",
|
|
336
|
-
"
|
|
441
|
+
"TIKTOK_NOT_CONNECTED",
|
|
442
|
+
"REVOKED_API_KEY",
|
|
443
|
+
"CAMPAIGN_NOT_FOUND"
|
|
337
444
|
]);
|
|
338
445
|
function isRecord(v) {
|
|
339
446
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -433,14 +540,13 @@ var ImageCreativeAspectRatioEnum = z.enum([
|
|
|
433
540
|
"9:16",
|
|
434
541
|
"16:9"
|
|
435
542
|
]);
|
|
436
|
-
var ImageBase64 = z.string().max(75e5);
|
|
437
543
|
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 `
|
|
544
|
+
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.'),
|
|
545
|
+
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`.')
|
|
546
|
+
}).refine((v) => Boolean(v.url) !== Boolean(v.path), {
|
|
547
|
+
message: "each image entry must have exactly one of `url` or `path`"
|
|
442
548
|
});
|
|
443
|
-
var ImagesInput = z.array(ImageRefSchema).min(1).max(5).describe(
|
|
549
|
+
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
550
|
function asText(value) {
|
|
445
551
|
return {
|
|
446
552
|
content: [
|
|
@@ -452,6 +558,10 @@ function asText(value) {
|
|
|
452
558
|
};
|
|
453
559
|
}
|
|
454
560
|
__name(asText, "asText");
|
|
561
|
+
function escapeMdLabel(s) {
|
|
562
|
+
return s.replace(/[\[\]\(\)\{\}`<>\r\n\\*_]/g, " ").replace(/\s+/g, " ").trim();
|
|
563
|
+
}
|
|
564
|
+
__name(escapeMdLabel, "escapeMdLabel");
|
|
455
565
|
function flatten(obj) {
|
|
456
566
|
const out = {};
|
|
457
567
|
for (const [k, v] of Object.entries(obj)) {
|
|
@@ -465,7 +575,9 @@ function flatten(obj) {
|
|
|
465
575
|
__name(flatten, "flatten");
|
|
466
576
|
async function submitCreative(api, opts) {
|
|
467
577
|
const { images, ...rest } = opts.input;
|
|
468
|
-
const resolved = await resolveImages(images
|
|
578
|
+
const resolved = await resolveImages(images, {
|
|
579
|
+
transport: opts.transport
|
|
580
|
+
});
|
|
469
581
|
const parts = resolved.map((img, i) => ({
|
|
470
582
|
...img,
|
|
471
583
|
filename: `image-${i}.png`
|
|
@@ -473,8 +585,9 @@ async function submitCreative(api, opts) {
|
|
|
473
585
|
const r = await api.postMultipart(opts.path, {
|
|
474
586
|
images: parts
|
|
475
587
|
}, flatten(rest));
|
|
588
|
+
const taskId = r.taskId ?? r.jobId;
|
|
476
589
|
return asText({
|
|
477
|
-
taskId
|
|
590
|
+
taskId,
|
|
478
591
|
status: "queued",
|
|
479
592
|
estimatedSeconds: opts.estimatedSeconds
|
|
480
593
|
});
|
|
@@ -572,18 +685,18 @@ __name(sanitizeFilename, "sanitizeFilename");
|
|
|
572
685
|
async function fetchTaskOutputs(api, taskId) {
|
|
573
686
|
const raw = await api.getJson(`/v1/results/${encodeURIComponent(taskId)}?signedUrls=true`);
|
|
574
687
|
const videos = (raw.videoGenerators ?? []).map((vg) => {
|
|
575
|
-
const
|
|
688
|
+
const variants2 = (vg.variants ?? []).filter((v) => Boolean(v.kind) && Boolean(v.url)).map((v) => ({
|
|
576
689
|
kind: v.kind,
|
|
577
690
|
url: v.url
|
|
578
691
|
}));
|
|
579
|
-
const fallback = !
|
|
692
|
+
const fallback = !variants2.length && Array.isArray(vg.resultUrls) && vg.resultUrls.length ? vg.resultUrls.map((url) => ({
|
|
580
693
|
kind: "mp4",
|
|
581
694
|
url
|
|
582
695
|
})) : [];
|
|
583
696
|
return {
|
|
584
697
|
angleKey: vg.angleKey ?? "",
|
|
585
698
|
angleIndex: vg.angleIndex ?? 0,
|
|
586
|
-
variants:
|
|
699
|
+
variants: variants2.length ? variants2 : fallback
|
|
587
700
|
};
|
|
588
701
|
}).filter((v) => v.angleKey && v.variants.length);
|
|
589
702
|
const sections = (raw.landing?.images ?? []).filter((img) => Boolean(img.role) && Boolean(img.url)).map((img) => ({
|
|
@@ -591,18 +704,21 @@ async function fetchTaskOutputs(api, taskId) {
|
|
|
591
704
|
url: img.url
|
|
592
705
|
}));
|
|
593
706
|
const images = [];
|
|
594
|
-
|
|
707
|
+
const variants = raw.adCreative?.variants ?? [];
|
|
708
|
+
const hasVariants = variants.some((v) => typeof v === "string");
|
|
709
|
+
if (hasVariants) {
|
|
710
|
+
for (const variant of variants) {
|
|
711
|
+
if (typeof variant === "string") images.push({
|
|
712
|
+
url: variant
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
} else if (raw.adCreative?.url) {
|
|
595
716
|
images.push({
|
|
596
717
|
url: raw.adCreative.url,
|
|
597
718
|
width: raw.adCreative.width,
|
|
598
719
|
height: raw.adCreative.height
|
|
599
720
|
});
|
|
600
721
|
}
|
|
601
|
-
for (const variant of raw.adCreative?.variants ?? []) {
|
|
602
|
-
if (typeof variant === "string") images.push({
|
|
603
|
-
url: variant
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
722
|
return {
|
|
607
723
|
webappUrl: `${WEBAPP_BASE}/tasks/${taskId}`,
|
|
608
724
|
videos,
|
|
@@ -613,21 +729,24 @@ async function fetchTaskOutputs(api, taskId) {
|
|
|
613
729
|
};
|
|
614
730
|
}
|
|
615
731
|
__name(fetchTaskOutputs, "fetchTaskOutputs");
|
|
616
|
-
function escapeMdLabel(s) {
|
|
617
|
-
return s.replace(/[\[\]\(\)\{\}`<>\r\n\\]/g, " ").replace(/\s+/g, " ").trim();
|
|
618
|
-
}
|
|
619
|
-
__name(escapeMdLabel, "escapeMdLabel");
|
|
620
732
|
function renderOutputsMarkdown(outputs) {
|
|
621
733
|
const parts = [];
|
|
622
734
|
parts.push(`\u2705 **Tu tarea est\xE1 lista** \u2014 [Abrir en Product Maker](${outputs.webappUrl})`);
|
|
623
735
|
if (outputs.images.length) {
|
|
624
|
-
parts.push(
|
|
625
|
-
|
|
736
|
+
parts.push(`
|
|
737
|
+
### Ad creative (${outputs.images.length} ${outputs.images.length === 1 ? "variante" : "variantes"})`);
|
|
738
|
+
outputs.images.forEach((img, i) => {
|
|
739
|
+
parts.push(``);
|
|
740
|
+
parts.push(`[\u2B07 Descargar v${i}](${img.url})`);
|
|
741
|
+
});
|
|
626
742
|
}
|
|
627
743
|
if (outputs.landing.sections.length) {
|
|
628
744
|
parts.push(`
|
|
629
745
|
### Landing (${outputs.landing.sections.length} secciones)`);
|
|
630
|
-
for (const s of outputs.landing.sections)
|
|
746
|
+
for (const s of outputs.landing.sections) {
|
|
747
|
+
parts.push(``);
|
|
748
|
+
parts.push(`[\u2B07 Descargar ${escapeMdLabel(s.role)}](${s.url})`);
|
|
749
|
+
}
|
|
631
750
|
}
|
|
632
751
|
if (outputs.videos.length) {
|
|
633
752
|
parts.push("\n### Video");
|
|
@@ -682,10 +801,12 @@ var EditTaskDraftInput = z2.object({
|
|
|
682
801
|
scriptNotes: z2.string().max(2e3).optional()
|
|
683
802
|
})
|
|
684
803
|
});
|
|
685
|
-
function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
804
|
+
function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
|
|
686
805
|
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
806
|
try {
|
|
688
|
-
const imgs = await resolveImages(input.images
|
|
807
|
+
const imgs = await resolveImages(input.images, {
|
|
808
|
+
transport
|
|
809
|
+
});
|
|
689
810
|
const parts = imgs.map((img, i) => ({
|
|
690
811
|
...img,
|
|
691
812
|
filename: `image-${i}.png`
|
|
@@ -754,8 +875,8 @@ function registerTaskTools(server, api, proxyBaseUrl = null) {
|
|
|
754
875
|
if (input.limit != null) qs.set("limit", String(input.limit));
|
|
755
876
|
if (input.offset != null) qs.set("offset", String(input.offset));
|
|
756
877
|
if (input.search) qs.set("search", input.search);
|
|
757
|
-
const
|
|
758
|
-
const r = await api.getJson(
|
|
878
|
+
const path2 = `/v1/tasks${qs.toString() ? "?" + qs.toString() : ""}`;
|
|
879
|
+
const r = await api.getJson(path2);
|
|
759
880
|
return asText(r);
|
|
760
881
|
} catch (e) {
|
|
761
882
|
throw translateError(e);
|
|
@@ -844,13 +965,14 @@ var LandingInput = z3.object({
|
|
|
844
965
|
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
966
|
isCombo: CreativeRefs.isCombo
|
|
846
967
|
});
|
|
847
|
-
function registerCreativeTools(server, api) {
|
|
968
|
+
function registerCreativeTools(server, api, { transport = "http" } = {}) {
|
|
848
969
|
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
970
|
try {
|
|
850
971
|
return await submitCreative(api, {
|
|
851
972
|
path: "/v1/creatives/videos",
|
|
852
973
|
estimatedSeconds: 300,
|
|
853
|
-
input
|
|
974
|
+
input,
|
|
975
|
+
transport
|
|
854
976
|
});
|
|
855
977
|
} catch (e) {
|
|
856
978
|
throw translateError(e);
|
|
@@ -861,7 +983,8 @@ function registerCreativeTools(server, api) {
|
|
|
861
983
|
return await submitCreative(api, {
|
|
862
984
|
path: "/v1/creatives/images",
|
|
863
985
|
estimatedSeconds: 60,
|
|
864
|
-
input
|
|
986
|
+
input,
|
|
987
|
+
transport
|
|
865
988
|
});
|
|
866
989
|
} catch (e) {
|
|
867
990
|
throw translateError(e);
|
|
@@ -872,7 +995,8 @@ function registerCreativeTools(server, api) {
|
|
|
872
995
|
return await submitCreative(api, {
|
|
873
996
|
path: "/v1/creatives/landing",
|
|
874
997
|
estimatedSeconds: 60,
|
|
875
|
-
input
|
|
998
|
+
input,
|
|
999
|
+
transport
|
|
876
1000
|
});
|
|
877
1001
|
} catch (e) {
|
|
878
1002
|
throw translateError(e);
|
|
@@ -900,6 +1024,14 @@ function registerConnectionTools(server, api) {
|
|
|
900
1024
|
throw translateError(e);
|
|
901
1025
|
}
|
|
902
1026
|
});
|
|
1027
|
+
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 () => {
|
|
1028
|
+
try {
|
|
1029
|
+
const r = await api.getJson("/v1/tiktok/advertisers");
|
|
1030
|
+
return asText(r);
|
|
1031
|
+
} catch (e) {
|
|
1032
|
+
throw translateError(e);
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
903
1035
|
}
|
|
904
1036
|
__name(registerConnectionTools, "registerConnectionTools");
|
|
905
1037
|
|
|
@@ -910,14 +1042,38 @@ var ShopifyInput = z5.object({
|
|
|
910
1042
|
shopDomain: z5.string().min(1),
|
|
911
1043
|
numLandingImages: z5.number().int().min(1).max(7).optional(),
|
|
912
1044
|
variantIndex: z5.number().int().min(0).optional(),
|
|
1045
|
+
/** ASK THE USER before setting: "¿Tu producto tiene variantes (talla, color, etc.)?".
|
|
1046
|
+
* When true, the landing renders variant-picker pills; when false, it ships
|
|
1047
|
+
* a single-option product page. Defaults to false. */
|
|
913
1048
|
showVariantPicker: z5.boolean().optional()
|
|
914
1049
|
});
|
|
1050
|
+
var McpCampaignConfig = z5.object({
|
|
1051
|
+
dailyBudgetUsd: z5.number().positive().optional(),
|
|
1052
|
+
/** ISO-3166-1 alpha-2 country codes. Meta accepts max 1 (single-country
|
|
1053
|
+
* ad set). TikTok takes only the first entry. */
|
|
1054
|
+
targetCountries: z5.array(z5.string().length(2)).max(1).optional(),
|
|
1055
|
+
campaignName: z5.string().trim().min(1).max(100).optional()
|
|
1056
|
+
});
|
|
915
1057
|
var MetaInput = z5.object({
|
|
916
1058
|
taskId: z5.string().min(1),
|
|
917
|
-
adAccountId: z5.string().min(1)
|
|
1059
|
+
adAccountId: z5.string().min(1),
|
|
1060
|
+
pixelId: z5.string().min(1).optional(),
|
|
1061
|
+
landingUrl: z5.string().url().optional(),
|
|
1062
|
+
/** Select which angles to publish by 0-based index. Omit to publish all
|
|
1063
|
+
* successful angles. Example: a task with 3 angles → `[0,2]` publishes #0 + #2. */
|
|
1064
|
+
angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
|
|
1065
|
+
campaignConfig: McpCampaignConfig.optional()
|
|
1066
|
+
});
|
|
1067
|
+
var TikTokInput = z5.object({
|
|
1068
|
+
taskId: z5.string().min(1),
|
|
1069
|
+
advertiserId: z5.string().min(1),
|
|
1070
|
+
pixelId: z5.string().min(1).optional(),
|
|
1071
|
+
landingUrl: z5.string().url().optional(),
|
|
1072
|
+
angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
|
|
1073
|
+
campaignConfig: McpCampaignConfig.optional()
|
|
918
1074
|
});
|
|
919
1075
|
function registerPublishTools(server, api) {
|
|
920
|
-
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'
|
|
1076
|
+
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) => {
|
|
921
1077
|
try {
|
|
922
1078
|
const numImages = input.numLandingImages ?? 3;
|
|
923
1079
|
const variantIndex = input.variantIndex ?? 0;
|
|
@@ -932,16 +1088,37 @@ function registerPublishTools(server, api) {
|
|
|
932
1088
|
selectedLandingImages,
|
|
933
1089
|
showVariantPicker: input.showVariantPicker
|
|
934
1090
|
});
|
|
935
|
-
return
|
|
1091
|
+
return renderShopifyPublishMarkdown(r);
|
|
936
1092
|
} catch (e) {
|
|
937
1093
|
throw translateError(e);
|
|
938
1094
|
}
|
|
939
1095
|
});
|
|
940
|
-
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
|
|
1096
|
+
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 }) => {
|
|
941
1097
|
try {
|
|
942
|
-
const
|
|
943
|
-
|
|
1098
|
+
const body = {
|
|
1099
|
+
...rest
|
|
1100
|
+
};
|
|
1101
|
+
if (campaignConfig) {
|
|
1102
|
+
body.campaignOverrides = toMetaCampaignOverrides(campaignConfig);
|
|
1103
|
+
}
|
|
1104
|
+
const r = await api.postJson(`/v1/facebook/campaigns/from-task/${encodeURIComponent(taskId)}`, body);
|
|
1105
|
+
return asText({
|
|
1106
|
+
...r,
|
|
1107
|
+
status: "PAUSED"
|
|
944
1108
|
});
|
|
1109
|
+
} catch (e) {
|
|
1110
|
+
throw translateError(e);
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
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 }) => {
|
|
1114
|
+
try {
|
|
1115
|
+
const body = {
|
|
1116
|
+
...rest
|
|
1117
|
+
};
|
|
1118
|
+
if (campaignConfig) {
|
|
1119
|
+
body.campaignConfig = toTikTokCampaignConfig(campaignConfig);
|
|
1120
|
+
}
|
|
1121
|
+
const r = await api.postJson(`/v1/tiktok/campaigns/from-task/${encodeURIComponent(taskId)}`, body);
|
|
945
1122
|
return asText({
|
|
946
1123
|
...r,
|
|
947
1124
|
status: "PAUSED"
|
|
@@ -952,9 +1129,153 @@ function registerPublishTools(server, api) {
|
|
|
952
1129
|
});
|
|
953
1130
|
}
|
|
954
1131
|
__name(registerPublishTools, "registerPublishTools");
|
|
1132
|
+
function renderShopifyPublishMarkdown(r) {
|
|
1133
|
+
const lines = [];
|
|
1134
|
+
const statusEmoji = r.status === "success" ? "\u2705" : r.status === "error" ? "\u274C" : "\u23F3";
|
|
1135
|
+
lines.push(`${statusEmoji} **Publicaci\xF3n en Shopify** \u2014 ${r.shopDomain}`);
|
|
1136
|
+
lines.push(`Estado: \`${r.status}\``);
|
|
1137
|
+
if (r.pageUrl) lines.push(`
|
|
1138
|
+
### Landing
|
|
1139
|
+
[Ver landing publicada](${r.pageUrl})`);
|
|
1140
|
+
if (r.productUrl) lines.push(`
|
|
1141
|
+
### Producto
|
|
1142
|
+
[Ver producto](${r.productUrl})`);
|
|
1143
|
+
if (!r.pageUrl && !r.productUrl && r.status !== "success") {
|
|
1144
|
+
lines.push("\n_La publicaci\xF3n a\xFAn est\xE1 proces\xE1ndose. Usa `get_task_status` para verificar progreso._");
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
content: [
|
|
1148
|
+
{
|
|
1149
|
+
type: "text",
|
|
1150
|
+
text: lines.join("\n")
|
|
1151
|
+
}
|
|
1152
|
+
]
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
__name(renderShopifyPublishMarkdown, "renderShopifyPublishMarkdown");
|
|
1156
|
+
function toMetaCampaignOverrides(cfg) {
|
|
1157
|
+
const out = {};
|
|
1158
|
+
if (cfg.dailyBudgetUsd !== void 0) out.dailyBudgetUsd = cfg.dailyBudgetUsd;
|
|
1159
|
+
if (cfg.targetCountries !== void 0) out.targetCountries = cfg.targetCountries;
|
|
1160
|
+
if (cfg.campaignName !== void 0) out.campaignName = cfg.campaignName;
|
|
1161
|
+
return out;
|
|
1162
|
+
}
|
|
1163
|
+
__name(toMetaCampaignOverrides, "toMetaCampaignOverrides");
|
|
1164
|
+
function toTikTokCampaignConfig(cfg) {
|
|
1165
|
+
const out = {};
|
|
1166
|
+
if (cfg.dailyBudgetUsd !== void 0) out.dailyBudget = Math.round(cfg.dailyBudgetUsd * 100);
|
|
1167
|
+
if (cfg.targetCountries && cfg.targetCountries.length > 0) out.targetCountry = cfg.targetCountries[0];
|
|
1168
|
+
if (cfg.campaignName !== void 0) out.campaignName = cfg.campaignName;
|
|
1169
|
+
return out;
|
|
1170
|
+
}
|
|
1171
|
+
__name(toTikTokCampaignConfig, "toTikTokCampaignConfig");
|
|
1172
|
+
|
|
1173
|
+
// src/tools/campaigns.ts
|
|
1174
|
+
import { z as z6 } from "zod";
|
|
1175
|
+
var MetaStatusInput = z6.object({
|
|
1176
|
+
campaignId: z6.string().min(1).describe("Meta campaign ID returned by publish_to_meta.")
|
|
1177
|
+
});
|
|
1178
|
+
var TikTokStatusInput = z6.object({
|
|
1179
|
+
campaignId: z6.string().min(1).describe("TikTok campaign ID returned by publish_to_tiktok."),
|
|
1180
|
+
advertiserId: z6.string().min(1).describe("The same advertiserId used to publish the campaign.")
|
|
1181
|
+
});
|
|
1182
|
+
function registerCampaignTools(server, api) {
|
|
1183
|
+
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) => {
|
|
1184
|
+
try {
|
|
1185
|
+
const r = await api.getJson(`/v1/facebook/campaigns/${encodeURIComponent(input.campaignId)}`);
|
|
1186
|
+
return renderMetaStatusMarkdown(r);
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
throw translateError(e);
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
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) => {
|
|
1192
|
+
try {
|
|
1193
|
+
const r = await api.getJson(`/v1/tiktok/campaigns/${encodeURIComponent(input.campaignId)}?advertiserId=${encodeURIComponent(input.advertiserId)}`);
|
|
1194
|
+
return renderTikTokStatusMarkdown(r);
|
|
1195
|
+
} catch (e) {
|
|
1196
|
+
throw translateError(e);
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
__name(registerCampaignTools, "registerCampaignTools");
|
|
1201
|
+
function renderCampaignStatusMarkdown(opts) {
|
|
1202
|
+
const lines = [
|
|
1203
|
+
`\u{1F4CA} **${escapeMdLabel(opts.name)}** (\`${opts.campaignId}\`)`
|
|
1204
|
+
];
|
|
1205
|
+
for (const [label, value] of opts.statusLines) {
|
|
1206
|
+
lines.push(`- ${label}: \`${value}\``);
|
|
1207
|
+
}
|
|
1208
|
+
if (opts.metrics) {
|
|
1209
|
+
lines.push("\n### M\xE9tricas (lifetime)");
|
|
1210
|
+
lines.push(`- Spend: ${opts.metrics.spend}`);
|
|
1211
|
+
lines.push(`- Impressions: ${opts.metrics.impressions}`);
|
|
1212
|
+
lines.push(`- Clicks: ${opts.metrics.clicks}`);
|
|
1213
|
+
lines.push(`- Reach: ${opts.metrics.reach}`);
|
|
1214
|
+
} else {
|
|
1215
|
+
lines.push(`
|
|
1216
|
+
_${opts.noMetricsHint ?? "A\xFAn no hay m\xE9tricas reportadas."}_`);
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
content: [
|
|
1220
|
+
{
|
|
1221
|
+
type: "text",
|
|
1222
|
+
text: lines.join("\n")
|
|
1223
|
+
}
|
|
1224
|
+
]
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
__name(renderCampaignStatusMarkdown, "renderCampaignStatusMarkdown");
|
|
1228
|
+
function renderMetaStatusMarkdown(r) {
|
|
1229
|
+
const statusLines = [
|
|
1230
|
+
[
|
|
1231
|
+
"Estado efectivo",
|
|
1232
|
+
r.effectiveStatus
|
|
1233
|
+
],
|
|
1234
|
+
[
|
|
1235
|
+
"Estado configurado",
|
|
1236
|
+
r.configuredStatus
|
|
1237
|
+
]
|
|
1238
|
+
];
|
|
1239
|
+
if (r.objective) statusLines.push([
|
|
1240
|
+
"Objetivo",
|
|
1241
|
+
r.objective
|
|
1242
|
+
]);
|
|
1243
|
+
return renderCampaignStatusMarkdown({
|
|
1244
|
+
name: r.name,
|
|
1245
|
+
campaignId: r.campaignId,
|
|
1246
|
+
statusLines,
|
|
1247
|
+
metrics: r.insights,
|
|
1248
|
+
noMetricsHint: "A\xFAn no hay m\xE9tricas reportadas (la campa\xF1a puede estar pausada o sin entregar)."
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
__name(renderMetaStatusMarkdown, "renderMetaStatusMarkdown");
|
|
1252
|
+
function renderTikTokStatusMarkdown(r) {
|
|
1253
|
+
const statusLines = [
|
|
1254
|
+
[
|
|
1255
|
+
"Estado de operaci\xF3n",
|
|
1256
|
+
r.operationStatus
|
|
1257
|
+
]
|
|
1258
|
+
];
|
|
1259
|
+
if (r.secondaryStatus) statusLines.push([
|
|
1260
|
+
"Estado secundario",
|
|
1261
|
+
r.secondaryStatus
|
|
1262
|
+
]);
|
|
1263
|
+
if (r.objectiveType) statusLines.push([
|
|
1264
|
+
"Objetivo",
|
|
1265
|
+
r.objectiveType
|
|
1266
|
+
]);
|
|
1267
|
+
return renderCampaignStatusMarkdown({
|
|
1268
|
+
name: r.name,
|
|
1269
|
+
campaignId: r.campaignId,
|
|
1270
|
+
statusLines,
|
|
1271
|
+
metrics: r.report
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
__name(renderTikTokStatusMarkdown, "renderTikTokStatusMarkdown");
|
|
955
1275
|
|
|
956
1276
|
// src/server.ts
|
|
957
|
-
function createServer(
|
|
1277
|
+
function createServer(opts) {
|
|
1278
|
+
const { apiKey, apiBaseUrl, proxyBaseUrl = null, transport = "http" } = opts;
|
|
958
1279
|
const server = new McpServer({
|
|
959
1280
|
name: "productmaker",
|
|
960
1281
|
version: MCP_VERSION
|
|
@@ -967,10 +1288,16 @@ function createServer(apiKey, apiBaseUrl, proxyBaseUrl = null) {
|
|
|
967
1288
|
}
|
|
968
1289
|
});
|
|
969
1290
|
const api = new ApiClient(apiBaseUrl, apiKey);
|
|
970
|
-
registerTaskTools(server, api,
|
|
971
|
-
|
|
1291
|
+
registerTaskTools(server, api, {
|
|
1292
|
+
proxyBaseUrl,
|
|
1293
|
+
transport
|
|
1294
|
+
});
|
|
1295
|
+
registerCreativeTools(server, api, {
|
|
1296
|
+
transport
|
|
1297
|
+
});
|
|
972
1298
|
registerConnectionTools(server, api);
|
|
973
1299
|
registerPublishTools(server, api);
|
|
1300
|
+
registerCampaignTools(server, api);
|
|
974
1301
|
return server;
|
|
975
1302
|
}
|
|
976
1303
|
__name(createServer, "createServer");
|
|
@@ -1010,7 +1337,11 @@ async function main() {
|
|
|
1010
1337
|
console.error(e.message);
|
|
1011
1338
|
process.exit(1);
|
|
1012
1339
|
}
|
|
1013
|
-
const server = createServer(
|
|
1340
|
+
const server = createServer({
|
|
1341
|
+
apiKey,
|
|
1342
|
+
apiBaseUrl,
|
|
1343
|
+
transport: "stdio"
|
|
1344
|
+
});
|
|
1014
1345
|
await server.connect(new StdioServerTransport());
|
|
1015
1346
|
}
|
|
1016
1347
|
__name(main, "main");
|