mobile-growth-mcp 2.2.4 → 2.3.5
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/dist/index.js +288 -840
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -164,685 +164,8 @@ function registerFetchedPrompts(server2, apiKey2, prompts) {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// src/tools/meta-campaigns.ts
|
|
168
|
-
import { z as z2 } from "zod";
|
|
169
|
-
|
|
170
|
-
// src/meta/client.ts
|
|
171
|
-
var META_API_VERSION = "v21.0";
|
|
172
|
-
var META_BASE_URL = `https://graph.facebook.com/${META_API_VERSION}`;
|
|
173
|
-
var THROTTLE_WARN_THRESHOLD = 75;
|
|
174
|
-
function getMetaAccessToken() {
|
|
175
|
-
const token = process.env.META_ACCESS_TOKEN;
|
|
176
|
-
if (!token) {
|
|
177
|
-
throw new Error(
|
|
178
|
-
'Missing META_ACCESS_TOKEN. To fix, add it to your MCP config:\n \u2022 Claude Code / Cursor: add "META_ACCESS_TOKEN": "..." to the "env" block in .mcp.json\n \u2022 Claude Desktop: add "META_ACCESS_TOKEN": "..." to the "env" block in claude_desktop_config.json\n \u2022 CLI: add META_ACCESS_TOKEN=... to a .env file in your working directory\nThen restart your MCP client.'
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
return token;
|
|
182
|
-
}
|
|
183
|
-
function isMetaApiError(body) {
|
|
184
|
-
return typeof body === "object" && body !== null && "error" in body && typeof body.error?.message === "string";
|
|
185
|
-
}
|
|
186
|
-
function formatMetaError(err) {
|
|
187
|
-
const code = err.code;
|
|
188
|
-
const sub = err.error_subcode;
|
|
189
|
-
if (code === 190) {
|
|
190
|
-
return `Authentication error: Access token is invalid or expired. Generate a new token in Meta Business Suite. (code ${code})`;
|
|
191
|
-
}
|
|
192
|
-
if (code === 4 || code === 17 || code >= 8e4 && code <= 80099) {
|
|
193
|
-
return `Rate limit hit: ${err.message}. Wait a few minutes before retrying. (code ${code}${sub ? `/${sub}` : ""})`;
|
|
194
|
-
}
|
|
195
|
-
if (code === 100) {
|
|
196
|
-
return `Invalid parameter: ${err.message} (code ${code}${sub ? `/${sub}` : ""})`;
|
|
197
|
-
}
|
|
198
|
-
return `Meta API error: ${err.message} (code ${code}${sub ? `/${sub}` : ""})`;
|
|
199
|
-
}
|
|
200
|
-
function parseThrottleHeader(headers) {
|
|
201
|
-
const raw = headers.get("x-fb-ads-insights-throttle");
|
|
202
|
-
if (!raw) return void 0;
|
|
203
|
-
try {
|
|
204
|
-
const info = JSON.parse(raw);
|
|
205
|
-
const maxUtil = Math.max(info.app_id_util_pct, info.acc_id_util_pct);
|
|
206
|
-
const warning = maxUtil > THROTTLE_WARN_THRESHOLD ? `\u26A0 Meta API utilization at ${maxUtil}% \u2014 approaching rate limit. Slow down requests.` : void 0;
|
|
207
|
-
return { throttle: info, warning };
|
|
208
|
-
} catch {
|
|
209
|
-
return void 0;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
async function metaApiGet(options) {
|
|
213
|
-
const token = getMetaAccessToken();
|
|
214
|
-
const url = new URL(`${META_BASE_URL}${options.path}`);
|
|
215
|
-
if (options.params) {
|
|
216
|
-
for (const [key, value] of Object.entries(options.params)) {
|
|
217
|
-
url.searchParams.set(key, value);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const response = await fetch(url.toString(), {
|
|
221
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
222
|
-
});
|
|
223
|
-
const body = await response.json();
|
|
224
|
-
if (!response.ok || isMetaApiError(body)) {
|
|
225
|
-
if (isMetaApiError(body)) {
|
|
226
|
-
throw new Error(formatMetaError(body.error));
|
|
227
|
-
}
|
|
228
|
-
throw new Error(`Meta API returned ${response.status}: ${JSON.stringify(body)}`);
|
|
229
|
-
}
|
|
230
|
-
const throttleInfo = parseThrottleHeader(response.headers);
|
|
231
|
-
return {
|
|
232
|
-
data: body,
|
|
233
|
-
throttle: throttleInfo?.throttle,
|
|
234
|
-
warning: throttleInfo?.warning
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
function activeFilter() {
|
|
238
|
-
return JSON.stringify([
|
|
239
|
-
{
|
|
240
|
-
field: "ad.effective_status",
|
|
241
|
-
operator: "IN",
|
|
242
|
-
value: ["ACTIVE"]
|
|
243
|
-
}
|
|
244
|
-
]);
|
|
245
|
-
}
|
|
246
|
-
function getActionValue(actions, actionType) {
|
|
247
|
-
if (!actions) return 0;
|
|
248
|
-
const action = actions.find((a) => a.action_type === actionType);
|
|
249
|
-
return action ? parseFloat(action.value) : 0;
|
|
250
|
-
}
|
|
251
|
-
function getCostPerAction(costPerAction, actionType) {
|
|
252
|
-
if (!costPerAction) return null;
|
|
253
|
-
const action = costPerAction.find((a) => a.action_type === actionType);
|
|
254
|
-
return action ? parseFloat(action.value) : null;
|
|
255
|
-
}
|
|
256
|
-
var DEFAULT_DATE_PRESET = "last_7d";
|
|
257
|
-
var CAMPAIGN_DEFAULT_FIELDS = "id,name,status,effective_status,objective,bid_strategy,daily_budget,lifetime_budget,buying_type,special_ad_categories";
|
|
258
|
-
var ADSET_DEFAULT_FIELDS = "id,name,status,effective_status,campaign_id,optimization_goal,billing_event,bid_strategy,bid_amount,daily_budget,lifetime_budget,targeting,promoted_object";
|
|
259
|
-
var AD_DEFAULT_FIELDS = "id,name,status,effective_status,adset_id,campaign_id,creative{id,title,body,image_url,video_id,call_to_action_type}";
|
|
260
|
-
var INSIGHT_DEFAULT_FIELDS = "campaign_id,campaign_name,spend,impressions,clicks,ctr,cpm,cpc,actions,cost_per_action_type";
|
|
261
|
-
|
|
262
|
-
// src/tools/meta-campaigns.ts
|
|
263
|
-
function registerGetMetaCampaigns(server2) {
|
|
264
|
-
server2.tool(
|
|
265
|
-
"get_meta_campaigns",
|
|
266
|
-
"List campaigns from a Meta ad account. Defaults to active campaigns with lean field set. Returns first page only \u2014 use 'after' cursor for next page.",
|
|
267
|
-
{
|
|
268
|
-
ad_account_id: z2.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
269
|
-
fields: z2.string().optional().describe(
|
|
270
|
-
`Comma-separated fields. Default: ${CAMPAIGN_DEFAULT_FIELDS}`
|
|
271
|
-
),
|
|
272
|
-
effective_status: z2.array(z2.string()).optional().describe(
|
|
273
|
-
'Filter by status. Default: ["ACTIVE"]. Use ["ACTIVE","PAUSED"] for all non-deleted.'
|
|
274
|
-
),
|
|
275
|
-
limit: z2.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
|
|
276
|
-
after: z2.string().optional().describe("Pagination cursor from previous response")
|
|
277
|
-
},
|
|
278
|
-
async ({ ad_account_id, fields, effective_status, limit, after }) => {
|
|
279
|
-
try {
|
|
280
|
-
const params = {
|
|
281
|
-
fields: fields ?? CAMPAIGN_DEFAULT_FIELDS,
|
|
282
|
-
limit: String(limit ?? 50)
|
|
283
|
-
};
|
|
284
|
-
if (effective_status) {
|
|
285
|
-
params.filtering = JSON.stringify([
|
|
286
|
-
{
|
|
287
|
-
field: "effective_status",
|
|
288
|
-
operator: "IN",
|
|
289
|
-
value: effective_status
|
|
290
|
-
}
|
|
291
|
-
]);
|
|
292
|
-
} else {
|
|
293
|
-
params.filtering = activeFilter();
|
|
294
|
-
}
|
|
295
|
-
if (after) {
|
|
296
|
-
params.after = after;
|
|
297
|
-
}
|
|
298
|
-
const result = await metaApiGet({
|
|
299
|
-
path: `/${ad_account_id}/campaigns`,
|
|
300
|
-
params
|
|
301
|
-
});
|
|
302
|
-
const campaigns = result.data.data;
|
|
303
|
-
const nextCursor = result.data.paging?.cursors?.after;
|
|
304
|
-
let text = `Found ${campaigns.length} campaigns:
|
|
305
|
-
|
|
306
|
-
`;
|
|
307
|
-
for (const c of campaigns) {
|
|
308
|
-
text += `- **${c.name}** (${c.id})
|
|
309
|
-
Status: ${c.effective_status} | Objective: ${c.objective}` + (c.bid_strategy ? ` | Bid: ${c.bid_strategy}` : "") + (c.daily_budget ? ` | Daily: $${(parseInt(c.daily_budget) / 100).toFixed(2)}` : "") + (c.lifetime_budget ? ` | Lifetime: $${(parseInt(c.lifetime_budget) / 100).toFixed(2)}` : "") + "\n";
|
|
310
|
-
}
|
|
311
|
-
if (nextCursor) {
|
|
312
|
-
text += `
|
|
313
|
-
_More results available. Pass \`after: "${nextCursor}"\` for next page._`;
|
|
314
|
-
}
|
|
315
|
-
if (result.warning) {
|
|
316
|
-
text = `${result.warning}
|
|
317
|
-
|
|
318
|
-
${text}`;
|
|
319
|
-
}
|
|
320
|
-
return { content: [{ type: "text", text }] };
|
|
321
|
-
} catch (err) {
|
|
322
|
-
return {
|
|
323
|
-
content: [
|
|
324
|
-
{
|
|
325
|
-
type: "text",
|
|
326
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
327
|
-
}
|
|
328
|
-
],
|
|
329
|
-
isError: true
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// src/tools/meta-adsets.ts
|
|
337
|
-
import { z as z3 } from "zod";
|
|
338
|
-
function registerGetMetaAdSets(server2) {
|
|
339
|
-
server2.tool(
|
|
340
|
-
"get_meta_adsets",
|
|
341
|
-
"List ad sets from a Meta ad account, optionally scoped to a campaign. Defaults to active ad sets with lean field set.",
|
|
342
|
-
{
|
|
343
|
-
ad_account_id: z3.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
344
|
-
campaign_id: z3.string().optional().describe(
|
|
345
|
-
"Scope to a specific campaign ID. If provided, fetches ad sets under that campaign."
|
|
346
|
-
),
|
|
347
|
-
fields: z3.string().optional().describe(`Comma-separated fields. Default: ${ADSET_DEFAULT_FIELDS}`),
|
|
348
|
-
effective_status: z3.array(z3.string()).optional().describe('Filter by status. Default: ["ACTIVE"]'),
|
|
349
|
-
limit: z3.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
|
|
350
|
-
after: z3.string().optional().describe("Pagination cursor from previous response")
|
|
351
|
-
},
|
|
352
|
-
async ({
|
|
353
|
-
ad_account_id,
|
|
354
|
-
campaign_id,
|
|
355
|
-
fields,
|
|
356
|
-
effective_status,
|
|
357
|
-
limit,
|
|
358
|
-
after
|
|
359
|
-
}) => {
|
|
360
|
-
try {
|
|
361
|
-
const params = {
|
|
362
|
-
fields: fields ?? ADSET_DEFAULT_FIELDS,
|
|
363
|
-
limit: String(limit ?? 50)
|
|
364
|
-
};
|
|
365
|
-
if (effective_status) {
|
|
366
|
-
params.filtering = JSON.stringify([
|
|
367
|
-
{
|
|
368
|
-
field: "effective_status",
|
|
369
|
-
operator: "IN",
|
|
370
|
-
value: effective_status
|
|
371
|
-
}
|
|
372
|
-
]);
|
|
373
|
-
} else {
|
|
374
|
-
params.filtering = activeFilter();
|
|
375
|
-
}
|
|
376
|
-
if (after) {
|
|
377
|
-
params.after = after;
|
|
378
|
-
}
|
|
379
|
-
const parentPath = campaign_id ? `/${campaign_id}/adsets` : `/${ad_account_id}/adsets`;
|
|
380
|
-
const result = await metaApiGet({
|
|
381
|
-
path: parentPath,
|
|
382
|
-
params
|
|
383
|
-
});
|
|
384
|
-
const adsets = result.data.data;
|
|
385
|
-
const nextCursor = result.data.paging?.cursors?.after;
|
|
386
|
-
let text = `Found ${adsets.length} ad sets:
|
|
387
|
-
|
|
388
|
-
`;
|
|
389
|
-
for (const a of adsets) {
|
|
390
|
-
text += `- **${a.name}** (${a.id})
|
|
391
|
-
Campaign: ${a.campaign_id} | Status: ${a.effective_status}` + (a.optimization_goal ? ` | Goal: ${a.optimization_goal}` : "") + (a.bid_strategy ? ` | Bid: ${a.bid_strategy}` : "") + (a.daily_budget ? ` | Daily: $${(parseInt(a.daily_budget) / 100).toFixed(2)}` : "") + "\n";
|
|
392
|
-
}
|
|
393
|
-
if (nextCursor) {
|
|
394
|
-
text += `
|
|
395
|
-
_More results available. Pass \`after: "${nextCursor}"\` for next page._`;
|
|
396
|
-
}
|
|
397
|
-
if (result.warning) {
|
|
398
|
-
text = `${result.warning}
|
|
399
|
-
|
|
400
|
-
${text}`;
|
|
401
|
-
}
|
|
402
|
-
return { content: [{ type: "text", text }] };
|
|
403
|
-
} catch (err) {
|
|
404
|
-
return {
|
|
405
|
-
content: [
|
|
406
|
-
{
|
|
407
|
-
type: "text",
|
|
408
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
409
|
-
}
|
|
410
|
-
],
|
|
411
|
-
isError: true
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// src/tools/meta-ads.ts
|
|
419
|
-
import { z as z4 } from "zod";
|
|
420
|
-
function registerGetMetaAds(server2) {
|
|
421
|
-
server2.tool(
|
|
422
|
-
"get_meta_ads",
|
|
423
|
-
"List ads from a Meta ad account, optionally scoped to an ad set. Defaults to active ads with lean field set.",
|
|
424
|
-
{
|
|
425
|
-
ad_account_id: z4.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
426
|
-
adset_id: z4.string().optional().describe("Scope to a specific ad set ID."),
|
|
427
|
-
fields: z4.string().optional().describe(`Comma-separated fields. Default: ${AD_DEFAULT_FIELDS}`),
|
|
428
|
-
effective_status: z4.array(z4.string()).optional().describe('Filter by status. Default: ["ACTIVE"]'),
|
|
429
|
-
limit: z4.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
|
|
430
|
-
after: z4.string().optional().describe("Pagination cursor from previous response")
|
|
431
|
-
},
|
|
432
|
-
async ({ ad_account_id, adset_id, fields, effective_status, limit, after }) => {
|
|
433
|
-
try {
|
|
434
|
-
const params = {
|
|
435
|
-
fields: fields ?? AD_DEFAULT_FIELDS,
|
|
436
|
-
limit: String(limit ?? 50)
|
|
437
|
-
};
|
|
438
|
-
if (effective_status) {
|
|
439
|
-
params.filtering = JSON.stringify([
|
|
440
|
-
{
|
|
441
|
-
field: "effective_status",
|
|
442
|
-
operator: "IN",
|
|
443
|
-
value: effective_status
|
|
444
|
-
}
|
|
445
|
-
]);
|
|
446
|
-
} else {
|
|
447
|
-
params.filtering = activeFilter();
|
|
448
|
-
}
|
|
449
|
-
if (after) {
|
|
450
|
-
params.after = after;
|
|
451
|
-
}
|
|
452
|
-
const parentPath = adset_id ? `/${adset_id}/ads` : `/${ad_account_id}/ads`;
|
|
453
|
-
const result = await metaApiGet({
|
|
454
|
-
path: parentPath,
|
|
455
|
-
params
|
|
456
|
-
});
|
|
457
|
-
const ads = result.data.data;
|
|
458
|
-
const nextCursor = result.data.paging?.cursors?.after;
|
|
459
|
-
let text = `Found ${ads.length} ads:
|
|
460
|
-
|
|
461
|
-
`;
|
|
462
|
-
for (const ad of ads) {
|
|
463
|
-
text += `- **${ad.name}** (${ad.id})
|
|
464
|
-
Ad Set: ${ad.adset_id} | Status: ${ad.effective_status}` + (ad.creative?.call_to_action_type ? ` | CTA: ${ad.creative.call_to_action_type}` : "") + (ad.creative?.video_id ? " | Format: Video" : "") + (ad.creative?.image_url ? " | Format: Image" : "") + "\n";
|
|
465
|
-
}
|
|
466
|
-
if (nextCursor) {
|
|
467
|
-
text += `
|
|
468
|
-
_More results available. Pass \`after: "${nextCursor}"\` for next page._`;
|
|
469
|
-
}
|
|
470
|
-
if (result.warning) {
|
|
471
|
-
text = `${result.warning}
|
|
472
|
-
|
|
473
|
-
${text}`;
|
|
474
|
-
}
|
|
475
|
-
return { content: [{ type: "text", text }] };
|
|
476
|
-
} catch (err) {
|
|
477
|
-
return {
|
|
478
|
-
content: [
|
|
479
|
-
{
|
|
480
|
-
type: "text",
|
|
481
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
482
|
-
}
|
|
483
|
-
],
|
|
484
|
-
isError: true
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// src/tools/meta-insights.ts
|
|
492
|
-
import { z as z5 } from "zod";
|
|
493
|
-
function registerGetMetaInsights(server2) {
|
|
494
|
-
server2.tool(
|
|
495
|
-
"get_meta_insights",
|
|
496
|
-
"Pull performance insights from a Meta ad account with configurable level, breakdowns, and date range. Default: campaign-level, last 7 days, active only. Conversion event is configurable (default: mobile_app_install).",
|
|
497
|
-
{
|
|
498
|
-
ad_account_id: z5.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
499
|
-
level: z5.enum(["account", "campaign", "adset", "ad"]).optional().describe("Aggregation level (default: campaign)"),
|
|
500
|
-
fields: z5.string().optional().describe(
|
|
501
|
-
`Comma-separated fields. Default: ${INSIGHT_DEFAULT_FIELDS}`
|
|
502
|
-
),
|
|
503
|
-
date_preset: z5.string().optional().describe(`Date preset (default: ${DEFAULT_DATE_PRESET})`),
|
|
504
|
-
time_range: z5.object({
|
|
505
|
-
since: z5.string().describe("Start date YYYY-MM-DD"),
|
|
506
|
-
until: z5.string().describe("End date YYYY-MM-DD")
|
|
507
|
-
}).optional().describe("Custom date range. Overrides date_preset if provided."),
|
|
508
|
-
time_increment: z5.string().optional().describe(
|
|
509
|
-
'Time granularity: "1" for daily, "7" for weekly, "monthly", or "all_days" (default: aggregated)'
|
|
510
|
-
),
|
|
511
|
-
breakdowns: z5.string().optional().describe(
|
|
512
|
-
"Comma-separated breakdowns (e.g. age,gender or publisher_platform,platform_position)"
|
|
513
|
-
),
|
|
514
|
-
filtering: z5.string().optional().describe(
|
|
515
|
-
'JSON filtering array. Default: active only. Pass "[]" to include all statuses.'
|
|
516
|
-
),
|
|
517
|
-
conversion_event: z5.string().optional().describe(
|
|
518
|
-
"Action type for CPA calculation (default: mobile_app_install)"
|
|
519
|
-
),
|
|
520
|
-
sort: z5.string().optional().describe(
|
|
521
|
-
'Sort field (e.g. "spend_descending", "impressions_descending")'
|
|
522
|
-
),
|
|
523
|
-
limit: z5.number().min(1).max(500).optional().describe("Results per page (default 50, max 500)"),
|
|
524
|
-
after: z5.string().optional().describe("Pagination cursor from previous response")
|
|
525
|
-
},
|
|
526
|
-
async ({
|
|
527
|
-
ad_account_id,
|
|
528
|
-
level,
|
|
529
|
-
fields,
|
|
530
|
-
date_preset,
|
|
531
|
-
time_range,
|
|
532
|
-
time_increment,
|
|
533
|
-
breakdowns,
|
|
534
|
-
filtering,
|
|
535
|
-
conversion_event,
|
|
536
|
-
sort,
|
|
537
|
-
limit,
|
|
538
|
-
after
|
|
539
|
-
}) => {
|
|
540
|
-
try {
|
|
541
|
-
const convEvent = conversion_event ?? "mobile_app_install";
|
|
542
|
-
const params = {
|
|
543
|
-
fields: fields ?? INSIGHT_DEFAULT_FIELDS,
|
|
544
|
-
level: level ?? "campaign",
|
|
545
|
-
limit: String(limit ?? 50)
|
|
546
|
-
};
|
|
547
|
-
if (time_range) {
|
|
548
|
-
params.time_range = JSON.stringify(time_range);
|
|
549
|
-
} else {
|
|
550
|
-
params.date_preset = date_preset ?? DEFAULT_DATE_PRESET;
|
|
551
|
-
}
|
|
552
|
-
if (time_increment) {
|
|
553
|
-
params.time_increment = time_increment;
|
|
554
|
-
}
|
|
555
|
-
if (breakdowns) {
|
|
556
|
-
params.breakdowns = breakdowns;
|
|
557
|
-
}
|
|
558
|
-
if (filtering !== void 0) {
|
|
559
|
-
params.filtering = filtering === "[]" ? "[]" : filtering;
|
|
560
|
-
} else {
|
|
561
|
-
params.filtering = activeFilter();
|
|
562
|
-
}
|
|
563
|
-
if (sort) {
|
|
564
|
-
params.sort = sort;
|
|
565
|
-
}
|
|
566
|
-
if (after) {
|
|
567
|
-
params.after = after;
|
|
568
|
-
}
|
|
569
|
-
const result = await metaApiGet({
|
|
570
|
-
path: `/${ad_account_id}/insights`,
|
|
571
|
-
params
|
|
572
|
-
});
|
|
573
|
-
const rows = result.data.data;
|
|
574
|
-
const nextCursor = result.data.paging?.cursors?.after;
|
|
575
|
-
if (!rows || rows.length === 0) {
|
|
576
|
-
return {
|
|
577
|
-
content: [
|
|
578
|
-
{
|
|
579
|
-
type: "text",
|
|
580
|
-
text: "No insight data returned for the given parameters. Try a broader date range or check that active campaigns exist."
|
|
581
|
-
}
|
|
582
|
-
]
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
let text = `**${rows.length} rows** | Level: ${level ?? "campaign"} | Event: ${convEvent}
|
|
586
|
-
|
|
587
|
-
`;
|
|
588
|
-
for (const row of rows) {
|
|
589
|
-
const spend = parseFloat(row.spend || "0");
|
|
590
|
-
const impressions = parseInt(row.impressions || "0");
|
|
591
|
-
const conversions = getActionValue(row.actions, convEvent);
|
|
592
|
-
const cpa = getCostPerAction(row.cost_per_action_type, convEvent);
|
|
593
|
-
const label = row.ad_name ?? row.adset_name ?? row.campaign_name ?? row.campaign_id ?? "Account";
|
|
594
|
-
const breakdownParts = [];
|
|
595
|
-
if (row.age) breakdownParts.push(`Age: ${row.age}`);
|
|
596
|
-
if (row.gender) breakdownParts.push(`Gender: ${row.gender}`);
|
|
597
|
-
if (row.publisher_platform)
|
|
598
|
-
breakdownParts.push(`Platform: ${row.publisher_platform}`);
|
|
599
|
-
if (row.platform_position)
|
|
600
|
-
breakdownParts.push(`Position: ${row.platform_position}`);
|
|
601
|
-
if (row.country) breakdownParts.push(`Country: ${row.country}`);
|
|
602
|
-
const breakdownStr = breakdownParts.length > 0 ? ` (${breakdownParts.join(", ")})` : "";
|
|
603
|
-
const dateStr = time_increment && row.date_start !== row.date_stop ? ` [${row.date_start} \u2192 ${row.date_stop}]` : time_increment ? ` [${row.date_start}]` : "";
|
|
604
|
-
text += `**${label}**${breakdownStr}${dateStr}
|
|
605
|
-
Spend: $${spend.toFixed(2)} | Impressions: ${impressions.toLocaleString()}` + (row.clicks ? ` | Clicks: ${row.clicks}` : "") + (row.ctr ? ` | CTR: ${row.ctr}%` : "") + (row.cpm ? ` | CPM: $${row.cpm}` : "") + (row.frequency ? ` | Freq: ${row.frequency}` : "") + (conversions > 0 ? ` | ${convEvent}: ${conversions}` : "") + (cpa !== null ? ` | CPA: $${cpa.toFixed(2)}` : "") + "\n\n";
|
|
606
|
-
}
|
|
607
|
-
if (nextCursor) {
|
|
608
|
-
text += `_More results available. Pass \`after: "${nextCursor}"\` for next page._
|
|
609
|
-
`;
|
|
610
|
-
}
|
|
611
|
-
if (result.warning) {
|
|
612
|
-
text = `${result.warning}
|
|
613
|
-
|
|
614
|
-
${text}`;
|
|
615
|
-
}
|
|
616
|
-
return { content: [{ type: "text", text }] };
|
|
617
|
-
} catch (err) {
|
|
618
|
-
return {
|
|
619
|
-
content: [
|
|
620
|
-
{
|
|
621
|
-
type: "text",
|
|
622
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
623
|
-
}
|
|
624
|
-
],
|
|
625
|
-
isError: true
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// src/tools/meta-ad-fatigue.ts
|
|
633
|
-
import { z as z6 } from "zod";
|
|
634
|
-
function registerGetMetaAdFatigue(server2) {
|
|
635
|
-
server2.tool(
|
|
636
|
-
"get_meta_ad_fatigue",
|
|
637
|
-
"Detect creative fatigue in active ads. Analyzes frequency, CTR decline, and CPA trends over the last 7 days at daily granularity. Grounded in knowledge base insights: wk-tw-001 (diagnostic patterns), ds-pt-003 (frequency thresholds), vs-nt-002 (degraded creative rules).",
|
|
638
|
-
{
|
|
639
|
-
ad_account_id: z6.string().describe("Meta ad account ID (e.g. act_123456789)"),
|
|
640
|
-
campaign_id: z6.string().optional().describe("Scope to a specific campaign"),
|
|
641
|
-
conversion_event: z6.string().optional().describe("Action type for CPA (default: mobile_app_install)"),
|
|
642
|
-
frequency_warning: z6.number().optional().describe("Frequency threshold for warning (default: 3)"),
|
|
643
|
-
frequency_critical: z6.number().optional().describe("Frequency threshold for critical (default: 5)"),
|
|
644
|
-
ctr_decline_threshold: z6.number().optional().describe(
|
|
645
|
-
"CTR decline % from peak to flag fatigue (default: 30)"
|
|
646
|
-
)
|
|
647
|
-
},
|
|
648
|
-
async ({
|
|
649
|
-
ad_account_id,
|
|
650
|
-
campaign_id,
|
|
651
|
-
conversion_event,
|
|
652
|
-
frequency_warning,
|
|
653
|
-
frequency_critical,
|
|
654
|
-
ctr_decline_threshold
|
|
655
|
-
}) => {
|
|
656
|
-
try {
|
|
657
|
-
const convEvent = conversion_event ?? "mobile_app_install";
|
|
658
|
-
const freqWarn = frequency_warning ?? 3;
|
|
659
|
-
const freqCrit = frequency_critical ?? 5;
|
|
660
|
-
const ctrThreshold = ctr_decline_threshold ?? 30;
|
|
661
|
-
const filtering = JSON.parse(activeFilter());
|
|
662
|
-
if (campaign_id) {
|
|
663
|
-
filtering.push({
|
|
664
|
-
field: "campaign.id",
|
|
665
|
-
operator: "EQUAL",
|
|
666
|
-
value: campaign_id
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
const result = await metaApiGet({
|
|
670
|
-
path: `/${ad_account_id}/insights`,
|
|
671
|
-
params: {
|
|
672
|
-
level: "ad",
|
|
673
|
-
time_increment: "1",
|
|
674
|
-
fields: "ad_id,ad_name,spend,impressions,clicks,ctr,cpm,frequency,actions,cost_per_action_type",
|
|
675
|
-
date_preset: "last_7d",
|
|
676
|
-
filtering: JSON.stringify(filtering),
|
|
677
|
-
limit: "500"
|
|
678
|
-
}
|
|
679
|
-
});
|
|
680
|
-
const rows = result.data.data;
|
|
681
|
-
if (!rows || rows.length === 0) {
|
|
682
|
-
return {
|
|
683
|
-
content: [
|
|
684
|
-
{
|
|
685
|
-
type: "text",
|
|
686
|
-
text: "No active ad data found for the last 7 days."
|
|
687
|
-
}
|
|
688
|
-
]
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
const adMap = /* @__PURE__ */ new Map();
|
|
692
|
-
for (const row of rows) {
|
|
693
|
-
const adId = row.ad_id;
|
|
694
|
-
if (!adMap.has(adId)) {
|
|
695
|
-
adMap.set(adId, {
|
|
696
|
-
ad_id: adId,
|
|
697
|
-
ad_name: row.ad_name ?? adId,
|
|
698
|
-
days: []
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
adMap.get(adId).days.push({
|
|
702
|
-
date: row.date_start,
|
|
703
|
-
spend: parseFloat(row.spend || "0"),
|
|
704
|
-
impressions: parseInt(row.impressions || "0"),
|
|
705
|
-
clicks: parseInt(row.clicks || "0"),
|
|
706
|
-
ctr: parseFloat(row.ctr || "0"),
|
|
707
|
-
cpm: parseFloat(row.cpm || "0"),
|
|
708
|
-
frequency: parseFloat(row.frequency || "0"),
|
|
709
|
-
conversions: getActionValue(row.actions, convEvent),
|
|
710
|
-
cpa: getCostPerAction(row.cost_per_action_type, convEvent)
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
const results = [];
|
|
714
|
-
let totalSpend = 0;
|
|
715
|
-
for (const ad of adMap.values()) {
|
|
716
|
-
ad.days.sort(
|
|
717
|
-
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
718
|
-
);
|
|
719
|
-
const adSpend = ad.days.reduce((s, d) => s + d.spend, 0);
|
|
720
|
-
totalSpend += adSpend;
|
|
721
|
-
const avgFreq = ad.days.reduce((s, d) => s + d.frequency, 0) / ad.days.length;
|
|
722
|
-
const peakCtr = Math.max(...ad.days.map((d) => d.ctr));
|
|
723
|
-
const last3 = ad.days.slice(-3);
|
|
724
|
-
const recentCtr = last3.reduce((s, d) => s + d.ctr, 0) / last3.length;
|
|
725
|
-
const ctrDecline = peakCtr > 0 ? (peakCtr - recentCtr) / peakCtr * 100 : 0;
|
|
726
|
-
const mid = Math.floor(ad.days.length / 2);
|
|
727
|
-
const earlyDays = ad.days.slice(0, Math.max(mid, 1));
|
|
728
|
-
const lateDays = ad.days.slice(mid);
|
|
729
|
-
const earlyConv = earlyDays.reduce((s, d) => s + d.conversions, 0);
|
|
730
|
-
const earlySpend = earlyDays.reduce((s, d) => s + d.spend, 0);
|
|
731
|
-
const earlyCpa = earlyConv > 0 ? earlySpend / earlyConv : null;
|
|
732
|
-
const lateConv = lateDays.reduce((s, d) => s + d.conversions, 0);
|
|
733
|
-
const lateSpend = lateDays.reduce((s, d) => s + d.spend, 0);
|
|
734
|
-
const recentCpa = lateConv > 0 ? lateSpend / lateConv : null;
|
|
735
|
-
const cpaChange = earlyCpa !== null && recentCpa !== null && earlyCpa > 0 ? (recentCpa - earlyCpa) / earlyCpa * 100 : null;
|
|
736
|
-
let status2 = "HEALTHY";
|
|
737
|
-
let diagnosis = "Metrics stable \u2014 no fatigue detected.";
|
|
738
|
-
const highFreq = avgFreq >= freqCrit;
|
|
739
|
-
const medFreq = avgFreq >= freqWarn;
|
|
740
|
-
const ctrDeclining = ctrDecline >= ctrThreshold;
|
|
741
|
-
const cpaRising = cpaChange !== null && cpaChange > 20;
|
|
742
|
-
if (highFreq && cpaRising) {
|
|
743
|
-
status2 = "FATIGUED";
|
|
744
|
-
diagnosis = `Audience saturation: frequency ${avgFreq.toFixed(1)} + CPA rising ${cpaChange.toFixed(0)}% [wk-tw-001 #1, ds-pt-003]`;
|
|
745
|
-
} else if (ctrDeclining && cpaRising) {
|
|
746
|
-
status2 = "FATIGUED";
|
|
747
|
-
diagnosis = `Creative fatigue: CTR declined ${ctrDecline.toFixed(0)}% from peak + CPA rising ${cpaChange.toFixed(0)}% [wk-tw-001 #4]`;
|
|
748
|
-
} else if (highFreq) {
|
|
749
|
-
status2 = "WARNING";
|
|
750
|
-
diagnosis = `High frequency (${avgFreq.toFixed(1)}) \u2014 approaching saturation [ds-pt-003]. CPA ${cpaRising ? "rising" : "stable"}.`;
|
|
751
|
-
} else if (ctrDeclining) {
|
|
752
|
-
status2 = "WARNING";
|
|
753
|
-
diagnosis = `CTR declining ${ctrDecline.toFixed(0)}% from peak \u2014 early fatigue signal [wk-tw-001 #4].`;
|
|
754
|
-
} else if (medFreq && cpaRising) {
|
|
755
|
-
status2 = "WARNING";
|
|
756
|
-
diagnosis = `Frequency ${avgFreq.toFixed(1)} + CPA trending up ${cpaChange.toFixed(0)}% \u2014 monitor closely [wk-tw-001 #1].`;
|
|
757
|
-
}
|
|
758
|
-
results.push({
|
|
759
|
-
ad_id: ad.ad_id,
|
|
760
|
-
ad_name: ad.ad_name,
|
|
761
|
-
total_spend: adSpend,
|
|
762
|
-
avg_frequency: avgFreq,
|
|
763
|
-
peak_ctr: peakCtr,
|
|
764
|
-
recent_ctr: recentCtr,
|
|
765
|
-
ctr_decline_pct: ctrDecline,
|
|
766
|
-
early_cpa: earlyCpa,
|
|
767
|
-
recent_cpa: recentCpa,
|
|
768
|
-
cpa_change_pct: cpaChange,
|
|
769
|
-
status: status2,
|
|
770
|
-
diagnosis
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
const statusOrder = { FATIGUED: 0, WARNING: 1, HEALTHY: 2 };
|
|
774
|
-
results.sort(
|
|
775
|
-
(a, b) => statusOrder[a.status] - statusOrder[b.status] || b.total_spend - a.total_spend
|
|
776
|
-
);
|
|
777
|
-
const fatigued = results.filter((r) => r.status === "FATIGUED");
|
|
778
|
-
const warning = results.filter((r) => r.status === "WARNING");
|
|
779
|
-
const healthy = results.filter((r) => r.status === "HEALTHY");
|
|
780
|
-
const fatiguedSpend = fatigued.reduce((s, r) => s + r.total_spend, 0);
|
|
781
|
-
const spendConcentration = totalSpend > 0 ? fatiguedSpend / totalSpend * 100 : 0;
|
|
782
|
-
let text = `# Ad Fatigue Report
|
|
783
|
-
|
|
784
|
-
`;
|
|
785
|
-
text += `**${results.length} ads analyzed** | ${fatigued.length} fatigued | ${warning.length} warning | ${healthy.length} healthy
|
|
786
|
-
`;
|
|
787
|
-
text += `**Total spend:** $${totalSpend.toFixed(2)} | **Fatigued ad spend:** $${fatiguedSpend.toFixed(2)} (${spendConcentration.toFixed(0)}%)
|
|
788
|
-
|
|
789
|
-
`;
|
|
790
|
-
if (spendConcentration > 50) {
|
|
791
|
-
text += `\u26A0 **Over 50% of spend is going to fatigued ads.** Urgent creative rotation needed.
|
|
792
|
-
|
|
793
|
-
`;
|
|
794
|
-
}
|
|
795
|
-
text += `| Status | Ad | Spend | Freq | CTR (peak\u2192recent) | CPA Trend | Diagnosis |
|
|
796
|
-
`;
|
|
797
|
-
text += `|--------|-----|-------|------|-------------------|-----------|------------|
|
|
798
|
-
`;
|
|
799
|
-
for (const r of results) {
|
|
800
|
-
const icon = r.status === "FATIGUED" ? "\u{1F534}" : r.status === "WARNING" ? "\u{1F7E1}" : "\u{1F7E2}";
|
|
801
|
-
text += `| ${icon} ${r.status} | ${r.ad_name.slice(0, 30)} | $${r.total_spend.toFixed(0)} | ${r.avg_frequency.toFixed(1)} | ${r.peak_ctr.toFixed(2)}%\u2192${r.recent_ctr.toFixed(2)}% (${r.ctr_decline_pct > 0 ? "-" : ""}${r.ctr_decline_pct.toFixed(0)}%) | ${r.cpa_change_pct !== null ? `${r.cpa_change_pct > 0 ? "+" : ""}${r.cpa_change_pct.toFixed(0)}%` : "N/A"} | ${r.diagnosis} |
|
|
802
|
-
`;
|
|
803
|
-
}
|
|
804
|
-
text += `
|
|
805
|
-
## Recommendations
|
|
806
|
-
|
|
807
|
-
`;
|
|
808
|
-
if (fatigued.length > 0) {
|
|
809
|
-
text += `### Fatigued Ads (${fatigued.length})
|
|
810
|
-
- **Rotate with genuinely new creative concepts**, not variations. Meta's Andromeda requires at least 25% visual difference to treat it as a new ad [lp-pt-001, oh-li-001].
|
|
811
|
-
- **Change the hook** (first 3 seconds) to access new audience segments [oh-li-005].
|
|
812
|
-
- **Do NOT re-test degraded creatives** \u2014 exhaust all other angles first. Wait 3-6 months before revisiting [vs-nt-002].
|
|
813
|
-
- Meta will auto-shift spend away from degrading creatives [vs-nt-001] \u2014 don't panic-pause, but do prepare replacements.
|
|
814
|
-
|
|
815
|
-
`;
|
|
816
|
-
}
|
|
817
|
-
if (healthy.length > 0) {
|
|
818
|
-
text += `### Healthy Ads (${healthy.length})
|
|
819
|
-
- Let Meta manage allocation. Don't force spend to low-spend winners \u2014 Meta has determined scaling them would degrade performance [ds-pt-004].
|
|
820
|
-
|
|
821
|
-
`;
|
|
822
|
-
}
|
|
823
|
-
if (result.warning) {
|
|
824
|
-
text = `${result.warning}
|
|
825
|
-
|
|
826
|
-
${text}`;
|
|
827
|
-
}
|
|
828
|
-
return { content: [{ type: "text", text }] };
|
|
829
|
-
} catch (err) {
|
|
830
|
-
return {
|
|
831
|
-
content: [
|
|
832
|
-
{
|
|
833
|
-
type: "text",
|
|
834
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
835
|
-
}
|
|
836
|
-
],
|
|
837
|
-
isError: true
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
);
|
|
842
|
-
}
|
|
843
|
-
|
|
844
167
|
// src/tools/google-campaigns.ts
|
|
845
|
-
import { z as
|
|
168
|
+
import { z as z2 } from "zod";
|
|
846
169
|
|
|
847
170
|
// src/google/oauth.ts
|
|
848
171
|
var TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
@@ -944,6 +267,30 @@ function formatGoogleAdsError(err) {
|
|
|
944
267
|
}
|
|
945
268
|
return `Google Ads API error (${code}): ${err.message}`;
|
|
946
269
|
}
|
|
270
|
+
async function googleAdsMutate(customerId, operations) {
|
|
271
|
+
const auth = getGoogleAdsAuth();
|
|
272
|
+
const normalizedId = normalizeCustomerId(customerId);
|
|
273
|
+
const headers = await auth.getHeaders(normalizedId);
|
|
274
|
+
const url = `${BASE_URL}/customers/${normalizedId}/googleAds:mutate`;
|
|
275
|
+
const response = await fetch(url, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: {
|
|
278
|
+
...headers,
|
|
279
|
+
"Content-Type": "application/json"
|
|
280
|
+
},
|
|
281
|
+
body: JSON.stringify({ mutateOperations: operations })
|
|
282
|
+
});
|
|
283
|
+
const body = await response.json();
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
if (isGoogleAdsError(body)) {
|
|
286
|
+
throw new Error(formatGoogleAdsError(body.error));
|
|
287
|
+
}
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Google Ads API mutate returned ${response.status}: ${JSON.stringify(body)}`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return body;
|
|
293
|
+
}
|
|
947
294
|
async function googleAdsQuery(customerId, query) {
|
|
948
295
|
const auth = getGoogleAdsAuth();
|
|
949
296
|
const normalizedId = normalizeCustomerId(customerId);
|
|
@@ -1072,10 +419,10 @@ function registerGetGoogleAdsCampaigns(server2) {
|
|
|
1072
419
|
"get_google_ads_campaigns",
|
|
1073
420
|
"List Google App Campaigns with status, bid strategy, budgets, and app info. Campaign naming conventions encode dimensions (app, event, country, OS) \u2014 surface the raw name for parsing. Requires Google Ads credentials \u2014 run `npx mobile-growth-mcp auth google` to set up.",
|
|
1074
421
|
{
|
|
1075
|
-
customer_id:
|
|
1076
|
-
status:
|
|
1077
|
-
channel_sub_type:
|
|
1078
|
-
limit:
|
|
422
|
+
customer_id: z2.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
|
|
423
|
+
status: z2.array(z2.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by campaign status. Default: ["ENABLED"]'),
|
|
424
|
+
channel_sub_type: z2.array(z2.enum(["APP_CAMPAIGN", "APP_CAMPAIGN_FOR_ENGAGEMENT"])).optional().describe('Filter by sub-type. Default: ["APP_CAMPAIGN", "APP_CAMPAIGN_FOR_ENGAGEMENT"]. Set to include all app campaign types'),
|
|
425
|
+
limit: z2.number().min(1).max(100).optional().describe("Max campaigns to return (default 50)")
|
|
1079
426
|
},
|
|
1080
427
|
async ({ customer_id, status: status2, channel_sub_type, limit }) => {
|
|
1081
428
|
try {
|
|
@@ -1156,16 +503,16 @@ function registerGetGoogleAdsCampaigns(server2) {
|
|
|
1156
503
|
}
|
|
1157
504
|
|
|
1158
505
|
// src/tools/google-ad-groups.ts
|
|
1159
|
-
import { z as
|
|
506
|
+
import { z as z3 } from "zod";
|
|
1160
507
|
function registerGetGoogleAdsAdGroups(server2) {
|
|
1161
508
|
server2.tool(
|
|
1162
509
|
"get_google_ad_groups",
|
|
1163
510
|
"List ad groups within Google App Campaigns. In UAC, ad groups represent creative themes (e.g. 'at_home_workouts'). The algorithm allocates spend across ad groups based on which themes resonate \u2014 observe spend distribution to identify winning messaging angles. To scale, add new ad groups with different text-asset strategies rather than aggressively increasing bids (ab-pt-006).",
|
|
1164
511
|
{
|
|
1165
|
-
customer_id:
|
|
1166
|
-
campaign_id:
|
|
1167
|
-
status:
|
|
1168
|
-
limit:
|
|
512
|
+
customer_id: z3.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
|
|
513
|
+
campaign_id: z3.string().optional().describe("Scope to a specific campaign. If omitted, returns ad groups across all app campaigns"),
|
|
514
|
+
status: z3.array(z3.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by ad group status. Default: ["ENABLED"]'),
|
|
515
|
+
limit: z3.number().min(1).max(100).optional().describe("Max results to return (default 50)")
|
|
1169
516
|
},
|
|
1170
517
|
async ({ customer_id, campaign_id, status: status2, limit }) => {
|
|
1171
518
|
try {
|
|
@@ -1236,7 +583,7 @@ function registerGetGoogleAdsAdGroups(server2) {
|
|
|
1236
583
|
}
|
|
1237
584
|
|
|
1238
585
|
// src/tools/google-assets.ts
|
|
1239
|
-
import { z as
|
|
586
|
+
import { z as z4 } from "zod";
|
|
1240
587
|
var SLOT_LIMITS = {
|
|
1241
588
|
HEADLINE: 5,
|
|
1242
589
|
DESCRIPTION: 5,
|
|
@@ -1255,12 +602,12 @@ function registerGetGoogleAdsAssets(server2) {
|
|
|
1255
602
|
"get_google_assets",
|
|
1256
603
|
"List creative assets linked to a campaign or ad group with metadata, performance labels, and slot utilization audit. Google allows up to 5 headlines, 5 descriptions, 20 images, 20 videos, 20 HTML5 per ad group. Missing asset types mean missing inventory channels. Google's built-in asset ratings (Low/Good/Best) measure scalability, not conversion value \u2014 evaluate by your own CPA/ROAS (ab-pt-008).",
|
|
1257
604
|
{
|
|
1258
|
-
customer_id:
|
|
1259
|
-
campaign_id:
|
|
1260
|
-
ad_group_id:
|
|
1261
|
-
asset_type:
|
|
1262
|
-
include_slot_audit:
|
|
1263
|
-
limit:
|
|
605
|
+
customer_id: z4.string().describe("Google Ads customer ID"),
|
|
606
|
+
campaign_id: z4.string().optional().describe("Scope to a specific campaign"),
|
|
607
|
+
ad_group_id: z4.string().optional().describe("Scope to a specific ad group"),
|
|
608
|
+
asset_type: z4.array(z4.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT", "MEDIA_BUNDLE"])).optional().describe("Filter by asset type"),
|
|
609
|
+
include_slot_audit: z4.boolean().optional().describe("Include slot utilization audit (default true)"),
|
|
610
|
+
limit: z4.number().min(1).max(500).optional().describe("Max results to return (default 50)")
|
|
1264
611
|
},
|
|
1265
612
|
async ({ customer_id, campaign_id, ad_group_id, asset_type, include_slot_audit, limit }) => {
|
|
1266
613
|
try {
|
|
@@ -1444,7 +791,7 @@ function registerGetGoogleAdsAssets(server2) {
|
|
|
1444
791
|
}
|
|
1445
792
|
|
|
1446
793
|
// src/tools/google-insights.ts
|
|
1447
|
-
import { z as
|
|
794
|
+
import { z as z5 } from "zod";
|
|
1448
795
|
function parseNum(v) {
|
|
1449
796
|
if (v === void 0 || v === null) return 0;
|
|
1450
797
|
if (typeof v === "number") return v;
|
|
@@ -1501,19 +848,19 @@ function registerGetGoogleAdsInsights(server2) {
|
|
|
1501
848
|
"get_google_insights",
|
|
1502
849
|
"Pull performance metrics from Google Ads with configurable level, breakdowns, date ranges, and time granularity. Use network breakdown to detect traffic shifts between Search, Display/AdMob, and YouTube \u2014 the #1 diagnostic lever for Google campaigns (ab-pt-005). Supports campaign, ad_group, asset, and account levels.",
|
|
1503
850
|
{
|
|
1504
|
-
customer_id:
|
|
1505
|
-
level:
|
|
1506
|
-
campaign_id:
|
|
1507
|
-
ad_group_id:
|
|
1508
|
-
breakdown:
|
|
1509
|
-
date_range:
|
|
1510
|
-
start_date:
|
|
1511
|
-
end_date:
|
|
851
|
+
customer_id: z5.string().describe("Google Ads customer ID"),
|
|
852
|
+
level: z5.enum(["account", "campaign", "ad_group", "asset"]).optional().describe("Aggregation level (default: campaign)"),
|
|
853
|
+
campaign_id: z5.string().optional().describe("Scope to specific campaign"),
|
|
854
|
+
ad_group_id: z5.string().optional().describe("Scope to specific ad group"),
|
|
855
|
+
breakdown: z5.enum(["network", "device"]).optional().describe("Segmentation dimension. Only one at a time (GAQL restriction)"),
|
|
856
|
+
date_range: z5.object({
|
|
857
|
+
start_date: z5.string().describe("YYYY-MM-DD"),
|
|
858
|
+
end_date: z5.string().describe("YYYY-MM-DD")
|
|
1512
859
|
}).optional().describe("Custom date range. Overrides date_preset"),
|
|
1513
|
-
date_preset:
|
|
1514
|
-
time_increment:
|
|
1515
|
-
sort:
|
|
1516
|
-
limit:
|
|
860
|
+
date_preset: z5.enum(["LAST_7_DAYS", "LAST_14_DAYS", "LAST_30_DAYS", "THIS_MONTH", "LAST_MONTH"]).optional().describe("Predefined date range (default: LAST_7_DAYS)"),
|
|
861
|
+
time_increment: z5.enum(["daily", "weekly", "monthly", "summary"]).optional().describe("Time granularity (default: summary)"),
|
|
862
|
+
sort: z5.enum(["cost_desc", "conversions_desc", "impressions_desc", "ctr_desc"]).optional().describe("Sort order (default: cost_desc)"),
|
|
863
|
+
limit: z5.number().min(1).max(500).optional().describe("Max results (default 50)")
|
|
1517
864
|
},
|
|
1518
865
|
async ({ customer_id, level, campaign_id, ad_group_id, breakdown, date_range, date_preset, time_increment, sort, limit }) => {
|
|
1519
866
|
try {
|
|
@@ -1691,7 +1038,7 @@ function registerGetGoogleAdsInsights(server2) {
|
|
|
1691
1038
|
}
|
|
1692
1039
|
|
|
1693
1040
|
// src/tools/google-network-mix.ts
|
|
1694
|
-
import { z as
|
|
1041
|
+
import { z as z6 } from "zod";
|
|
1695
1042
|
function parseNum2(v) {
|
|
1696
1043
|
if (v === void 0 || v === null) return 0;
|
|
1697
1044
|
if (typeof v === "number") return v;
|
|
@@ -1703,13 +1050,13 @@ function registerGetGoogleAdsNetworkMix(server2) {
|
|
|
1703
1050
|
"get_google_network_mix",
|
|
1704
1051
|
"Analyze traffic distribution across Google's ad networks (Search, Display/AdMob, YouTube) over time. Computes spend share % per network per day and flags significant shifts. Traffic shifts signal performance problems \u2014 a sudden shift to Display/MGDN tanks CPA. Google optimizes for Google's revenue alongside yours; use this to detect shifts to low-quality inventory (ab-pt-005, ab-pt-017).",
|
|
1705
1052
|
{
|
|
1706
|
-
customer_id:
|
|
1707
|
-
campaign_id:
|
|
1708
|
-
date_range:
|
|
1709
|
-
start_date:
|
|
1710
|
-
end_date:
|
|
1053
|
+
customer_id: z6.string().describe("Google Ads customer ID"),
|
|
1054
|
+
campaign_id: z6.string().optional().describe("Scope to one campaign. If omitted, aggregates across all app campaigns"),
|
|
1055
|
+
date_range: z6.object({
|
|
1056
|
+
start_date: z6.string().describe("YYYY-MM-DD"),
|
|
1057
|
+
end_date: z6.string().describe("YYYY-MM-DD")
|
|
1711
1058
|
}).optional().describe("Custom date range. Default: last 14 days"),
|
|
1712
|
-
shift_threshold_pct:
|
|
1059
|
+
shift_threshold_pct: z6.number().optional().describe("Flag networks whose spend share changed by more than this % (default 10)")
|
|
1713
1060
|
},
|
|
1714
1061
|
async ({ customer_id, campaign_id, date_range, shift_threshold_pct }) => {
|
|
1715
1062
|
try {
|
|
@@ -1876,7 +1223,7 @@ function registerGetGoogleAdsNetworkMix(server2) {
|
|
|
1876
1223
|
}
|
|
1877
1224
|
|
|
1878
1225
|
// src/tools/google-asset-fatigue.ts
|
|
1879
|
-
import { z as
|
|
1226
|
+
import { z as z7 } from "zod";
|
|
1880
1227
|
function parseNum3(v) {
|
|
1881
1228
|
if (v === void 0 || v === null) return 0;
|
|
1882
1229
|
if (typeof v === "number") return v;
|
|
@@ -1916,13 +1263,13 @@ function registerGetGoogleAdsAssetFatigue(server2) {
|
|
|
1916
1263
|
"get_google_asset_fatigue",
|
|
1917
1264
|
"Detect creative asset fatigue in Google UAC campaigns by analyzing per-asset impression trends, CTR decline, and CPA deterioration. Also checks asset age against Google's 2-week learning minimum and 2-3 month refresh cadence. Google doesn't expose per-asset frequency \u2014 uses impression volume decay as the primary fatigue signal. Never remove assets within first 2 weeks (goog-pdf-018). Google's performance label measures scalability, not fatigue (ab-pt-008).",
|
|
1918
1265
|
{
|
|
1919
|
-
customer_id:
|
|
1920
|
-
campaign_id:
|
|
1921
|
-
ad_group_id:
|
|
1922
|
-
lookback_days:
|
|
1923
|
-
ctr_decline_threshold_pct:
|
|
1924
|
-
impression_decay_threshold_pct:
|
|
1925
|
-
asset_type:
|
|
1266
|
+
customer_id: z7.string().describe("Google Ads customer ID"),
|
|
1267
|
+
campaign_id: z7.string().describe("Campaign to analyze"),
|
|
1268
|
+
ad_group_id: z7.string().optional().describe("Scope to specific ad group"),
|
|
1269
|
+
lookback_days: z7.number().min(7).max(90).optional().describe("Days of daily data to analyze (default 14)"),
|
|
1270
|
+
ctr_decline_threshold_pct: z7.number().optional().describe("CTR decline % from peak to flag fatigue (default 30)"),
|
|
1271
|
+
impression_decay_threshold_pct: z7.number().optional().describe("Impression volume drop % from peak to flag (default 50)"),
|
|
1272
|
+
asset_type: z7.array(z7.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT"])).optional().describe("Filter by asset type")
|
|
1926
1273
|
},
|
|
1927
1274
|
async ({ customer_id, campaign_id, ad_group_id, lookback_days, ctr_decline_threshold_pct, impression_decay_threshold_pct, asset_type }) => {
|
|
1928
1275
|
try {
|
|
@@ -2173,11 +1520,173 @@ Sources: goog-pdf-018, ab-pt-008, goog-pdf-019, ab-pt-007
|
|
|
2173
1520
|
);
|
|
2174
1521
|
}
|
|
2175
1522
|
|
|
1523
|
+
// src/tools/google-upload-assets.ts
|
|
1524
|
+
import { z as z8 } from "zod";
|
|
1525
|
+
import { readFile } from "fs/promises";
|
|
1526
|
+
import { basename, resolve } from "path";
|
|
1527
|
+
async function fetchAsBase64(url) {
|
|
1528
|
+
if (url.startsWith("data:")) {
|
|
1529
|
+
const commaIdx = url.indexOf(",");
|
|
1530
|
+
if (commaIdx === -1) throw new Error("Invalid data URI");
|
|
1531
|
+
return url.slice(commaIdx + 1);
|
|
1532
|
+
}
|
|
1533
|
+
const res = await fetch(url);
|
|
1534
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
|
1535
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1536
|
+
return buf.toString("base64");
|
|
1537
|
+
}
|
|
1538
|
+
async function readFileAsBase64(filePath) {
|
|
1539
|
+
const buf = await readFile(resolve(filePath));
|
|
1540
|
+
return buf.toString("base64");
|
|
1541
|
+
}
|
|
1542
|
+
function registerUploadGoogleImageAssets(server2) {
|
|
1543
|
+
server2.tool(
|
|
1544
|
+
"upload_google_image_assets",
|
|
1545
|
+
"Upload image assets to a Google Ads account. Accepts URLs or local file paths. Each image becomes an Asset resource in the account. Optionally link assets to a campaign or ad group after upload. Supports batch upload (up to 50 images per call).",
|
|
1546
|
+
{
|
|
1547
|
+
customer_id: z8.string().describe("Google Ads customer ID"),
|
|
1548
|
+
images: z8.array(
|
|
1549
|
+
z8.object({
|
|
1550
|
+
source: z8.string().describe(
|
|
1551
|
+
"Image URL (https://...) or local file path (/path/to/image.png)"
|
|
1552
|
+
),
|
|
1553
|
+
name: z8.string().optional().describe(
|
|
1554
|
+
"Asset name in Google Ads (default: filename from source)"
|
|
1555
|
+
)
|
|
1556
|
+
})
|
|
1557
|
+
).min(1).max(50).describe("Array of images to upload (max 50 per call)"),
|
|
1558
|
+
campaign_id: z8.string().optional().describe(
|
|
1559
|
+
"Link uploaded assets to this campaign (creates CampaignAsset links)"
|
|
1560
|
+
),
|
|
1561
|
+
ad_group_id: z8.string().optional().describe(
|
|
1562
|
+
"Link uploaded assets to this ad group (creates AdGroupAsset links). Takes priority over campaign_id."
|
|
1563
|
+
),
|
|
1564
|
+
field_type: z8.enum(["IMAGE", "LANDSCAPE_LOGO", "LOGO", "MARKETING_IMAGE", "SQUARE_MARKETING_IMAGE", "PORTRAIT_MARKETING_IMAGE"]).optional().describe("Asset field type when linking to campaign/ad group (default: IMAGE)"),
|
|
1565
|
+
dry_run: z8.boolean().optional().describe("Preview what would be uploaded without making changes")
|
|
1566
|
+
},
|
|
1567
|
+
async ({
|
|
1568
|
+
customer_id,
|
|
1569
|
+
images,
|
|
1570
|
+
campaign_id,
|
|
1571
|
+
ad_group_id,
|
|
1572
|
+
field_type,
|
|
1573
|
+
dry_run
|
|
1574
|
+
}) => {
|
|
1575
|
+
try {
|
|
1576
|
+
const normalizedId = normalizeCustomerId(customer_id);
|
|
1577
|
+
const effectiveFieldType = field_type ?? "IMAGE";
|
|
1578
|
+
if (dry_run) {
|
|
1579
|
+
let text2 = `**Dry run** \u2014 would upload ${images.length} image(s) to customer ${customer_id}
|
|
1580
|
+
|
|
1581
|
+
`;
|
|
1582
|
+
for (const img of images) {
|
|
1583
|
+
const name = img.name ?? basename(img.source).replace(/\?.*$/, "") ?? "unnamed";
|
|
1584
|
+
text2 += `- ${name} \u2190 ${img.source}
|
|
1585
|
+
`;
|
|
1586
|
+
}
|
|
1587
|
+
if (ad_group_id) {
|
|
1588
|
+
text2 += `
|
|
1589
|
+
Would link to ad group ${ad_group_id} as ${effectiveFieldType}`;
|
|
1590
|
+
} else if (campaign_id) {
|
|
1591
|
+
text2 += `
|
|
1592
|
+
Would link to campaign ${campaign_id} as ${effectiveFieldType}`;
|
|
1593
|
+
}
|
|
1594
|
+
return { content: [{ type: "text", text: text2 }] };
|
|
1595
|
+
}
|
|
1596
|
+
const assetOps = [];
|
|
1597
|
+
const assetNames = [];
|
|
1598
|
+
for (const img of images) {
|
|
1599
|
+
const name = img.name ?? basename(img.source).replace(/\?.*$/, "") ?? "unnamed";
|
|
1600
|
+
assetNames.push(name);
|
|
1601
|
+
const isUrl = img.source.startsWith("http://") || img.source.startsWith("https://") || img.source.startsWith("data:");
|
|
1602
|
+
const data = isUrl ? await fetchAsBase64(img.source) : await readFileAsBase64(img.source);
|
|
1603
|
+
assetOps.push({
|
|
1604
|
+
assetOperation: {
|
|
1605
|
+
create: {
|
|
1606
|
+
name,
|
|
1607
|
+
type: "IMAGE",
|
|
1608
|
+
imageAsset: { data }
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
const assetResult = await googleAdsMutate(normalizedId, assetOps);
|
|
1614
|
+
const createdResourceNames = assetResult.mutateOperationResponses.map(
|
|
1615
|
+
(r) => r.assetResult?.resourceName ?? ""
|
|
1616
|
+
);
|
|
1617
|
+
const successCount = createdResourceNames.filter(Boolean).length;
|
|
1618
|
+
let text = `**Uploaded ${successCount}/${images.length} image assets**
|
|
1619
|
+
|
|
1620
|
+
`;
|
|
1621
|
+
for (let i = 0; i < images.length; i++) {
|
|
1622
|
+
const rn = createdResourceNames[i];
|
|
1623
|
+
if (rn) {
|
|
1624
|
+
text += `\u2713 ${assetNames[i]} \u2192 ${rn}
|
|
1625
|
+
`;
|
|
1626
|
+
} else {
|
|
1627
|
+
text += `\u2717 ${assetNames[i]} \u2014 failed
|
|
1628
|
+
`;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (successCount > 0 && (ad_group_id || campaign_id)) {
|
|
1632
|
+
const linkOps = [];
|
|
1633
|
+
for (const resourceName of createdResourceNames) {
|
|
1634
|
+
if (!resourceName) continue;
|
|
1635
|
+
if (ad_group_id) {
|
|
1636
|
+
linkOps.push({
|
|
1637
|
+
adGroupAssetOperation: {
|
|
1638
|
+
create: {
|
|
1639
|
+
adGroup: `customers/${normalizedId}/adGroups/${ad_group_id}`,
|
|
1640
|
+
asset: resourceName,
|
|
1641
|
+
fieldType: effectiveFieldType
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
} else if (campaign_id) {
|
|
1646
|
+
linkOps.push({
|
|
1647
|
+
campaignAssetOperation: {
|
|
1648
|
+
create: {
|
|
1649
|
+
campaign: `customers/${normalizedId}/campaigns/${campaign_id}`,
|
|
1650
|
+
asset: resourceName,
|
|
1651
|
+
fieldType: effectiveFieldType
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
try {
|
|
1658
|
+
await googleAdsMutate(normalizedId, linkOps);
|
|
1659
|
+
const linkTarget = ad_group_id ? `ad group ${ad_group_id}` : `campaign ${campaign_id}`;
|
|
1660
|
+
text += `
|
|
1661
|
+
\u2713 Linked ${successCount} assets to ${linkTarget} as ${effectiveFieldType}`;
|
|
1662
|
+
} catch (linkErr) {
|
|
1663
|
+
text += `
|
|
1664
|
+
\u26A0\uFE0F Assets uploaded but linking failed: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`;
|
|
1665
|
+
text += `
|
|
1666
|
+
Assets exist in the account and can be linked manually.`;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return { content: [{ type: "text", text }] };
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
return {
|
|
1672
|
+
content: [
|
|
1673
|
+
{
|
|
1674
|
+
type: "text",
|
|
1675
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
1676
|
+
}
|
|
1677
|
+
],
|
|
1678
|
+
isError: true
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
2176
1685
|
// src/tools/connection-status.ts
|
|
2177
1686
|
function registerConnectionStatus(server2, status2) {
|
|
2178
1687
|
server2.tool(
|
|
2179
1688
|
"connection_status",
|
|
2180
|
-
"Check the connection status of the knowledge base and
|
|
1689
|
+
"Check the connection status of the knowledge base and Google Ads API. Call this if tools seem missing or you get unexpected errors.",
|
|
2181
1690
|
{},
|
|
2182
1691
|
async () => {
|
|
2183
1692
|
const lines = ["# Connection Status", ""];
|
|
@@ -2201,26 +1710,11 @@ function registerConnectionStatus(server2, status2) {
|
|
|
2201
1710
|
);
|
|
2202
1711
|
}
|
|
2203
1712
|
lines.push("");
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
} else {
|
|
2210
|
-
lines.push(
|
|
2211
|
-
"## Meta Marketing API: Not Connected (Optional)",
|
|
2212
|
-
"- KB, suggestions, and private insights work without it",
|
|
2213
|
-
"- Connect Meta to unlock live campaign data and reports",
|
|
2214
|
-
"",
|
|
2215
|
-
"### How to connect",
|
|
2216
|
-
"Provide your Meta access token using one of these methods:",
|
|
2217
|
-
'1. MCP config: add `"META_ACCESS_TOKEN": "..."` to the `"env"` block in `.mcp.json` (Claude Code/Cursor) or `claude_desktop_config.json` (Claude Desktop)',
|
|
2218
|
-
"2. CLI argument: add `--meta-token=...` to the args array",
|
|
2219
|
-
"3. `.env` file: add `META_ACCESS_TOKEN=...` to a `.env` file in your working directory",
|
|
2220
|
-
"",
|
|
2221
|
-
"Then restart your MCP client."
|
|
2222
|
-
);
|
|
2223
|
-
}
|
|
1713
|
+
lines.push(
|
|
1714
|
+
"## Meta Ads Data: Use Meta's Official AI Connector",
|
|
1715
|
+
"- This MCP no longer ships Meta API tools (avoids unofficial-API risk).",
|
|
1716
|
+
"- For Meta data, install Meta's official Meta Ads MCP / AI connector and let the analytical skills here interpret what it returns."
|
|
1717
|
+
);
|
|
2224
1718
|
lines.push("");
|
|
2225
1719
|
if (status2.google.configured) {
|
|
2226
1720
|
lines.push(
|
|
@@ -2307,7 +1801,9 @@ var TOPICS = [
|
|
|
2307
1801
|
"competitive_analysis",
|
|
2308
1802
|
"ad_copy",
|
|
2309
1803
|
"conversion_rate",
|
|
2310
|
-
"strategy"
|
|
1804
|
+
"strategy",
|
|
1805
|
+
"psychology",
|
|
1806
|
+
"compliance"
|
|
2311
1807
|
];
|
|
2312
1808
|
var APPLIES_TO = [
|
|
2313
1809
|
"subscription_apps",
|
|
@@ -2367,15 +1863,17 @@ function registerVocabularyResource(server2) {
|
|
|
2367
1863
|
}
|
|
2368
1864
|
|
|
2369
1865
|
// src/resources/instructions.ts
|
|
2370
|
-
var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base +
|
|
1866
|
+
var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Analysis Skills
|
|
2371
1867
|
|
|
2372
1868
|
## Welcome
|
|
2373
1869
|
|
|
2374
|
-
You're connected to the Mobile Growth knowledge base \u2014 curated expert insights on mobile advertising, campaign optimization, and subscription app growth \u2014 plus
|
|
1870
|
+
You're connected to the Mobile Growth knowledge base \u2014 curated expert insights on mobile advertising, campaign optimization, and subscription app growth \u2014 plus Google Ads API integration and a library of analytical skills.
|
|
2375
1871
|
|
|
2376
1872
|
**The knowledge base is always on.** Use \`search_insights\` freely \u2014 before making recommendations, when diagnosing issues, or exploring strategies. The more specific your query, the better the results.
|
|
2377
1873
|
|
|
2378
|
-
> **
|
|
1874
|
+
> **For Meta Ads data**, use Meta's official Meta Ads MCP / AI connector. This MCP intentionally does **not** ship Meta API tools \u2014 instead, the analytical skills here (ad-fatigue-report, weekly-performance, audit-meta-account, etc.) interpret data fetched from Meta's official connector or pasted from CSV exports.
|
|
1875
|
+
>
|
|
1876
|
+
> Connecting Google Ads is optional. The knowledge base, community suggestions, and private insights all work with just your API key.
|
|
2379
1877
|
|
|
2380
1878
|
Quick examples:
|
|
2381
1879
|
- "subscription app creative fatigue signals"
|
|
@@ -2386,7 +1884,7 @@ Quick examples:
|
|
|
2386
1884
|
If you can't find what you need, call \`submit_feedback\` to report the gap \u2014 it helps us improve the knowledge base.
|
|
2387
1885
|
|
|
2388
1886
|
## What This Is
|
|
2389
|
-
A curated knowledge base of mobile advertising insights +
|
|
1887
|
+
A curated knowledge base of mobile advertising insights + Google Ads API integration + analytical skills (Meta + Google) that interpret data from the platforms' own MCPs/CSV exports.
|
|
2390
1888
|
|
|
2391
1889
|
---
|
|
2392
1890
|
|
|
@@ -2429,50 +1927,20 @@ Save knowledge that is private to your API key. Immediately searchable but only
|
|
|
2429
1927
|
- Same full schema as suggest_insight
|
|
2430
1928
|
- No admin approval needed \u2014 saved instantly
|
|
2431
1929
|
|
|
1930
|
+
### suggest_skill
|
|
1931
|
+
Propose a new skill (repeatable workflow) for the knowledge base. **Use this when a user describes a workflow they want automated** \u2014 recurring audits, analyses, or procedures they run regularly.
|
|
1932
|
+
- **Before calling**: draft the full skill .md yourself. Canonical format: "## What It Does", "## When to Use This" (bullet triggers), "## What It Needs" (data sources + exact CSV export instructions), "## Procedure" (numbered steps with tool calls or CSV column specs), "## Output".
|
|
1933
|
+
- Pass the draft as content_md. Only use user_description without a draft if the user wants to submit a rough idea for the admin to develop.
|
|
1934
|
+
- Provide when_to_use phrases and data_sources (meta_api / google_ads_api / csv / manual_input)
|
|
1935
|
+
- Submissions go to admin review before becoming live
|
|
1936
|
+
|
|
2432
1937
|
---
|
|
2433
1938
|
|
|
2434
|
-
## Meta
|
|
2435
|
-
|
|
2436
|
-
**
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
### get_meta_campaigns
|
|
2441
|
-
List campaigns from a Meta ad account. Defaults to active campaigns.
|
|
2442
|
-
- **ad_account_id** (required): e.g. "act_123456789"
|
|
2443
|
-
- **fields, effective_status, limit, after** (optional)
|
|
2444
|
-
|
|
2445
|
-
### get_meta_adsets
|
|
2446
|
-
List ad sets, optionally scoped to a campaign.
|
|
2447
|
-
- **ad_account_id** (required)
|
|
2448
|
-
- **campaign_id** (optional): Scope to specific campaign
|
|
2449
|
-
- **fields, effective_status, limit, after** (optional)
|
|
2450
|
-
|
|
2451
|
-
### get_meta_ads
|
|
2452
|
-
List ads, optionally scoped to an ad set.
|
|
2453
|
-
- **ad_account_id** (required)
|
|
2454
|
-
- **adset_id** (optional): Scope to specific ad set
|
|
2455
|
-
- **fields, effective_status, limit, after** (optional)
|
|
2456
|
-
|
|
2457
|
-
### get_meta_insights
|
|
2458
|
-
Pull performance insights with configurable level, breakdowns, date range.
|
|
2459
|
-
- **ad_account_id** (required)
|
|
2460
|
-
- **level** (optional): account, campaign, adset, ad (default: campaign)
|
|
2461
|
-
- **date_preset** (optional): default last_7d
|
|
2462
|
-
- **time_range** (optional): {since, until} for custom dates
|
|
2463
|
-
- **time_increment** (optional): "1" for daily, "7" for weekly
|
|
2464
|
-
- **breakdowns** (optional): e.g. "age,gender" or "publisher_platform,platform_position"
|
|
2465
|
-
- **conversion_event** (optional): default "mobile_app_install"
|
|
2466
|
-
- **fields, filtering, sort, limit, after** (optional)
|
|
2467
|
-
|
|
2468
|
-
### get_meta_ad_fatigue
|
|
2469
|
-
Built-in report: detect creative fatigue via frequency, CTR decline, CPA trends.
|
|
2470
|
-
- **ad_account_id** (required)
|
|
2471
|
-
- **campaign_id** (optional): Scope to specific campaign
|
|
2472
|
-
- **conversion_event** (optional): default "mobile_app_install"
|
|
2473
|
-
- **frequency_warning** (optional): default 3
|
|
2474
|
-
- **frequency_critical** (optional): default 5
|
|
2475
|
-
- **ctr_decline_threshold** (optional): default 30%
|
|
1939
|
+
## Meta Ads Data \u2014 Use Meta's Official AI Connector
|
|
1940
|
+
|
|
1941
|
+
This MCP **does not ship Meta API tools**. Use Meta's official Meta Ads MCP / AI connector to fetch campaign data, performance metrics, audiences, and catalog operations. Then run the analytical skills below to interpret what comes back.
|
|
1942
|
+
|
|
1943
|
+
This separation keeps users out of unofficial-API risk while preserving the analytical value (KB-grounded methodology) that this MCP provides.
|
|
2476
1944
|
|
|
2477
1945
|
---
|
|
2478
1946
|
|
|
@@ -2539,19 +2007,19 @@ Detect creative asset fatigue by analyzing per-asset impression trends, CTR decl
|
|
|
2539
2007
|
|
|
2540
2008
|
## Reports (MCP Prompts)
|
|
2541
2009
|
|
|
2542
|
-
Pre-built
|
|
2010
|
+
Pre-built analytical workflows. Each skill expects either Meta MCP\u2013sourced data (use Meta's official AI connector) or CSV exports from Ads Manager. Select a prompt and provide your \`ad_account_id\` to run:
|
|
2543
2011
|
|
|
2544
|
-
| Prompt | What it does |
|
|
2545
|
-
|
|
2546
|
-
| ad-fatigue-report | Detect creative fatigue with daily granularity |
|
|
2547
|
-
| weekly-performance | Week-over-week health comparison with diagnosis |
|
|
2548
|
-
| creative-performance | Categorize ads by health status |
|
|
2549
|
-
| audience-composition | Age
|
|
2550
|
-
| architecture-review | Campaign structure evaluation |
|
|
2551
|
-
| audit-meta-account | Comprehensive account audit |
|
|
2552
|
-
| campaign-comparison | Side-by-side campaign comparison |
|
|
2553
|
-
| placement-audit | Detailed placement audit with examples |
|
|
2554
|
-
| attribution-analysis | Conversion quality validation |
|
|
2012
|
+
| Prompt | What it does |
|
|
2013
|
+
|--------|-------------|
|
|
2014
|
+
| ad-fatigue-report | Detect creative fatigue with daily granularity |
|
|
2015
|
+
| weekly-performance | Week-over-week health comparison with diagnosis |
|
|
2016
|
+
| creative-performance | Categorize ads by health status |
|
|
2017
|
+
| audience-composition | Age \xD7 gender heatmap with CPA analysis |
|
|
2018
|
+
| architecture-review | Campaign structure evaluation |
|
|
2019
|
+
| audit-meta-account | Comprehensive account audit |
|
|
2020
|
+
| campaign-comparison | Side-by-side campaign comparison |
|
|
2021
|
+
| placement-audit | Detailed placement audit with examples |
|
|
2022
|
+
| attribution-analysis | Conversion quality validation |
|
|
2555
2023
|
|
|
2556
2024
|
---
|
|
2557
2025
|
|
|
@@ -2579,9 +2047,9 @@ When your response draws on knowledge base results, **always attribute visibly**
|
|
|
2579
2047
|
5. Use \`get_google_asset_fatigue\` on specific campaigns to detect creative decay
|
|
2580
2048
|
|
|
2581
2049
|
### For Meta analysis:
|
|
2582
|
-
1.
|
|
2583
|
-
2.
|
|
2584
|
-
3. For custom analysis, use
|
|
2050
|
+
1. Use Meta's official Meta Ads MCP / AI connector to fetch the data
|
|
2051
|
+
2. Run a report (MCP prompt) here to interpret it against the knowledge base
|
|
2052
|
+
3. For custom analysis, ask the user to paste a CSV export and use the skill's Option B path
|
|
2585
2053
|
|
|
2586
2054
|
### General:
|
|
2587
2055
|
- Always \`search_insights\` before making recommendations \u2014 ground advice in expert knowledge
|
|
@@ -2605,16 +2073,9 @@ function buildStatusSection(status2) {
|
|
|
2605
2073
|
" - Fix: provide your API key via `--api-key=me_...` CLI arg, `API_KEY` env var, or `.env` file"
|
|
2606
2074
|
);
|
|
2607
2075
|
}
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
lines.push(
|
|
2612
|
-
"- **Meta Marketing API**: Not connected (optional \u2014 KB works without it)"
|
|
2613
|
-
);
|
|
2614
|
-
lines.push(
|
|
2615
|
-
" - To connect: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
|
|
2616
|
-
);
|
|
2617
|
-
}
|
|
2076
|
+
lines.push(
|
|
2077
|
+
"- **Meta Ads Data**: Use Meta's official Meta Ads MCP / AI connector \u2014 this MCP no longer ships Meta API tools"
|
|
2078
|
+
);
|
|
2618
2079
|
if (status2.google.configured) {
|
|
2619
2080
|
lines.push("- **Google Ads API**: Configured");
|
|
2620
2081
|
} else {
|
|
@@ -2689,7 +2150,7 @@ function getDotEnv() {
|
|
|
2689
2150
|
}
|
|
2690
2151
|
return dotEnvCache;
|
|
2691
2152
|
}
|
|
2692
|
-
function
|
|
2153
|
+
function resolve2(envName, cliName) {
|
|
2693
2154
|
const cli = getCliArg(cliName);
|
|
2694
2155
|
if (cli) return { value: cli, source: `--${cliName} argument` };
|
|
2695
2156
|
const env = process.env[envName]?.trim();
|
|
@@ -2699,17 +2160,14 @@ function resolve(envName, cliName) {
|
|
|
2699
2160
|
return { value: void 0, source: "not configured" };
|
|
2700
2161
|
}
|
|
2701
2162
|
function resolveApiKey() {
|
|
2702
|
-
return
|
|
2703
|
-
}
|
|
2704
|
-
function resolveMetaToken() {
|
|
2705
|
-
return resolve("META_ACCESS_TOKEN", "meta-token");
|
|
2163
|
+
return resolve2("API_KEY", "api-key");
|
|
2706
2164
|
}
|
|
2707
2165
|
function resolveGoogleAdsConfig() {
|
|
2708
|
-
const devToken =
|
|
2709
|
-
const clientId =
|
|
2710
|
-
const clientSecret =
|
|
2711
|
-
const refreshToken =
|
|
2712
|
-
const loginCustomerId =
|
|
2166
|
+
const devToken = resolve2("GOOGLE_ADS_DEVELOPER_TOKEN", "google-dev-token");
|
|
2167
|
+
const clientId = resolve2("GOOGLE_ADS_CLIENT_ID", "google-client-id");
|
|
2168
|
+
const clientSecret = resolve2("GOOGLE_ADS_CLIENT_SECRET", "google-client-secret");
|
|
2169
|
+
const refreshToken = resolve2("GOOGLE_ADS_REFRESH_TOKEN", "google-refresh-token");
|
|
2170
|
+
const loginCustomerId = resolve2("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "google-login-customer-id");
|
|
2713
2171
|
const required = [
|
|
2714
2172
|
{ name: "GOOGLE_ADS_DEVELOPER_TOKEN", result: devToken },
|
|
2715
2173
|
{ name: "GOOGLE_ADS_CLIENT_ID", result: clientId },
|
|
@@ -2752,8 +2210,8 @@ async function maybeRunAuthCommand() {
|
|
|
2752
2210
|
process.exit(0);
|
|
2753
2211
|
}
|
|
2754
2212
|
function prompt(rl, question) {
|
|
2755
|
-
return new Promise((
|
|
2756
|
-
rl.question(question, (answer) =>
|
|
2213
|
+
return new Promise((resolve3) => {
|
|
2214
|
+
rl.question(question, (answer) => resolve3(answer.trim()));
|
|
2757
2215
|
});
|
|
2758
2216
|
}
|
|
2759
2217
|
function openBrowser(url) {
|
|
@@ -2841,7 +2299,7 @@ function saveToEnv(vars) {
|
|
|
2841
2299
|
}
|
|
2842
2300
|
}
|
|
2843
2301
|
async function waitForOAuthCallback(clientId, clientSecret) {
|
|
2844
|
-
return new Promise((
|
|
2302
|
+
return new Promise((resolve3, reject) => {
|
|
2845
2303
|
const server2 = createServer(
|
|
2846
2304
|
async (req, res) => {
|
|
2847
2305
|
const url = new URL(req.url ?? "/", `http://localhost:${OAUTH_PORT}`);
|
|
@@ -2896,7 +2354,7 @@ async function waitForOAuthCallback(clientId, clientSecret) {
|
|
|
2896
2354
|
"<html><body><h2>Success!</h2><p>You can close this tab and return to the terminal.</p></body></html>"
|
|
2897
2355
|
);
|
|
2898
2356
|
server2.close();
|
|
2899
|
-
|
|
2357
|
+
resolve3({ refreshToken: tokenData.refresh_token });
|
|
2900
2358
|
} catch (err) {
|
|
2901
2359
|
res.writeHead(500, { "Content-Type": "text/html" });
|
|
2902
2360
|
res.end(
|
|
@@ -3000,10 +2458,8 @@ Error: ${err instanceof Error ? err.message : String(err)}`
|
|
|
3000
2458
|
// src/index.ts
|
|
3001
2459
|
await maybeRunAuthCommand();
|
|
3002
2460
|
var apiKeyResult = resolveApiKey();
|
|
3003
|
-
var metaTokenResult = resolveMetaToken();
|
|
3004
2461
|
var googleAdsResult = resolveGoogleAdsConfig();
|
|
3005
2462
|
if (apiKeyResult.value) process.env.API_KEY = apiKeyResult.value;
|
|
3006
|
-
if (metaTokenResult.value) process.env.META_ACCESS_TOKEN = metaTokenResult.value;
|
|
3007
2463
|
if (googleAdsResult.developerToken)
|
|
3008
2464
|
process.env.GOOGLE_ADS_DEVELOPER_TOKEN = googleAdsResult.developerToken;
|
|
3009
2465
|
if (googleAdsResult.clientId)
|
|
@@ -3018,9 +2474,6 @@ var apiKey = apiKeyResult.value;
|
|
|
3018
2474
|
console.error(
|
|
3019
2475
|
apiKey ? `API key: ${apiKeyResult.source}` : "API key: not configured \u2014 KB tools will not be available"
|
|
3020
2476
|
);
|
|
3021
|
-
console.error(
|
|
3022
|
-
metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured (optional \u2014 KB works without it)"
|
|
3023
|
-
);
|
|
3024
2477
|
console.error(
|
|
3025
2478
|
googleAdsResult.configured ? `Google Ads: configured` : `Google Ads: not configured (optional \u2014 KB works without it). Run \`npx mobile-growth-mcp auth google\` to set up`
|
|
3026
2479
|
);
|
|
@@ -3030,7 +2483,6 @@ var server = new McpServer({
|
|
|
3030
2483
|
});
|
|
3031
2484
|
var status = {
|
|
3032
2485
|
kb: { connected: false, toolCount: 0, promptCount: 0 },
|
|
3033
|
-
meta: { tokenConfigured: !!metaTokenResult.value },
|
|
3034
2486
|
google: {
|
|
3035
2487
|
configured: googleAdsResult.configured,
|
|
3036
2488
|
missing: googleAdsResult.missing
|
|
@@ -3053,17 +2505,13 @@ if (apiKey) {
|
|
|
3053
2505
|
} else {
|
|
3054
2506
|
status.kb.error = "API_KEY not configured";
|
|
3055
2507
|
}
|
|
3056
|
-
registerGetMetaCampaigns(server);
|
|
3057
|
-
registerGetMetaAdSets(server);
|
|
3058
|
-
registerGetMetaAds(server);
|
|
3059
|
-
registerGetMetaInsights(server);
|
|
3060
|
-
registerGetMetaAdFatigue(server);
|
|
3061
2508
|
registerGetGoogleAdsCampaigns(server);
|
|
3062
2509
|
registerGetGoogleAdsAdGroups(server);
|
|
3063
2510
|
registerGetGoogleAdsAssets(server);
|
|
3064
2511
|
registerGetGoogleAdsInsights(server);
|
|
3065
2512
|
registerGetGoogleAdsNetworkMix(server);
|
|
3066
2513
|
registerGetGoogleAdsAssetFatigue(server);
|
|
2514
|
+
registerUploadGoogleImageAssets(server);
|
|
3067
2515
|
registerConnectionStatus(server, status);
|
|
3068
2516
|
registerVocabularyResource(server);
|
|
3069
2517
|
registerInstructionsResource(server, status);
|