mobile-growth-mcp 2.3.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 +100 -871
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -164,715 +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
- var INSIGHT_ADSET_FIELDS = "adset_id,adset_name";
262
- var INSIGHT_AD_FIELDS = "adset_id,adset_name,ad_id,ad_name";
263
-
264
- // src/tools/meta-campaigns.ts
265
- function registerGetMetaCampaigns(server2) {
266
- server2.tool(
267
- "get_meta_campaigns",
268
- "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.",
269
- {
270
- ad_account_id: z2.string().describe("Meta ad account ID (e.g. act_123456789)"),
271
- fields: z2.string().optional().describe(
272
- `Comma-separated fields. Default: ${CAMPAIGN_DEFAULT_FIELDS}`
273
- ),
274
- effective_status: z2.array(z2.string()).optional().describe(
275
- 'Filter by status. Default: ["ACTIVE"]. Use ["ACTIVE","PAUSED"] for all non-deleted.'
276
- ),
277
- limit: z2.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
278
- after: z2.string().optional().describe("Pagination cursor from previous response")
279
- },
280
- async ({ ad_account_id, fields, effective_status, limit, after }) => {
281
- try {
282
- const params = {
283
- fields: fields ?? CAMPAIGN_DEFAULT_FIELDS,
284
- limit: String(limit ?? 50)
285
- };
286
- if (effective_status) {
287
- params.filtering = JSON.stringify([
288
- {
289
- field: "effective_status",
290
- operator: "IN",
291
- value: effective_status
292
- }
293
- ]);
294
- } else {
295
- params.filtering = activeFilter();
296
- }
297
- if (after) {
298
- params.after = after;
299
- }
300
- const result = await metaApiGet({
301
- path: `/${ad_account_id}/campaigns`,
302
- params
303
- });
304
- const campaigns = result.data.data;
305
- const nextCursor = result.data.paging?.cursors?.after;
306
- let text = `Found ${campaigns.length} campaigns:
307
-
308
- `;
309
- for (const c of campaigns) {
310
- text += `- **${c.name}** (${c.id})
311
- 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";
312
- }
313
- if (nextCursor) {
314
- text += `
315
- _More results available. Pass \`after: "${nextCursor}"\` for next page._`;
316
- }
317
- if (result.warning) {
318
- text = `${result.warning}
319
-
320
- ${text}`;
321
- }
322
- return { content: [{ type: "text", text }] };
323
- } catch (err) {
324
- return {
325
- content: [
326
- {
327
- type: "text",
328
- text: `Error: ${err instanceof Error ? err.message : String(err)}`
329
- }
330
- ],
331
- isError: true
332
- };
333
- }
334
- }
335
- );
336
- }
337
-
338
- // src/tools/meta-adsets.ts
339
- import { z as z3 } from "zod";
340
- function registerGetMetaAdSets(server2) {
341
- server2.tool(
342
- "get_meta_adsets",
343
- "List ad sets from a Meta ad account, optionally scoped to a campaign. Defaults to active ad sets with lean field set.",
344
- {
345
- ad_account_id: z3.string().describe("Meta ad account ID (e.g. act_123456789)"),
346
- campaign_id: z3.string().optional().describe(
347
- "Scope to a specific campaign ID. If provided, fetches ad sets under that campaign."
348
- ),
349
- fields: z3.string().optional().describe(`Comma-separated fields. Default: ${ADSET_DEFAULT_FIELDS}`),
350
- effective_status: z3.array(z3.string()).optional().describe('Filter by status. Default: ["ACTIVE"]'),
351
- limit: z3.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
352
- after: z3.string().optional().describe("Pagination cursor from previous response")
353
- },
354
- async ({
355
- ad_account_id,
356
- campaign_id,
357
- fields,
358
- effective_status,
359
- limit,
360
- after
361
- }) => {
362
- try {
363
- const params = {
364
- fields: fields ?? ADSET_DEFAULT_FIELDS,
365
- limit: String(limit ?? 50)
366
- };
367
- if (effective_status) {
368
- params.filtering = JSON.stringify([
369
- {
370
- field: "effective_status",
371
- operator: "IN",
372
- value: effective_status
373
- }
374
- ]);
375
- } else {
376
- params.filtering = activeFilter();
377
- }
378
- if (after) {
379
- params.after = after;
380
- }
381
- const parentPath = campaign_id ? `/${campaign_id}/adsets` : `/${ad_account_id}/adsets`;
382
- const result = await metaApiGet({
383
- path: parentPath,
384
- params
385
- });
386
- const adsets = result.data.data;
387
- const nextCursor = result.data.paging?.cursors?.after;
388
- let text = `Found ${adsets.length} ad sets:
389
-
390
- `;
391
- for (const a of adsets) {
392
- text += `- **${a.name}** (${a.id})
393
- 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";
394
- }
395
- if (nextCursor) {
396
- text += `
397
- _More results available. Pass \`after: "${nextCursor}"\` for next page._`;
398
- }
399
- if (result.warning) {
400
- text = `${result.warning}
401
-
402
- ${text}`;
403
- }
404
- return { content: [{ type: "text", text }] };
405
- } catch (err) {
406
- return {
407
- content: [
408
- {
409
- type: "text",
410
- text: `Error: ${err instanceof Error ? err.message : String(err)}`
411
- }
412
- ],
413
- isError: true
414
- };
415
- }
416
- }
417
- );
418
- }
419
-
420
- // src/tools/meta-ads.ts
421
- import { z as z4 } from "zod";
422
- function registerGetMetaAds(server2) {
423
- server2.tool(
424
- "get_meta_ads",
425
- "List ads from a Meta ad account, optionally scoped to a campaign or ad set. Defaults to active ads with lean field set.",
426
- {
427
- ad_account_id: z4.string().describe("Meta ad account ID (e.g. act_123456789)"),
428
- campaign_id: z4.string().optional().describe("Scope to a specific campaign ID (e.g. 23851234567890)"),
429
- adset_id: z4.string().optional().describe("Scope to a specific ad set ID. Takes priority over campaign_id."),
430
- fields: z4.string().optional().describe(`Comma-separated fields. Default: ${AD_DEFAULT_FIELDS}`),
431
- effective_status: z4.array(z4.string()).optional().describe('Filter by status. Default: ["ACTIVE"]'),
432
- limit: z4.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
433
- after: z4.string().optional().describe("Pagination cursor from previous response")
434
- },
435
- async ({ ad_account_id, campaign_id, adset_id, fields, effective_status, limit, after }) => {
436
- try {
437
- const params = {
438
- fields: fields ?? AD_DEFAULT_FIELDS,
439
- limit: String(limit ?? 50)
440
- };
441
- if (effective_status) {
442
- params.filtering = JSON.stringify([
443
- {
444
- field: "effective_status",
445
- operator: "IN",
446
- value: effective_status
447
- }
448
- ]);
449
- } else {
450
- params.filtering = activeFilter();
451
- }
452
- if (after) {
453
- params.after = after;
454
- }
455
- const parentPath = adset_id ? `/${adset_id}/ads` : campaign_id ? `/${campaign_id}/ads` : `/${ad_account_id}/ads`;
456
- const result = await metaApiGet({
457
- path: parentPath,
458
- params
459
- });
460
- const ads = result.data.data;
461
- const nextCursor = result.data.paging?.cursors?.after;
462
- let text = `Found ${ads.length} ads:
463
-
464
- `;
465
- for (const ad of ads) {
466
- text += `- **${ad.name}** (${ad.id})
467
- Campaign: ${ad.campaign_id} | 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";
468
- }
469
- if (nextCursor) {
470
- text += `
471
- _More results available. Pass \`after: "${nextCursor}"\` for next page._`;
472
- }
473
- if (result.warning) {
474
- text = `${result.warning}
475
-
476
- ${text}`;
477
- }
478
- return { content: [{ type: "text", text }] };
479
- } catch (err) {
480
- return {
481
- content: [
482
- {
483
- type: "text",
484
- text: `Error: ${err instanceof Error ? err.message : String(err)}`
485
- }
486
- ],
487
- isError: true
488
- };
489
- }
490
- }
491
- );
492
- }
493
-
494
- // src/tools/meta-insights.ts
495
- import { z as z5 } from "zod";
496
- function registerGetMetaInsights(server2) {
497
- server2.tool(
498
- "get_meta_insights",
499
- "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). Use campaign_id or adset_id to scope results.",
500
- {
501
- ad_account_id: z5.string().describe("Meta ad account ID (e.g. act_123456789)"),
502
- campaign_id: z5.string().optional().describe("Scope to a specific campaign ID (e.g. 23851234567890)"),
503
- adset_id: z5.string().optional().describe("Scope to a specific ad set ID (e.g. 23851234567891)"),
504
- level: z5.enum(["account", "campaign", "adset", "ad"]).optional().describe("Aggregation level (default: campaign)"),
505
- fields: z5.string().optional().describe(
506
- `Comma-separated fields. Default includes level-appropriate name fields + ${INSIGHT_DEFAULT_FIELDS}`
507
- ),
508
- date_preset: z5.string().optional().describe(`Date preset (default: ${DEFAULT_DATE_PRESET})`),
509
- time_range: z5.object({
510
- since: z5.string().describe("Start date YYYY-MM-DD"),
511
- until: z5.string().describe("End date YYYY-MM-DD")
512
- }).optional().describe("Custom date range. Overrides date_preset if provided."),
513
- time_increment: z5.string().optional().describe(
514
- 'Time granularity: "1" for daily, "7" for weekly, "monthly", or "all_days" (default: aggregated)'
515
- ),
516
- breakdowns: z5.string().optional().describe(
517
- "Comma-separated breakdowns (e.g. age,gender or publisher_platform,platform_position)"
518
- ),
519
- filtering: z5.string().optional().describe(
520
- 'JSON filtering array. Default: active only. Pass "[]" to include all statuses.'
521
- ),
522
- conversion_event: z5.string().optional().describe(
523
- "Action type for CPA calculation (default: mobile_app_install)"
524
- ),
525
- sort: z5.string().optional().describe(
526
- 'Sort field (e.g. "spend_descending", "impressions_descending")'
527
- ),
528
- limit: z5.number().min(1).max(500).optional().describe("Results per page (default 50, max 500)"),
529
- after: z5.string().optional().describe("Pagination cursor from previous response")
530
- },
531
- async ({
532
- ad_account_id,
533
- campaign_id,
534
- adset_id,
535
- level,
536
- fields,
537
- date_preset,
538
- time_range,
539
- time_increment,
540
- breakdowns,
541
- filtering,
542
- conversion_event,
543
- sort,
544
- limit,
545
- after
546
- }) => {
547
- try {
548
- const convEvent = conversion_event ?? "mobile_app_install";
549
- const effectiveLevel = level ?? "campaign";
550
- let effectiveFields = fields;
551
- if (!effectiveFields) {
552
- if (effectiveLevel === "ad") {
553
- effectiveFields = `${INSIGHT_AD_FIELDS},${INSIGHT_DEFAULT_FIELDS}`;
554
- } else if (effectiveLevel === "adset") {
555
- effectiveFields = `${INSIGHT_ADSET_FIELDS},${INSIGHT_DEFAULT_FIELDS}`;
556
- } else {
557
- effectiveFields = INSIGHT_DEFAULT_FIELDS;
558
- }
559
- }
560
- const params = {
561
- fields: effectiveFields,
562
- level: effectiveLevel,
563
- limit: String(limit ?? 50)
564
- };
565
- if (time_range) {
566
- params.time_range = JSON.stringify(time_range);
567
- } else {
568
- params.date_preset = date_preset ?? DEFAULT_DATE_PRESET;
569
- }
570
- if (time_increment) {
571
- params.time_increment = time_increment;
572
- }
573
- if (breakdowns) {
574
- params.breakdowns = breakdowns;
575
- }
576
- const baseFilters = filtering !== void 0 ? filtering === "[]" ? [] : JSON.parse(filtering) : JSON.parse(activeFilter());
577
- if (campaign_id) {
578
- baseFilters.push({
579
- field: "campaign.id",
580
- operator: "IN",
581
- value: [campaign_id]
582
- });
583
- }
584
- if (adset_id) {
585
- baseFilters.push({
586
- field: "adset.id",
587
- operator: "IN",
588
- value: [adset_id]
589
- });
590
- }
591
- params.filtering = JSON.stringify(baseFilters);
592
- if (sort) {
593
- params.sort = sort;
594
- }
595
- if (after) {
596
- params.after = after;
597
- }
598
- const result = await metaApiGet({
599
- path: `/${ad_account_id}/insights`,
600
- params
601
- });
602
- const rows = result.data.data;
603
- const nextCursor = result.data.paging?.cursors?.after;
604
- if (!rows || rows.length === 0) {
605
- return {
606
- content: [
607
- {
608
- type: "text",
609
- text: "No insight data returned for the given parameters. Try a broader date range or check that active campaigns exist."
610
- }
611
- ]
612
- };
613
- }
614
- let text = `**${rows.length} rows** | Level: ${effectiveLevel} | Event: ${convEvent}`;
615
- if (campaign_id) text += ` | Campaign: ${campaign_id}`;
616
- if (adset_id) text += ` | Ad Set: ${adset_id}`;
617
- text += "\n\n";
618
- for (const row of rows) {
619
- const spend = parseFloat(row.spend || "0");
620
- const impressions = parseInt(row.impressions || "0");
621
- const conversions = getActionValue(row.actions, convEvent);
622
- const cpa = getCostPerAction(row.cost_per_action_type, convEvent);
623
- const label = row.ad_name ?? row.adset_name ?? row.campaign_name ?? row.campaign_id ?? "Account";
624
- const breakdownParts = [];
625
- if (row.age) breakdownParts.push(`Age: ${row.age}`);
626
- if (row.gender) breakdownParts.push(`Gender: ${row.gender}`);
627
- if (row.publisher_platform)
628
- breakdownParts.push(`Platform: ${row.publisher_platform}`);
629
- if (row.platform_position)
630
- breakdownParts.push(`Position: ${row.platform_position}`);
631
- if (row.country) breakdownParts.push(`Country: ${row.country}`);
632
- const breakdownStr = breakdownParts.length > 0 ? ` (${breakdownParts.join(", ")})` : "";
633
- const dateStr = time_increment && row.date_start !== row.date_stop ? ` [${row.date_start} \u2192 ${row.date_stop}]` : time_increment ? ` [${row.date_start}]` : "";
634
- text += `**${label}**${breakdownStr}${dateStr}
635
- 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";
636
- }
637
- if (nextCursor) {
638
- text += `_More results available. Pass \`after: "${nextCursor}"\` for next page._
639
- `;
640
- }
641
- if (result.warning) {
642
- text = `${result.warning}
643
-
644
- ${text}`;
645
- }
646
- return { content: [{ type: "text", text }] };
647
- } catch (err) {
648
- return {
649
- content: [
650
- {
651
- type: "text",
652
- text: `Error: ${err instanceof Error ? err.message : String(err)}`
653
- }
654
- ],
655
- isError: true
656
- };
657
- }
658
- }
659
- );
660
- }
661
-
662
- // src/tools/meta-ad-fatigue.ts
663
- import { z as z6 } from "zod";
664
- function registerGetMetaAdFatigue(server2) {
665
- server2.tool(
666
- "get_meta_ad_fatigue",
667
- "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).",
668
- {
669
- ad_account_id: z6.string().describe("Meta ad account ID (e.g. act_123456789)"),
670
- campaign_id: z6.string().optional().describe("Scope to a specific campaign"),
671
- conversion_event: z6.string().optional().describe("Action type for CPA (default: mobile_app_install)"),
672
- frequency_warning: z6.number().optional().describe("Frequency threshold for warning (default: 3)"),
673
- frequency_critical: z6.number().optional().describe("Frequency threshold for critical (default: 5)"),
674
- ctr_decline_threshold: z6.number().optional().describe(
675
- "CTR decline % from peak to flag fatigue (default: 30)"
676
- )
677
- },
678
- async ({
679
- ad_account_id,
680
- campaign_id,
681
- conversion_event,
682
- frequency_warning,
683
- frequency_critical,
684
- ctr_decline_threshold
685
- }) => {
686
- try {
687
- const convEvent = conversion_event ?? "mobile_app_install";
688
- const freqWarn = frequency_warning ?? 3;
689
- const freqCrit = frequency_critical ?? 5;
690
- const ctrThreshold = ctr_decline_threshold ?? 30;
691
- const filtering = JSON.parse(activeFilter());
692
- if (campaign_id) {
693
- filtering.push({
694
- field: "campaign.id",
695
- operator: "EQUAL",
696
- value: campaign_id
697
- });
698
- }
699
- const result = await metaApiGet({
700
- path: `/${ad_account_id}/insights`,
701
- params: {
702
- level: "ad",
703
- time_increment: "1",
704
- fields: "ad_id,ad_name,spend,impressions,clicks,ctr,cpm,frequency,actions,cost_per_action_type",
705
- date_preset: "last_7d",
706
- filtering: JSON.stringify(filtering),
707
- limit: "500"
708
- }
709
- });
710
- const rows = result.data.data;
711
- if (!rows || rows.length === 0) {
712
- return {
713
- content: [
714
- {
715
- type: "text",
716
- text: "No active ad data found for the last 7 days."
717
- }
718
- ]
719
- };
720
- }
721
- const adMap = /* @__PURE__ */ new Map();
722
- for (const row of rows) {
723
- const adId = row.ad_id;
724
- if (!adMap.has(adId)) {
725
- adMap.set(adId, {
726
- ad_id: adId,
727
- ad_name: row.ad_name ?? adId,
728
- days: []
729
- });
730
- }
731
- adMap.get(adId).days.push({
732
- date: row.date_start,
733
- spend: parseFloat(row.spend || "0"),
734
- impressions: parseInt(row.impressions || "0"),
735
- clicks: parseInt(row.clicks || "0"),
736
- ctr: parseFloat(row.ctr || "0"),
737
- cpm: parseFloat(row.cpm || "0"),
738
- frequency: parseFloat(row.frequency || "0"),
739
- conversions: getActionValue(row.actions, convEvent),
740
- cpa: getCostPerAction(row.cost_per_action_type, convEvent)
741
- });
742
- }
743
- const results = [];
744
- let totalSpend = 0;
745
- for (const ad of adMap.values()) {
746
- ad.days.sort(
747
- (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
748
- );
749
- const adSpend = ad.days.reduce((s, d) => s + d.spend, 0);
750
- totalSpend += adSpend;
751
- const avgFreq = ad.days.reduce((s, d) => s + d.frequency, 0) / ad.days.length;
752
- const peakCtr = Math.max(...ad.days.map((d) => d.ctr));
753
- const last3 = ad.days.slice(-3);
754
- const recentCtr = last3.reduce((s, d) => s + d.ctr, 0) / last3.length;
755
- const ctrDecline = peakCtr > 0 ? (peakCtr - recentCtr) / peakCtr * 100 : 0;
756
- const mid = Math.floor(ad.days.length / 2);
757
- const earlyDays = ad.days.slice(0, Math.max(mid, 1));
758
- const lateDays = ad.days.slice(mid);
759
- const earlyConv = earlyDays.reduce((s, d) => s + d.conversions, 0);
760
- const earlySpend = earlyDays.reduce((s, d) => s + d.spend, 0);
761
- const earlyCpa = earlyConv > 0 ? earlySpend / earlyConv : null;
762
- const lateConv = lateDays.reduce((s, d) => s + d.conversions, 0);
763
- const lateSpend = lateDays.reduce((s, d) => s + d.spend, 0);
764
- const recentCpa = lateConv > 0 ? lateSpend / lateConv : null;
765
- const cpaChange = earlyCpa !== null && recentCpa !== null && earlyCpa > 0 ? (recentCpa - earlyCpa) / earlyCpa * 100 : null;
766
- let status2 = "HEALTHY";
767
- let diagnosis = "Metrics stable \u2014 no fatigue detected.";
768
- const highFreq = avgFreq >= freqCrit;
769
- const medFreq = avgFreq >= freqWarn;
770
- const ctrDeclining = ctrDecline >= ctrThreshold;
771
- const cpaRising = cpaChange !== null && cpaChange > 20;
772
- if (highFreq && cpaRising) {
773
- status2 = "FATIGUED";
774
- diagnosis = `Audience saturation: frequency ${avgFreq.toFixed(1)} + CPA rising ${cpaChange.toFixed(0)}% [wk-tw-001 #1, ds-pt-003]`;
775
- } else if (ctrDeclining && cpaRising) {
776
- status2 = "FATIGUED";
777
- diagnosis = `Creative fatigue: CTR declined ${ctrDecline.toFixed(0)}% from peak + CPA rising ${cpaChange.toFixed(0)}% [wk-tw-001 #4]`;
778
- } else if (highFreq) {
779
- status2 = "WARNING";
780
- diagnosis = `High frequency (${avgFreq.toFixed(1)}) \u2014 approaching saturation [ds-pt-003]. CPA ${cpaRising ? "rising" : "stable"}.`;
781
- } else if (ctrDeclining) {
782
- status2 = "WARNING";
783
- diagnosis = `CTR declining ${ctrDecline.toFixed(0)}% from peak \u2014 early fatigue signal [wk-tw-001 #4].`;
784
- } else if (medFreq && cpaRising) {
785
- status2 = "WARNING";
786
- diagnosis = `Frequency ${avgFreq.toFixed(1)} + CPA trending up ${cpaChange.toFixed(0)}% \u2014 monitor closely [wk-tw-001 #1].`;
787
- }
788
- results.push({
789
- ad_id: ad.ad_id,
790
- ad_name: ad.ad_name,
791
- total_spend: adSpend,
792
- avg_frequency: avgFreq,
793
- peak_ctr: peakCtr,
794
- recent_ctr: recentCtr,
795
- ctr_decline_pct: ctrDecline,
796
- early_cpa: earlyCpa,
797
- recent_cpa: recentCpa,
798
- cpa_change_pct: cpaChange,
799
- status: status2,
800
- diagnosis
801
- });
802
- }
803
- const statusOrder = { FATIGUED: 0, WARNING: 1, HEALTHY: 2 };
804
- results.sort(
805
- (a, b) => statusOrder[a.status] - statusOrder[b.status] || b.total_spend - a.total_spend
806
- );
807
- const fatigued = results.filter((r) => r.status === "FATIGUED");
808
- const warning = results.filter((r) => r.status === "WARNING");
809
- const healthy = results.filter((r) => r.status === "HEALTHY");
810
- const fatiguedSpend = fatigued.reduce((s, r) => s + r.total_spend, 0);
811
- const spendConcentration = totalSpend > 0 ? fatiguedSpend / totalSpend * 100 : 0;
812
- let text = `# Ad Fatigue Report
813
-
814
- `;
815
- text += `**${results.length} ads analyzed** | ${fatigued.length} fatigued | ${warning.length} warning | ${healthy.length} healthy
816
- `;
817
- text += `**Total spend:** $${totalSpend.toFixed(2)} | **Fatigued ad spend:** $${fatiguedSpend.toFixed(2)} (${spendConcentration.toFixed(0)}%)
818
-
819
- `;
820
- if (spendConcentration > 50) {
821
- text += `\u26A0 **Over 50% of spend is going to fatigued ads.** Urgent creative rotation needed.
822
-
823
- `;
824
- }
825
- text += `| Status | Ad | Spend | Freq | CTR (peak\u2192recent) | CPA Trend | Diagnosis |
826
- `;
827
- text += `|--------|-----|-------|------|-------------------|-----------|------------|
828
- `;
829
- for (const r of results) {
830
- const icon = r.status === "FATIGUED" ? "\u{1F534}" : r.status === "WARNING" ? "\u{1F7E1}" : "\u{1F7E2}";
831
- 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} |
832
- `;
833
- }
834
- text += `
835
- ## Recommendations
836
-
837
- `;
838
- if (fatigued.length > 0) {
839
- text += `### Fatigued Ads (${fatigued.length})
840
- - **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].
841
- - **Change the hook** (first 3 seconds) to access new audience segments [oh-li-005].
842
- - **Do NOT re-test degraded creatives** \u2014 exhaust all other angles first. Wait 3-6 months before revisiting [vs-nt-002].
843
- - Meta will auto-shift spend away from degrading creatives [vs-nt-001] \u2014 don't panic-pause, but do prepare replacements.
844
-
845
- `;
846
- }
847
- if (healthy.length > 0) {
848
- text += `### Healthy Ads (${healthy.length})
849
- - Let Meta manage allocation. Don't force spend to low-spend winners \u2014 Meta has determined scaling them would degrade performance [ds-pt-004].
850
-
851
- `;
852
- }
853
- if (result.warning) {
854
- text = `${result.warning}
855
-
856
- ${text}`;
857
- }
858
- return { content: [{ type: "text", text }] };
859
- } catch (err) {
860
- return {
861
- content: [
862
- {
863
- type: "text",
864
- text: `Error: ${err instanceof Error ? err.message : String(err)}`
865
- }
866
- ],
867
- isError: true
868
- };
869
- }
870
- }
871
- );
872
- }
873
-
874
167
  // src/tools/google-campaigns.ts
875
- import { z as z7 } from "zod";
168
+ import { z as z2 } from "zod";
876
169
 
877
170
  // src/google/oauth.ts
878
171
  var TOKEN_URL = "https://oauth2.googleapis.com/token";
@@ -1126,10 +419,10 @@ function registerGetGoogleAdsCampaigns(server2) {
1126
419
  "get_google_ads_campaigns",
1127
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.",
1128
421
  {
1129
- customer_id: z7.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
1130
- status: z7.array(z7.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by campaign status. Default: ["ENABLED"]'),
1131
- 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'),
1132
- 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)")
1133
426
  },
1134
427
  async ({ customer_id, status: status2, channel_sub_type, limit }) => {
1135
428
  try {
@@ -1210,16 +503,16 @@ function registerGetGoogleAdsCampaigns(server2) {
1210
503
  }
1211
504
 
1212
505
  // src/tools/google-ad-groups.ts
1213
- import { z as z8 } from "zod";
506
+ import { z as z3 } from "zod";
1214
507
  function registerGetGoogleAdsAdGroups(server2) {
1215
508
  server2.tool(
1216
509
  "get_google_ad_groups",
1217
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).",
1218
511
  {
1219
- customer_id: z8.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
1220
- campaign_id: z8.string().optional().describe("Scope to a specific campaign. If omitted, returns ad groups across all app campaigns"),
1221
- status: z8.array(z8.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by ad group status. Default: ["ENABLED"]'),
1222
- 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)")
1223
516
  },
1224
517
  async ({ customer_id, campaign_id, status: status2, limit }) => {
1225
518
  try {
@@ -1290,7 +583,7 @@ function registerGetGoogleAdsAdGroups(server2) {
1290
583
  }
1291
584
 
1292
585
  // src/tools/google-assets.ts
1293
- import { z as z9 } from "zod";
586
+ import { z as z4 } from "zod";
1294
587
  var SLOT_LIMITS = {
1295
588
  HEADLINE: 5,
1296
589
  DESCRIPTION: 5,
@@ -1309,12 +602,12 @@ function registerGetGoogleAdsAssets(server2) {
1309
602
  "get_google_assets",
1310
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).",
1311
604
  {
1312
- customer_id: z9.string().describe("Google Ads customer ID"),
1313
- campaign_id: z9.string().optional().describe("Scope to a specific campaign"),
1314
- ad_group_id: z9.string().optional().describe("Scope to a specific ad group"),
1315
- asset_type: z9.array(z9.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT", "MEDIA_BUNDLE"])).optional().describe("Filter by asset type"),
1316
- include_slot_audit: z9.boolean().optional().describe("Include slot utilization audit (default true)"),
1317
- 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)")
1318
611
  },
1319
612
  async ({ customer_id, campaign_id, ad_group_id, asset_type, include_slot_audit, limit }) => {
1320
613
  try {
@@ -1498,7 +791,7 @@ function registerGetGoogleAdsAssets(server2) {
1498
791
  }
1499
792
 
1500
793
  // src/tools/google-insights.ts
1501
- import { z as z10 } from "zod";
794
+ import { z as z5 } from "zod";
1502
795
  function parseNum(v) {
1503
796
  if (v === void 0 || v === null) return 0;
1504
797
  if (typeof v === "number") return v;
@@ -1555,19 +848,19 @@ function registerGetGoogleAdsInsights(server2) {
1555
848
  "get_google_insights",
1556
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.",
1557
850
  {
1558
- customer_id: z10.string().describe("Google Ads customer ID"),
1559
- level: z10.enum(["account", "campaign", "ad_group", "asset"]).optional().describe("Aggregation level (default: campaign)"),
1560
- campaign_id: z10.string().optional().describe("Scope to specific campaign"),
1561
- ad_group_id: z10.string().optional().describe("Scope to specific ad group"),
1562
- breakdown: z10.enum(["network", "device"]).optional().describe("Segmentation dimension. Only one at a time (GAQL restriction)"),
1563
- date_range: z10.object({
1564
- start_date: z10.string().describe("YYYY-MM-DD"),
1565
- 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")
1566
859
  }).optional().describe("Custom date range. Overrides date_preset"),
1567
- 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)"),
1568
- time_increment: z10.enum(["daily", "weekly", "monthly", "summary"]).optional().describe("Time granularity (default: summary)"),
1569
- sort: z10.enum(["cost_desc", "conversions_desc", "impressions_desc", "ctr_desc"]).optional().describe("Sort order (default: cost_desc)"),
1570
- 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)")
1571
864
  },
1572
865
  async ({ customer_id, level, campaign_id, ad_group_id, breakdown, date_range, date_preset, time_increment, sort, limit }) => {
1573
866
  try {
@@ -1745,7 +1038,7 @@ function registerGetGoogleAdsInsights(server2) {
1745
1038
  }
1746
1039
 
1747
1040
  // src/tools/google-network-mix.ts
1748
- import { z as z11 } from "zod";
1041
+ import { z as z6 } from "zod";
1749
1042
  function parseNum2(v) {
1750
1043
  if (v === void 0 || v === null) return 0;
1751
1044
  if (typeof v === "number") return v;
@@ -1757,13 +1050,13 @@ function registerGetGoogleAdsNetworkMix(server2) {
1757
1050
  "get_google_network_mix",
1758
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).",
1759
1052
  {
1760
- customer_id: z11.string().describe("Google Ads customer ID"),
1761
- campaign_id: z11.string().optional().describe("Scope to one campaign. If omitted, aggregates across all app campaigns"),
1762
- date_range: z11.object({
1763
- start_date: z11.string().describe("YYYY-MM-DD"),
1764
- 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")
1765
1058
  }).optional().describe("Custom date range. Default: last 14 days"),
1766
- 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)")
1767
1060
  },
1768
1061
  async ({ customer_id, campaign_id, date_range, shift_threshold_pct }) => {
1769
1062
  try {
@@ -1930,7 +1223,7 @@ function registerGetGoogleAdsNetworkMix(server2) {
1930
1223
  }
1931
1224
 
1932
1225
  // src/tools/google-asset-fatigue.ts
1933
- import { z as z12 } from "zod";
1226
+ import { z as z7 } from "zod";
1934
1227
  function parseNum3(v) {
1935
1228
  if (v === void 0 || v === null) return 0;
1936
1229
  if (typeof v === "number") return v;
@@ -1970,13 +1263,13 @@ function registerGetGoogleAdsAssetFatigue(server2) {
1970
1263
  "get_google_asset_fatigue",
1971
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).",
1972
1265
  {
1973
- customer_id: z12.string().describe("Google Ads customer ID"),
1974
- campaign_id: z12.string().describe("Campaign to analyze"),
1975
- ad_group_id: z12.string().optional().describe("Scope to specific ad group"),
1976
- lookback_days: z12.number().min(7).max(90).optional().describe("Days of daily data to analyze (default 14)"),
1977
- ctr_decline_threshold_pct: z12.number().optional().describe("CTR decline % from peak to flag fatigue (default 30)"),
1978
- impression_decay_threshold_pct: z12.number().optional().describe("Impression volume drop % from peak to flag (default 50)"),
1979
- 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")
1980
1273
  },
1981
1274
  async ({ customer_id, campaign_id, ad_group_id, lookback_days, ctr_decline_threshold_pct, impression_decay_threshold_pct, asset_type }) => {
1982
1275
  try {
@@ -2228,7 +1521,7 @@ Sources: goog-pdf-018, ab-pt-008, goog-pdf-019, ab-pt-007
2228
1521
  }
2229
1522
 
2230
1523
  // src/tools/google-upload-assets.ts
2231
- import { z as z13 } from "zod";
1524
+ import { z as z8 } from "zod";
2232
1525
  import { readFile } from "fs/promises";
2233
1526
  import { basename, resolve } from "path";
2234
1527
  async function fetchAsBase64(url) {
@@ -2251,25 +1544,25 @@ function registerUploadGoogleImageAssets(server2) {
2251
1544
  "upload_google_image_assets",
2252
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).",
2253
1546
  {
2254
- customer_id: z13.string().describe("Google Ads customer ID"),
2255
- images: z13.array(
2256
- z13.object({
2257
- source: z13.string().describe(
1547
+ customer_id: z8.string().describe("Google Ads customer ID"),
1548
+ images: z8.array(
1549
+ z8.object({
1550
+ source: z8.string().describe(
2258
1551
  "Image URL (https://...) or local file path (/path/to/image.png)"
2259
1552
  ),
2260
- name: z13.string().optional().describe(
1553
+ name: z8.string().optional().describe(
2261
1554
  "Asset name in Google Ads (default: filename from source)"
2262
1555
  )
2263
1556
  })
2264
1557
  ).min(1).max(50).describe("Array of images to upload (max 50 per call)"),
2265
- campaign_id: z13.string().optional().describe(
1558
+ campaign_id: z8.string().optional().describe(
2266
1559
  "Link uploaded assets to this campaign (creates CampaignAsset links)"
2267
1560
  ),
2268
- ad_group_id: z13.string().optional().describe(
1561
+ ad_group_id: z8.string().optional().describe(
2269
1562
  "Link uploaded assets to this ad group (creates AdGroupAsset links). Takes priority over campaign_id."
2270
1563
  ),
2271
- field_type: z13.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)"),
2272
- dry_run: z13.boolean().optional().describe("Preview what would be uploaded without making changes")
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")
2273
1566
  },
2274
1567
  async ({
2275
1568
  customer_id,
@@ -2393,7 +1686,7 @@ Assets exist in the account and can be linked manually.`;
2393
1686
  function registerConnectionStatus(server2, status2) {
2394
1687
  server2.tool(
2395
1688
  "connection_status",
2396
- "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.",
2397
1690
  {},
2398
1691
  async () => {
2399
1692
  const lines = ["# Connection Status", ""];
@@ -2417,26 +1710,11 @@ function registerConnectionStatus(server2, status2) {
2417
1710
  );
2418
1711
  }
2419
1712
  lines.push("");
2420
- if (status2.meta.tokenConfigured) {
2421
- lines.push(
2422
- "## Meta Marketing API: Configured",
2423
- "- Meta tools are available and ready to use"
2424
- );
2425
- } else {
2426
- lines.push(
2427
- "## Meta Marketing API: Not Connected (Optional)",
2428
- "- KB, suggestions, and private insights work without it",
2429
- "- Connect Meta to unlock live campaign data and reports",
2430
- "",
2431
- "### How to connect",
2432
- "Provide your Meta access token using one of these methods:",
2433
- '1. MCP config: add `"META_ACCESS_TOKEN": "..."` to the `"env"` block in `.mcp.json` (Claude Code/Cursor) or `claude_desktop_config.json` (Claude Desktop)',
2434
- "2. CLI argument: add `--meta-token=...` to the args array",
2435
- "3. `.env` file: add `META_ACCESS_TOKEN=...` to a `.env` file in your working directory",
2436
- "",
2437
- "Then restart your MCP client."
2438
- );
2439
- }
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
+ );
2440
1718
  lines.push("");
2441
1719
  if (status2.google.configured) {
2442
1720
  lines.push(
@@ -2523,7 +1801,9 @@ var TOPICS = [
2523
1801
  "competitive_analysis",
2524
1802
  "ad_copy",
2525
1803
  "conversion_rate",
2526
- "strategy"
1804
+ "strategy",
1805
+ "psychology",
1806
+ "compliance"
2527
1807
  ];
2528
1808
  var APPLIES_TO = [
2529
1809
  "subscription_apps",
@@ -2583,15 +1863,17 @@ function registerVocabularyResource(server2) {
2583
1863
  }
2584
1864
 
2585
1865
  // src/resources/instructions.ts
2586
- var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Ad Platform Tools
1866
+ var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Analysis Skills
2587
1867
 
2588
1868
  ## Welcome
2589
1869
 
2590
- 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.
2591
1871
 
2592
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.
2593
1873
 
2594
- > **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.
2595
1877
 
2596
1878
  Quick examples:
2597
1879
  - "subscription app creative fatigue signals"
@@ -2602,7 +1884,7 @@ Quick examples:
2602
1884
  If you can't find what you need, call \`submit_feedback\` to report the gap \u2014 it helps us improve the knowledge base.
2603
1885
 
2604
1886
  ## What This Is
2605
- 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.
2606
1888
 
2607
1889
  ---
2608
1890
 
@@ -2645,52 +1927,20 @@ Save knowledge that is private to your API key. Immediately searchable but only
2645
1927
  - Same full schema as suggest_insight
2646
1928
  - No admin approval needed \u2014 saved instantly
2647
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
+
2648
1937
  ---
2649
1938
 
2650
- ## Meta Marketing API Tools
2651
-
2652
- **Requires META_ACCESS_TOKEN env var** \u2014 without it, these tools return a clear error. Knowledge base tools work with just API_KEY.
2653
-
2654
- **Rate limit safety**: All tools default to last_7d, active-only, minimal fields. No auto-pagination. Throttle header monitored \u2014 warns at >75% utilization.
2655
-
2656
- ### get_meta_campaigns
2657
- List campaigns from a Meta ad account. Defaults to active campaigns.
2658
- - **ad_account_id** (required): e.g. "act_123456789"
2659
- - **fields, effective_status, limit, after** (optional)
2660
-
2661
- ### get_meta_adsets
2662
- List ad sets, optionally scoped to a campaign.
2663
- - **ad_account_id** (required)
2664
- - **campaign_id** (optional): Scope to specific campaign
2665
- - **fields, effective_status, limit, after** (optional)
2666
-
2667
- ### get_meta_ads
2668
- List ads, optionally scoped to an ad set.
2669
- - **ad_account_id** (required)
2670
- - **adset_id** (optional): Scope to specific ad set
2671
- - **fields, effective_status, limit, after** (optional)
2672
-
2673
- ### get_meta_insights
2674
- Pull performance insights with configurable level, breakdowns, date range. Ad-level queries auto-include ad_id, ad_name, adset_id, adset_name.
2675
- - **ad_account_id** (required)
2676
- - **campaign_id** (optional): Scope to a specific campaign
2677
- - **adset_id** (optional): Scope to a specific ad set
2678
- - **level** (optional): account, campaign, adset, ad (default: campaign)
2679
- - **date_preset** (optional): default last_7d
2680
- - **time_range** (optional): {since, until} for custom dates
2681
- - **time_increment** (optional): "1" for daily, "7" for weekly
2682
- - **breakdowns** (optional): e.g. "age,gender" or "publisher_platform,platform_position"
2683
- - **conversion_event** (optional): default "mobile_app_install"
2684
- - **fields, filtering, sort, limit, after** (optional)
2685
-
2686
- ### get_meta_ad_fatigue
2687
- Built-in report: detect creative fatigue via frequency, CTR decline, CPA trends.
2688
- - **ad_account_id** (required)
2689
- - **campaign_id** (optional): Scope to specific campaign
2690
- - **conversion_event** (optional): default "mobile_app_install"
2691
- - **frequency_warning** (optional): default 3
2692
- - **frequency_critical** (optional): default 5
2693
- - **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.
2694
1944
 
2695
1945
  ---
2696
1946
 
@@ -2757,19 +2007,19 @@ Detect creative asset fatigue by analyzing per-asset impression trends, CTR decl
2757
2007
 
2758
2008
  ## Reports (MCP Prompts)
2759
2009
 
2760
- 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:
2761
2011
 
2762
- | Prompt | What it does | API calls |
2763
- |--------|-------------|-----------|
2764
- | ad-fatigue-report | Detect creative fatigue with daily granularity | 1 |
2765
- | weekly-performance | Week-over-week health comparison with diagnosis | 2 |
2766
- | creative-performance | Categorize ads by health status | 1 |
2767
- | audience-composition | Age x gender heatmap with CPA analysis | 1-2 |
2768
- | architecture-review | Campaign structure evaluation | 3 (no insights) |
2769
- | audit-meta-account | Comprehensive account audit | 6+ |
2770
- | campaign-comparison | Side-by-side campaign comparison | 3+ |
2771
- | placement-audit | Detailed placement audit with examples | 1 per campaign |
2772
- | 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 |
2773
2023
 
2774
2024
  ---
2775
2025
 
@@ -2797,9 +2047,9 @@ When your response draws on knowledge base results, **always attribute visibly**
2797
2047
  5. Use \`get_google_asset_fatigue\` on specific campaigns to detect creative decay
2798
2048
 
2799
2049
  ### For Meta analysis:
2800
- 1. Start with \`get_meta_campaigns\` to see account structure
2801
- 2. Use reports (MCP prompts) for comprehensive analysis
2802
- 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
2803
2053
 
2804
2054
  ### General:
2805
2055
  - Always \`search_insights\` before making recommendations \u2014 ground advice in expert knowledge
@@ -2823,16 +2073,9 @@ function buildStatusSection(status2) {
2823
2073
  " - Fix: provide your API key via `--api-key=me_...` CLI arg, `API_KEY` env var, or `.env` file"
2824
2074
  );
2825
2075
  }
2826
- if (status2.meta.tokenConfigured) {
2827
- lines.push("- **Meta Marketing API**: Token configured");
2828
- } else {
2829
- lines.push(
2830
- "- **Meta Marketing API**: Not connected (optional \u2014 KB works without it)"
2831
- );
2832
- lines.push(
2833
- " - To connect: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
2834
- );
2835
- }
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
+ );
2836
2079
  if (status2.google.configured) {
2837
2080
  lines.push("- **Google Ads API**: Configured");
2838
2081
  } else {
@@ -2919,9 +2162,6 @@ function resolve2(envName, cliName) {
2919
2162
  function resolveApiKey() {
2920
2163
  return resolve2("API_KEY", "api-key");
2921
2164
  }
2922
- function resolveMetaToken() {
2923
- return resolve2("META_ACCESS_TOKEN", "meta-token");
2924
- }
2925
2165
  function resolveGoogleAdsConfig() {
2926
2166
  const devToken = resolve2("GOOGLE_ADS_DEVELOPER_TOKEN", "google-dev-token");
2927
2167
  const clientId = resolve2("GOOGLE_ADS_CLIENT_ID", "google-client-id");
@@ -3218,10 +2458,8 @@ Error: ${err instanceof Error ? err.message : String(err)}`
3218
2458
  // src/index.ts
3219
2459
  await maybeRunAuthCommand();
3220
2460
  var apiKeyResult = resolveApiKey();
3221
- var metaTokenResult = resolveMetaToken();
3222
2461
  var googleAdsResult = resolveGoogleAdsConfig();
3223
2462
  if (apiKeyResult.value) process.env.API_KEY = apiKeyResult.value;
3224
- if (metaTokenResult.value) process.env.META_ACCESS_TOKEN = metaTokenResult.value;
3225
2463
  if (googleAdsResult.developerToken)
3226
2464
  process.env.GOOGLE_ADS_DEVELOPER_TOKEN = googleAdsResult.developerToken;
3227
2465
  if (googleAdsResult.clientId)
@@ -3236,9 +2474,6 @@ var apiKey = apiKeyResult.value;
3236
2474
  console.error(
3237
2475
  apiKey ? `API key: ${apiKeyResult.source}` : "API key: not configured \u2014 KB tools will not be available"
3238
2476
  );
3239
- console.error(
3240
- metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured (optional \u2014 KB works without it)"
3241
- );
3242
2477
  console.error(
3243
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`
3244
2479
  );
@@ -3248,7 +2483,6 @@ var server = new McpServer({
3248
2483
  });
3249
2484
  var status = {
3250
2485
  kb: { connected: false, toolCount: 0, promptCount: 0 },
3251
- meta: { tokenConfigured: !!metaTokenResult.value },
3252
2486
  google: {
3253
2487
  configured: googleAdsResult.configured,
3254
2488
  missing: googleAdsResult.missing
@@ -3271,11 +2505,6 @@ if (apiKey) {
3271
2505
  } else {
3272
2506
  status.kb.error = "API_KEY not configured";
3273
2507
  }
3274
- registerGetMetaCampaigns(server);
3275
- registerGetMetaAdSets(server);
3276
- registerGetMetaAds(server);
3277
- registerGetMetaInsights(server);
3278
- registerGetMetaAdFatigue(server);
3279
2508
  registerGetGoogleAdsCampaigns(server);
3280
2509
  registerGetGoogleAdsAdGroups(server);
3281
2510
  registerGetGoogleAdsAssets(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-growth-mcp",
3
- "version": "2.3.4",
3
+ "version": "2.3.5",
4
4
  "description": "MCP server for mobile growth & UA knowledge base — campaign optimization, creative strategy, and subscription app insights",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",