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.
Files changed (2) hide show
  1. package/dist/index.js +288 -840
  2. 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 z7 } from "zod";
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: z7.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
1076
- status: z7.array(z7.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by campaign status. Default: ["ENABLED"]'),
1077
- channel_sub_type: z7.array(z7.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'),
1078
- limit: z7.number().min(1).max(100).optional().describe("Max campaigns to return (default 50)")
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 z8 } from "zod";
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: z8.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
1166
- campaign_id: z8.string().optional().describe("Scope to a specific campaign. If omitted, returns ad groups across all app campaigns"),
1167
- status: z8.array(z8.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by ad group status. Default: ["ENABLED"]'),
1168
- limit: z8.number().min(1).max(100).optional().describe("Max results to return (default 50)")
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 z9 } from "zod";
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: z9.string().describe("Google Ads customer ID"),
1259
- campaign_id: z9.string().optional().describe("Scope to a specific campaign"),
1260
- ad_group_id: z9.string().optional().describe("Scope to a specific ad group"),
1261
- asset_type: z9.array(z9.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT", "MEDIA_BUNDLE"])).optional().describe("Filter by asset type"),
1262
- include_slot_audit: z9.boolean().optional().describe("Include slot utilization audit (default true)"),
1263
- limit: z9.number().min(1).max(500).optional().describe("Max results to return (default 50)")
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 z10 } from "zod";
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: z10.string().describe("Google Ads customer ID"),
1505
- level: z10.enum(["account", "campaign", "ad_group", "asset"]).optional().describe("Aggregation level (default: campaign)"),
1506
- campaign_id: z10.string().optional().describe("Scope to specific campaign"),
1507
- ad_group_id: z10.string().optional().describe("Scope to specific ad group"),
1508
- breakdown: z10.enum(["network", "device"]).optional().describe("Segmentation dimension. Only one at a time (GAQL restriction)"),
1509
- date_range: z10.object({
1510
- start_date: z10.string().describe("YYYY-MM-DD"),
1511
- end_date: z10.string().describe("YYYY-MM-DD")
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: z10.enum(["LAST_7_DAYS", "LAST_14_DAYS", "LAST_30_DAYS", "THIS_MONTH", "LAST_MONTH"]).optional().describe("Predefined date range (default: LAST_7_DAYS)"),
1514
- time_increment: z10.enum(["daily", "weekly", "monthly", "summary"]).optional().describe("Time granularity (default: summary)"),
1515
- sort: z10.enum(["cost_desc", "conversions_desc", "impressions_desc", "ctr_desc"]).optional().describe("Sort order (default: cost_desc)"),
1516
- limit: z10.number().min(1).max(500).optional().describe("Max results (default 50)")
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 z11 } from "zod";
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: z11.string().describe("Google Ads customer ID"),
1707
- campaign_id: z11.string().optional().describe("Scope to one campaign. If omitted, aggregates across all app campaigns"),
1708
- date_range: z11.object({
1709
- start_date: z11.string().describe("YYYY-MM-DD"),
1710
- end_date: z11.string().describe("YYYY-MM-DD")
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: z11.number().optional().describe("Flag networks whose spend share changed by more than this % (default 10)")
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 z12 } from "zod";
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: z12.string().describe("Google Ads customer ID"),
1920
- campaign_id: z12.string().describe("Campaign to analyze"),
1921
- ad_group_id: z12.string().optional().describe("Scope to specific ad group"),
1922
- lookback_days: z12.number().min(7).max(90).optional().describe("Days of daily data to analyze (default 14)"),
1923
- ctr_decline_threshold_pct: z12.number().optional().describe("CTR decline % from peak to flag fatigue (default 30)"),
1924
- impression_decay_threshold_pct: z12.number().optional().describe("Impression volume drop % from peak to flag (default 50)"),
1925
- asset_type: z12.array(z12.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT"])).optional().describe("Filter by 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 Meta API. Call this if tools seem missing or you get unexpected errors.",
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
- if (status2.meta.tokenConfigured) {
2205
- lines.push(
2206
- "## Meta Marketing API: Configured",
2207
- "- Meta tools are available and ready to use"
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 + Ad Platform Tools
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 direct Meta and Google Ads API integration.
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
- > **Note:** Connecting Meta or Google Ads is optional. The knowledge base, community suggestions, and private insights all work with just your API key. Add Meta or Google Ads credentials later if you want live campaign data and reports.
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 + direct Meta Marketing API and Google Ads API integration. Query expert knowledge, pull live campaign data, and run pre-built reports \u2014 all from your LLM.
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 Marketing API Tools
2435
-
2436
- **Requires META_ACCESS_TOKEN env var** \u2014 without it, these tools return a clear error. Knowledge base tools work with just API_KEY.
2437
-
2438
- **Rate limit safety**: All tools default to last_7d, active-only, minimal fields. No auto-pagination. Throttle header monitored \u2014 warns at >75% utilization.
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 analysis workflows for Meta accounts. Select a prompt and provide your ad_account_id to run:
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 | API calls |
2545
- |--------|-------------|-----------|
2546
- | ad-fatigue-report | Detect creative fatigue with daily granularity | 1 |
2547
- | weekly-performance | Week-over-week health comparison with diagnosis | 2 |
2548
- | creative-performance | Categorize ads by health status | 1 |
2549
- | audience-composition | Age x gender heatmap with CPA analysis | 1-2 |
2550
- | architecture-review | Campaign structure evaluation | 3 (no insights) |
2551
- | audit-meta-account | Comprehensive account audit | 6+ |
2552
- | campaign-comparison | Side-by-side campaign comparison | 3+ |
2553
- | placement-audit | Detailed placement audit with examples | 1 per campaign |
2554
- | attribution-analysis | Conversion quality validation | 2+ |
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. Start with \`get_meta_campaigns\` to see account structure
2583
- 2. Use reports (MCP prompts) for comprehensive analysis
2584
- 3. For custom analysis, use \`get_meta_insights\` with breakdowns
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
- if (status2.meta.tokenConfigured) {
2609
- lines.push("- **Meta Marketing API**: Token configured");
2610
- } else {
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 resolve(envName, cliName) {
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 resolve("API_KEY", "api-key");
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 = resolve("GOOGLE_ADS_DEVELOPER_TOKEN", "google-dev-token");
2709
- const clientId = resolve("GOOGLE_ADS_CLIENT_ID", "google-client-id");
2710
- const clientSecret = resolve("GOOGLE_ADS_CLIENT_SECRET", "google-client-secret");
2711
- const refreshToken = resolve("GOOGLE_ADS_REFRESH_TOKEN", "google-refresh-token");
2712
- const loginCustomerId = resolve("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "google-login-customer-id");
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((resolve2) => {
2756
- rl.question(question, (answer) => resolve2(answer.trim()));
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((resolve2, reject) => {
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
- resolve2({ refreshToken: tokenData.refresh_token });
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);