mobile-growth-mcp 2.2.1 → 2.3.4

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 +455 -93
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ async function jsonRpcRequest(apiKey2, method, params) {
23
23
  Accept: "application/json, text/event-stream"
24
24
  },
25
25
  body: JSON.stringify(body),
26
- signal: AbortSignal.timeout(15e3)
26
+ signal: AbortSignal.timeout(3e4)
27
27
  });
28
28
  if (!res.ok) {
29
29
  const text = await res.text();
@@ -39,19 +39,34 @@ async function fetchRemoteTools(apiKey2) {
39
39
  return resp.result?.tools ?? [];
40
40
  }
41
41
  async function callRemoteTool(apiKey2, name, args) {
42
- const resp = await jsonRpcRequest(apiKey2, "tools/call", {
43
- name,
44
- arguments: args
45
- });
46
- if (resp.error) {
47
- return {
48
- content: [{ type: "text", text: `Remote error: ${resp.error.message}` }],
49
- isError: true
50
- };
42
+ const maxAttempts = 2;
43
+ let lastError;
44
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
45
+ try {
46
+ const resp = await jsonRpcRequest(apiKey2, "tools/call", {
47
+ name,
48
+ arguments: args
49
+ });
50
+ if (resp.error) {
51
+ return {
52
+ content: [{ type: "text", text: `Remote error: ${resp.error.message}` }],
53
+ isError: true
54
+ };
55
+ }
56
+ return {
57
+ content: resp.result?.content ?? [{ type: "text", text: "No content returned" }],
58
+ isError: resp.result?.isError
59
+ };
60
+ } catch (err) {
61
+ lastError = err;
62
+ const isRetryable = lastError.name === "AbortError" || lastError.name === "TimeoutError" || lastError.message?.includes("fetch failed");
63
+ if (!isRetryable || attempt === maxAttempts) break;
64
+ await new Promise((r) => setTimeout(r, 2e3));
65
+ }
51
66
  }
52
67
  return {
53
- content: resp.result?.content ?? [{ type: "text", text: "No content returned" }],
54
- isError: resp.result?.isError
68
+ content: [{ type: "text", text: `Remote call failed after retry: ${lastError?.message ?? "unknown error"}` }],
69
+ isError: true
55
70
  };
56
71
  }
57
72
  function jsonSchemaToZodShape(inputSchema) {
@@ -222,7 +237,7 @@ async function metaApiGet(options) {
222
237
  function activeFilter() {
223
238
  return JSON.stringify([
224
239
  {
225
- field: "effective_status",
240
+ field: "ad.effective_status",
226
241
  operator: "IN",
227
242
  value: ["ACTIVE"]
228
243
  }
@@ -243,6 +258,8 @@ var CAMPAIGN_DEFAULT_FIELDS = "id,name,status,effective_status,objective,bid_str
243
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";
244
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}";
245
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";
246
263
 
247
264
  // src/tools/meta-campaigns.ts
248
265
  function registerGetMetaCampaigns(server2) {
@@ -405,16 +422,17 @@ import { z as z4 } from "zod";
405
422
  function registerGetMetaAds(server2) {
406
423
  server2.tool(
407
424
  "get_meta_ads",
408
- "List ads from a Meta ad account, optionally scoped to an ad set. Defaults to active ads with lean field set.",
425
+ "List ads from a Meta ad account, optionally scoped to a campaign or ad set. Defaults to active ads with lean field set.",
409
426
  {
410
427
  ad_account_id: z4.string().describe("Meta ad account ID (e.g. act_123456789)"),
411
- adset_id: z4.string().optional().describe("Scope to a specific ad set ID."),
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."),
412
430
  fields: z4.string().optional().describe(`Comma-separated fields. Default: ${AD_DEFAULT_FIELDS}`),
413
431
  effective_status: z4.array(z4.string()).optional().describe('Filter by status. Default: ["ACTIVE"]'),
414
432
  limit: z4.number().min(1).max(100).optional().describe("Results per page (default 50, max 100)"),
415
433
  after: z4.string().optional().describe("Pagination cursor from previous response")
416
434
  },
417
- async ({ ad_account_id, adset_id, fields, effective_status, limit, after }) => {
435
+ async ({ ad_account_id, campaign_id, adset_id, fields, effective_status, limit, after }) => {
418
436
  try {
419
437
  const params = {
420
438
  fields: fields ?? AD_DEFAULT_FIELDS,
@@ -434,7 +452,7 @@ function registerGetMetaAds(server2) {
434
452
  if (after) {
435
453
  params.after = after;
436
454
  }
437
- const parentPath = adset_id ? `/${adset_id}/ads` : `/${ad_account_id}/ads`;
455
+ const parentPath = adset_id ? `/${adset_id}/ads` : campaign_id ? `/${campaign_id}/ads` : `/${ad_account_id}/ads`;
438
456
  const result = await metaApiGet({
439
457
  path: parentPath,
440
458
  params
@@ -446,7 +464,7 @@ function registerGetMetaAds(server2) {
446
464
  `;
447
465
  for (const ad of ads) {
448
466
  text += `- **${ad.name}** (${ad.id})
449
- 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";
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";
450
468
  }
451
469
  if (nextCursor) {
452
470
  text += `
@@ -478,12 +496,14 @@ import { z as z5 } from "zod";
478
496
  function registerGetMetaInsights(server2) {
479
497
  server2.tool(
480
498
  "get_meta_insights",
481
- "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).",
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.",
482
500
  {
483
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)"),
484
504
  level: z5.enum(["account", "campaign", "adset", "ad"]).optional().describe("Aggregation level (default: campaign)"),
485
505
  fields: z5.string().optional().describe(
486
- `Comma-separated fields. Default: ${INSIGHT_DEFAULT_FIELDS}`
506
+ `Comma-separated fields. Default includes level-appropriate name fields + ${INSIGHT_DEFAULT_FIELDS}`
487
507
  ),
488
508
  date_preset: z5.string().optional().describe(`Date preset (default: ${DEFAULT_DATE_PRESET})`),
489
509
  time_range: z5.object({
@@ -510,6 +530,8 @@ function registerGetMetaInsights(server2) {
510
530
  },
511
531
  async ({
512
532
  ad_account_id,
533
+ campaign_id,
534
+ adset_id,
513
535
  level,
514
536
  fields,
515
537
  date_preset,
@@ -524,9 +546,20 @@ function registerGetMetaInsights(server2) {
524
546
  }) => {
525
547
  try {
526
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
+ }
527
560
  const params = {
528
- fields: fields ?? INSIGHT_DEFAULT_FIELDS,
529
- level: level ?? "campaign",
561
+ fields: effectiveFields,
562
+ level: effectiveLevel,
530
563
  limit: String(limit ?? 50)
531
564
  };
532
565
  if (time_range) {
@@ -540,11 +573,22 @@ function registerGetMetaInsights(server2) {
540
573
  if (breakdowns) {
541
574
  params.breakdowns = breakdowns;
542
575
  }
543
- if (filtering !== void 0) {
544
- params.filtering = filtering === "[]" ? "[]" : filtering;
545
- } else {
546
- params.filtering = activeFilter();
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
+ });
547
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);
548
592
  if (sort) {
549
593
  params.sort = sort;
550
594
  }
@@ -567,9 +611,10 @@ function registerGetMetaInsights(server2) {
567
611
  ]
568
612
  };
569
613
  }
570
- let text = `**${rows.length} rows** | Level: ${level ?? "campaign"} | Event: ${convEvent}
571
-
572
- `;
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";
573
618
  for (const row of rows) {
574
619
  const spend = parseFloat(row.spend || "0");
575
620
  const impressions = parseInt(row.impressions || "0");
@@ -929,6 +974,30 @@ function formatGoogleAdsError(err) {
929
974
  }
930
975
  return `Google Ads API error (${code}): ${err.message}`;
931
976
  }
977
+ async function googleAdsMutate(customerId, operations) {
978
+ const auth = getGoogleAdsAuth();
979
+ const normalizedId = normalizeCustomerId(customerId);
980
+ const headers = await auth.getHeaders(normalizedId);
981
+ const url = `${BASE_URL}/customers/${normalizedId}/googleAds:mutate`;
982
+ const response = await fetch(url, {
983
+ method: "POST",
984
+ headers: {
985
+ ...headers,
986
+ "Content-Type": "application/json"
987
+ },
988
+ body: JSON.stringify({ mutateOperations: operations })
989
+ });
990
+ const body = await response.json();
991
+ if (!response.ok) {
992
+ if (isGoogleAdsError(body)) {
993
+ throw new Error(formatGoogleAdsError(body.error));
994
+ }
995
+ throw new Error(
996
+ `Google Ads API mutate returned ${response.status}: ${JSON.stringify(body)}`
997
+ );
998
+ }
999
+ return body;
1000
+ }
932
1001
  async function googleAdsQuery(customerId, query) {
933
1002
  const auth = getGoogleAdsAuth();
934
1003
  const normalizedId = normalizeCustomerId(customerId);
@@ -958,6 +1027,23 @@ async function googleAdsQuery(customerId, query) {
958
1027
  }
959
1028
 
960
1029
  // src/google/format.ts
1030
+ async function resolveCampaignId(customerId, value) {
1031
+ if (/^\d+$/.test(value)) return value;
1032
+ const query = `SELECT campaign.id, campaign.name FROM campaign WHERE campaign.name = '${value.replace(/'/g, "\\'")}' LIMIT 1`;
1033
+ const chunks = await googleAdsQuery(customerId, query);
1034
+ const rows = chunks.flatMap((c) => c.results ?? []);
1035
+ if (rows.length === 0) throw new Error(`Campaign not found by name: "${value}"`);
1036
+ return rows[0].campaign.id;
1037
+ }
1038
+ async function resolveAdGroupId(customerId, value, campaignId) {
1039
+ if (/^\d+$/.test(value)) return value;
1040
+ let query = `SELECT ad_group.id, ad_group.name FROM ad_group WHERE ad_group.name = '${value.replace(/'/g, "\\'")}' LIMIT 1`;
1041
+ if (campaignId) query = query.replace("LIMIT 1", `AND campaign.id = ${campaignId} LIMIT 1`);
1042
+ const chunks = await googleAdsQuery(customerId, query);
1043
+ const rows = chunks.flatMap((c) => c.results ?? []);
1044
+ if (rows.length === 0) throw new Error(`Ad group not found by name: "${value}"`);
1045
+ return rows[0].adGroup.id;
1046
+ }
961
1047
  function formatMicros(micros) {
962
1048
  if (!micros) return "\u2014";
963
1049
  const val = parseInt(micros, 10);
@@ -1091,7 +1177,7 @@ function registerGetGoogleAdsCampaigns(server2) {
1091
1177
  const header = `## Google Campaigns (${rows.length} found)
1092
1178
 
1093
1179
  `;
1094
- const tableHeader = "| Campaign | Status | Type | Bid Strategy | Target | Daily Budget | App |\n|---|---|---|---|---|---|---|\n";
1180
+ const tableHeader = "| ID | Campaign | Status | Type | Bid Strategy | Target | Daily Budget | App |\n|---|---|---|---|---|---|---|---|\n";
1095
1181
  let tableRows = "";
1096
1182
  for (const row of rows) {
1097
1183
  const c = row.campaign;
@@ -1102,7 +1188,7 @@ function registerGetGoogleAdsCampaigns(server2) {
1102
1188
  const appId = c.appCampaignSetting?.appId ?? "";
1103
1189
  const store = appStoreLabel(c.appCampaignSetting?.appStore);
1104
1190
  const appDisplay = appId ? `${appId} (${store})` : "\u2014";
1105
- tableRows += `| ${c.name} | ${c.status} | ${channelSubTypeLabel(c.advertisingChannelSubType)} | ${c.biddingStrategyType ?? "\u2014"} | ${formatTarget(row)} | ${budgetDisplay} | ${appDisplay} |
1191
+ tableRows += `| ${c.id} | ${c.name} | ${c.status} | ${channelSubTypeLabel(c.advertisingChannelSubType)} | ${c.biddingStrategyType ?? "\u2014"} | ${formatTarget(row)} | ${budgetDisplay} | ${appDisplay} |
1106
1192
  `;
1107
1193
  }
1108
1194
  return {
@@ -1143,8 +1229,9 @@ function registerGetGoogleAdsAdGroups(server2) {
1143
1229
  let whereClause = `WHERE campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT')
1144
1230
  AND ad_group.status IN (${statusList})`;
1145
1231
  if (campaign_id) {
1232
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1146
1233
  whereClause += `
1147
- AND campaign.id = ${campaign_id}`;
1234
+ AND campaign.id = ${resolvedCampaignId}`;
1148
1235
  }
1149
1236
  const query = `
1150
1237
  SELECT
@@ -1175,13 +1262,13 @@ function registerGetGoogleAdsAdGroups(server2) {
1175
1262
  const header = `## Ad Groups${campaignName} (${rows.length} found)
1176
1263
 
1177
1264
  `;
1178
- const tableHeader = "| Ad Group | Status | Type | Campaign |\n|---|---|---|---|\n";
1265
+ const tableHeader = "| Ad Group ID | Ad Group | Status | Type | Campaign ID | Campaign |\n|---|---|---|---|---|---|\n";
1179
1266
  let tableRows = "";
1180
1267
  for (const row of rows) {
1181
1268
  const ag = row.adGroup;
1182
1269
  const c = row.campaign;
1183
1270
  if (!ag) continue;
1184
- tableRows += `| ${ag.name} | ${ag.status ?? "\u2014"} | ${ag.type ?? "\u2014"} | ${c?.name ?? "\u2014"} |
1271
+ tableRows += `| ${ag.id} | ${ag.name} | ${ag.status ?? "\u2014"} | ${ag.type ?? "\u2014"} | ${c?.id ?? "\u2014"} | ${c?.name ?? "\u2014"} |
1185
1272
  `;
1186
1273
  }
1187
1274
  return {
@@ -1236,8 +1323,14 @@ function registerGetGoogleAdsAssets(server2) {
1236
1323
  const conditions = [
1237
1324
  "campaign.status = 'ENABLED'"
1238
1325
  ];
1239
- if (campaign_id) conditions.push(`campaign.id = ${campaign_id}`);
1240
- if (ad_group_id) conditions.push(`ad_group.id = ${ad_group_id}`);
1326
+ if (campaign_id) {
1327
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1328
+ conditions.push(`campaign.id = ${resolvedCampaignId}`);
1329
+ }
1330
+ if (ad_group_id) {
1331
+ const resolvedAdGroupId = await resolveAdGroupId(customer_id, ad_group_id, campaign_id);
1332
+ conditions.push(`ad_group.id = ${resolvedAdGroupId}`);
1333
+ }
1241
1334
  if (asset_type?.length) {
1242
1335
  const typeList = asset_type.map((t) => `'${t}'`).join(", ");
1243
1336
  conditions.push(`asset.type IN (${typeList})`);
@@ -1531,8 +1624,14 @@ function registerGetGoogleAdsInsights(server2) {
1531
1624
  "campaign.status = 'ENABLED'",
1532
1625
  `segments.date BETWEEN '${startDate}' AND '${endDate}'`
1533
1626
  ];
1534
- if (campaign_id) conditions.push(`campaign.id = ${campaign_id}`);
1535
- if (ad_group_id) conditions.push(`ad_group.id = ${ad_group_id}`);
1627
+ if (campaign_id) {
1628
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1629
+ conditions.push(`campaign.id = ${resolvedCampaignId}`);
1630
+ }
1631
+ if (ad_group_id) {
1632
+ const resolvedAdGroupId = await resolveAdGroupId(customer_id, ad_group_id, campaign_id);
1633
+ conditions.push(`ad_group.id = ${resolvedAdGroupId}`);
1634
+ }
1536
1635
  const orderBy = `ORDER BY ${sortKeyForEnum(sortField)} DESC`;
1537
1636
  const query = `
1538
1637
  SELECT ${selectFields.join(", ")}
@@ -1677,7 +1776,10 @@ function registerGetGoogleAdsNetworkMix(server2) {
1677
1776
  "campaign.status = 'ENABLED'",
1678
1777
  `segments.date BETWEEN '${startDate}' AND '${endDate}'`
1679
1778
  ];
1680
- if (campaign_id) conditions.push(`campaign.id = ${campaign_id}`);
1779
+ if (campaign_id) {
1780
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1781
+ conditions.push(`campaign.id = ${resolvedCampaignId}`);
1782
+ }
1681
1783
  const query = `
1682
1784
  SELECT
1683
1785
  campaign.id,
@@ -1887,11 +1989,15 @@ function registerGetGoogleAdsAssetFatigue(server2) {
1887
1989
  startDate.setDate(startDate.getDate() - lookback + 1);
1888
1990
  const ageStartDate = new Date(endDate);
1889
1991
  ageStartDate.setDate(ageStartDate.getDate() - 89);
1992
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1890
1993
  const conditions = [
1891
- `campaign.id = ${campaign_id}`,
1994
+ `campaign.id = ${resolvedCampaignId}`,
1892
1995
  `segments.date BETWEEN '${dateFmt(startDate)}' AND '${dateFmt(endDate)}'`
1893
1996
  ];
1894
- if (ad_group_id) conditions.push(`ad_group.id = ${ad_group_id}`);
1997
+ if (ad_group_id) {
1998
+ const resolvedAdGroupId = await resolveAdGroupId(customer_id, ad_group_id, resolvedCampaignId);
1999
+ conditions.push(`ad_group.id = ${resolvedAdGroupId}`);
2000
+ }
1895
2001
  if (asset_type?.length) {
1896
2002
  const typeList = asset_type.map((t) => `'${t}'`).join(", ");
1897
2003
  conditions.push(`asset.type IN (${typeList})`);
@@ -2121,6 +2227,168 @@ Sources: goog-pdf-018, ab-pt-008, goog-pdf-019, ab-pt-007
2121
2227
  );
2122
2228
  }
2123
2229
 
2230
+ // src/tools/google-upload-assets.ts
2231
+ import { z as z13 } from "zod";
2232
+ import { readFile } from "fs/promises";
2233
+ import { basename, resolve } from "path";
2234
+ async function fetchAsBase64(url) {
2235
+ if (url.startsWith("data:")) {
2236
+ const commaIdx = url.indexOf(",");
2237
+ if (commaIdx === -1) throw new Error("Invalid data URI");
2238
+ return url.slice(commaIdx + 1);
2239
+ }
2240
+ const res = await fetch(url);
2241
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
2242
+ const buf = Buffer.from(await res.arrayBuffer());
2243
+ return buf.toString("base64");
2244
+ }
2245
+ async function readFileAsBase64(filePath) {
2246
+ const buf = await readFile(resolve(filePath));
2247
+ return buf.toString("base64");
2248
+ }
2249
+ function registerUploadGoogleImageAssets(server2) {
2250
+ server2.tool(
2251
+ "upload_google_image_assets",
2252
+ "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
+ {
2254
+ customer_id: z13.string().describe("Google Ads customer ID"),
2255
+ images: z13.array(
2256
+ z13.object({
2257
+ source: z13.string().describe(
2258
+ "Image URL (https://...) or local file path (/path/to/image.png)"
2259
+ ),
2260
+ name: z13.string().optional().describe(
2261
+ "Asset name in Google Ads (default: filename from source)"
2262
+ )
2263
+ })
2264
+ ).min(1).max(50).describe("Array of images to upload (max 50 per call)"),
2265
+ campaign_id: z13.string().optional().describe(
2266
+ "Link uploaded assets to this campaign (creates CampaignAsset links)"
2267
+ ),
2268
+ ad_group_id: z13.string().optional().describe(
2269
+ "Link uploaded assets to this ad group (creates AdGroupAsset links). Takes priority over campaign_id."
2270
+ ),
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")
2273
+ },
2274
+ async ({
2275
+ customer_id,
2276
+ images,
2277
+ campaign_id,
2278
+ ad_group_id,
2279
+ field_type,
2280
+ dry_run
2281
+ }) => {
2282
+ try {
2283
+ const normalizedId = normalizeCustomerId(customer_id);
2284
+ const effectiveFieldType = field_type ?? "IMAGE";
2285
+ if (dry_run) {
2286
+ let text2 = `**Dry run** \u2014 would upload ${images.length} image(s) to customer ${customer_id}
2287
+
2288
+ `;
2289
+ for (const img of images) {
2290
+ const name = img.name ?? basename(img.source).replace(/\?.*$/, "") ?? "unnamed";
2291
+ text2 += `- ${name} \u2190 ${img.source}
2292
+ `;
2293
+ }
2294
+ if (ad_group_id) {
2295
+ text2 += `
2296
+ Would link to ad group ${ad_group_id} as ${effectiveFieldType}`;
2297
+ } else if (campaign_id) {
2298
+ text2 += `
2299
+ Would link to campaign ${campaign_id} as ${effectiveFieldType}`;
2300
+ }
2301
+ return { content: [{ type: "text", text: text2 }] };
2302
+ }
2303
+ const assetOps = [];
2304
+ const assetNames = [];
2305
+ for (const img of images) {
2306
+ const name = img.name ?? basename(img.source).replace(/\?.*$/, "") ?? "unnamed";
2307
+ assetNames.push(name);
2308
+ const isUrl = img.source.startsWith("http://") || img.source.startsWith("https://") || img.source.startsWith("data:");
2309
+ const data = isUrl ? await fetchAsBase64(img.source) : await readFileAsBase64(img.source);
2310
+ assetOps.push({
2311
+ assetOperation: {
2312
+ create: {
2313
+ name,
2314
+ type: "IMAGE",
2315
+ imageAsset: { data }
2316
+ }
2317
+ }
2318
+ });
2319
+ }
2320
+ const assetResult = await googleAdsMutate(normalizedId, assetOps);
2321
+ const createdResourceNames = assetResult.mutateOperationResponses.map(
2322
+ (r) => r.assetResult?.resourceName ?? ""
2323
+ );
2324
+ const successCount = createdResourceNames.filter(Boolean).length;
2325
+ let text = `**Uploaded ${successCount}/${images.length} image assets**
2326
+
2327
+ `;
2328
+ for (let i = 0; i < images.length; i++) {
2329
+ const rn = createdResourceNames[i];
2330
+ if (rn) {
2331
+ text += `\u2713 ${assetNames[i]} \u2192 ${rn}
2332
+ `;
2333
+ } else {
2334
+ text += `\u2717 ${assetNames[i]} \u2014 failed
2335
+ `;
2336
+ }
2337
+ }
2338
+ if (successCount > 0 && (ad_group_id || campaign_id)) {
2339
+ const linkOps = [];
2340
+ for (const resourceName of createdResourceNames) {
2341
+ if (!resourceName) continue;
2342
+ if (ad_group_id) {
2343
+ linkOps.push({
2344
+ adGroupAssetOperation: {
2345
+ create: {
2346
+ adGroup: `customers/${normalizedId}/adGroups/${ad_group_id}`,
2347
+ asset: resourceName,
2348
+ fieldType: effectiveFieldType
2349
+ }
2350
+ }
2351
+ });
2352
+ } else if (campaign_id) {
2353
+ linkOps.push({
2354
+ campaignAssetOperation: {
2355
+ create: {
2356
+ campaign: `customers/${normalizedId}/campaigns/${campaign_id}`,
2357
+ asset: resourceName,
2358
+ fieldType: effectiveFieldType
2359
+ }
2360
+ }
2361
+ });
2362
+ }
2363
+ }
2364
+ try {
2365
+ await googleAdsMutate(normalizedId, linkOps);
2366
+ const linkTarget = ad_group_id ? `ad group ${ad_group_id}` : `campaign ${campaign_id}`;
2367
+ text += `
2368
+ \u2713 Linked ${successCount} assets to ${linkTarget} as ${effectiveFieldType}`;
2369
+ } catch (linkErr) {
2370
+ text += `
2371
+ \u26A0\uFE0F Assets uploaded but linking failed: ${linkErr instanceof Error ? linkErr.message : String(linkErr)}`;
2372
+ text += `
2373
+ Assets exist in the account and can be linked manually.`;
2374
+ }
2375
+ }
2376
+ return { content: [{ type: "text", text }] };
2377
+ } catch (err) {
2378
+ return {
2379
+ content: [
2380
+ {
2381
+ type: "text",
2382
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
2383
+ }
2384
+ ],
2385
+ isError: true
2386
+ };
2387
+ }
2388
+ }
2389
+ );
2390
+ }
2391
+
2124
2392
  // src/tools/connection-status.ts
2125
2393
  function registerConnectionStatus(server2, status2) {
2126
2394
  server2.tool(
@@ -2156,10 +2424,11 @@ function registerConnectionStatus(server2, status2) {
2156
2424
  );
2157
2425
  } else {
2158
2426
  lines.push(
2159
- "## Meta Marketing API: Not Configured",
2160
- "- Meta tools will return an error when called",
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",
2161
2430
  "",
2162
- "### How to fix",
2431
+ "### How to connect",
2163
2432
  "Provide your Meta access token using one of these methods:",
2164
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)',
2165
2434
  "2. CLI argument: add `--meta-token=...` to the args array",
@@ -2176,11 +2445,11 @@ function registerConnectionStatus(server2, status2) {
2176
2445
  );
2177
2446
  } else {
2178
2447
  lines.push(
2179
- "## Google Ads API: Not Configured",
2180
- "- Google Ads tools will return an error when called",
2181
- `- Missing: ${status2.google.missing.join(", ")}`,
2448
+ "## Google Ads API: Not Connected (Optional)",
2449
+ "- KB, suggestions, and private insights work without it",
2450
+ "- Connect Google Ads to unlock campaign data and network analysis",
2182
2451
  "",
2183
- "### How to fix",
2452
+ "### How to connect",
2184
2453
  "Option 1 \u2014 Interactive setup (recommended):",
2185
2454
  "```",
2186
2455
  "npx mobile-growth-mcp auth google",
@@ -2314,35 +2583,40 @@ function registerVocabularyResource(server2) {
2314
2583
  }
2315
2584
 
2316
2585
  // src/resources/instructions.ts
2317
- var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Meta Ad Tools
2586
+ var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Ad Platform Tools
2318
2587
 
2319
2588
  ## Welcome
2320
2589
 
2321
- You're connected to the Mobile Growth knowledge base \u2014 curated expert insights on mobile advertising, campaign optimization, and subscription app growth.
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.
2322
2591
 
2323
2592
  **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.
2324
2593
 
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.
2595
+
2325
2596
  Quick examples:
2326
2597
  - "subscription app creative fatigue signals"
2327
2598
  - "Meta CBO vs ABO tradeoffs for subscription apps"
2599
+ - "Google UAC network shift detection"
2328
2600
  - "iOS attribution strategies post-ATT"
2329
2601
 
2330
2602
  If you can't find what you need, call \`submit_feedback\` to report the gap \u2014 it helps us improve the knowledge base.
2331
2603
 
2332
2604
  ## What This Is
2333
- A curated knowledge base of mobile advertising insights + direct Meta Marketing API integration. Query expert knowledge, pull live campaign data, and run pre-built reports \u2014 all from your LLM.
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.
2606
+
2607
+ ---
2334
2608
 
2335
2609
  ## Knowledge Base Tools
2336
2610
 
2337
2611
  ### search_insights
2338
- Semantic + keyword hybrid search across the knowledge base.
2612
+ Semantic + keyword hybrid search across the knowledge base. **Use this first** for any question about mobile advertising before searching the web.
2339
2613
  - **query** (required): Natural language search query
2340
2614
  - **topics** (optional): Filter by topic tags, e.g. ["creative_strategy", "scaling"]
2341
2615
  - **applies_to** (optional): Filter by applicability, e.g. ["subscription_apps", "ios"]
2342
2616
  - **limit** (optional): Max results, 1-30, default 10
2343
2617
 
2344
2618
  ### list_insights
2345
- Browse all insights with optional filtering. Returns titles and metadata.
2619
+ Browse all insights with optional filtering. Returns titles and metadata. Private insights are marked with a lock icon.
2346
2620
  - **topic** (optional): Filter by a single topic tag
2347
2621
  - **applies_to** (optional): Filter by a single applies_to value
2348
2622
 
@@ -2350,12 +2624,29 @@ Browse all insights with optional filtering. Returns titles and metadata.
2350
2624
  Fetch the full content of a specific insight by ID or slug.
2351
2625
  - **id** (required): Numeric ID or string slug (e.g. "mb-li-001")
2352
2626
 
2627
+ ### get_vocabulary_counts
2628
+ Returns counts of how many insights use each topic and applies_to tag. Lightweight way to explore what's in the KB.
2629
+
2353
2630
  ### submit_feedback
2354
- Report a gap in the knowledge base or a missing capability. Helps improve the product.
2355
- - **category** (required): missing_knowledge, missing_feature, search_quality, or other
2356
- - **summary** (required): What was needed but not available (anonymized \u2014 no account IDs or tokens)
2631
+ Report a gap in the knowledge base, a bug in any tool, or a missing capability.
2632
+ - **category** (required): missing_knowledge, missing_feature, search_quality, bug_report, or other
2633
+ - **summary** (required): What was needed or what went wrong (anonymized \u2014 no account IDs or tokens)
2357
2634
  - **search_queries_tried** (optional): Search queries that returned poor/no results
2358
2635
 
2636
+ ## Community Knowledge
2637
+
2638
+ ### suggest_insight \u2B50 DEFAULT for saving knowledge
2639
+ Submit knowledge for admin review. **This is the default tool when a user wants to save an insight** \u2014 it contributes to the shared knowledge base that benefits all users. Extract as much structured data as possible from the source material \u2014 full insight schema (title, insight text, source metadata, topics, actionable steps). Once approved, it's added to the shared knowledge base.
2640
+ - Use this when the user shares an article, post, or discussion with valuable mobile growth knowledge
2641
+ - Keep raw_excerpt concise (under 500 chars) for reliability
2642
+
2643
+ ### save_private_insight
2644
+ Save knowledge that is private to your API key. Immediately searchable but only visible to you. **Only use this instead of suggest_insight when** the content contains client-specific data, internal benchmarks, account metrics, or the user explicitly asks for private storage.
2645
+ - Same full schema as suggest_insight
2646
+ - No admin approval needed \u2014 saved instantly
2647
+
2648
+ ---
2649
+
2359
2650
  ## Meta Marketing API Tools
2360
2651
 
2361
2652
  **Requires META_ACCESS_TOKEN env var** \u2014 without it, these tools return a clear error. Knowledge base tools work with just API_KEY.
@@ -2380,8 +2671,10 @@ List ads, optionally scoped to an ad set.
2380
2671
  - **fields, effective_status, limit, after** (optional)
2381
2672
 
2382
2673
  ### get_meta_insights
2383
- Pull performance insights with configurable level, breakdowns, date range.
2674
+ Pull performance insights with configurable level, breakdowns, date range. Ad-level queries auto-include ad_id, ad_name, adset_id, adset_name.
2384
2675
  - **ad_account_id** (required)
2676
+ - **campaign_id** (optional): Scope to a specific campaign
2677
+ - **adset_id** (optional): Scope to a specific ad set
2385
2678
  - **level** (optional): account, campaign, adset, ad (default: campaign)
2386
2679
  - **date_preset** (optional): default last_7d
2387
2680
  - **time_range** (optional): {since, until} for custom dates
@@ -2399,33 +2692,87 @@ Built-in report: detect creative fatigue via frequency, CTR decline, CPA trends.
2399
2692
  - **frequency_critical** (optional): default 5
2400
2693
  - **ctr_decline_threshold** (optional): default 30%
2401
2694
 
2695
+ ---
2696
+
2402
2697
  ## Google Ads Tools
2403
2698
 
2404
- **Requires Google Ads credentials** \u2014 run \`npx mobile-growth-mcp auth google\` to set up interactively. This walks you through developer token, OAuth app, and authorization. Credentials are saved to \`.env\` and never leave the user's machine.
2699
+ **Requires Google Ads credentials** \u2014 run \`npx mobile-growth-mcp auth google\` to set up interactively. Credentials are saved to \`.env\` and never leave the user's machine.
2700
+
2701
+ All tools accept campaign and ad group IDs as either **numeric IDs** or **campaign/ad group names** \u2014 names are auto-resolved to numeric IDs internally.
2405
2702
 
2406
2703
  ### get_google_ads_campaigns
2407
- List campaigns from a Google Ads account with key metrics (last 7 days).
2704
+ List Google App Campaigns with status, bid strategy, budgets, and app info. Returns **numeric campaign IDs** needed by other tools.
2408
2705
  - **customer_id** (required): Google Ads customer ID (e.g. "123-456-7890")
2409
- - **status** (optional): Filter by status \u2014 ENABLED, PAUSED, or REMOVED (default: ENABLED)
2706
+ - **status** (optional): Filter by status \u2014 ENABLED, PAUSED, REMOVED (default: ["ENABLED"])
2707
+ - **channel_sub_type** (optional): Filter by campaign type \u2014 APP_CAMPAIGN (ACi), APP_CAMPAIGN_FOR_ENGAGEMENT (ACe)
2410
2708
  - **limit** (optional): Max campaigns to return (default 50)
2411
2709
 
2710
+ ### get_google_ad_groups
2711
+ List ad groups within Google App Campaigns. Returns **numeric ad group IDs** and campaign IDs. In UAC, ad groups represent creative themes \u2014 observe spend distribution to identify winning messaging angles.
2712
+ - **customer_id** (required): Google Ads customer ID
2713
+ - **campaign_id** (optional): Scope to a specific campaign (name or numeric ID)
2714
+ - **status** (optional): Filter by status (default: ["ENABLED"])
2715
+ - **limit** (optional): Max results (default 50)
2716
+
2717
+ ### get_google_insights
2718
+ Pull performance metrics 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.
2719
+ - **customer_id** (required): Google Ads customer ID
2720
+ - **level** (optional): account, campaign, ad_group, asset (default: campaign)
2721
+ - **campaign_id** (optional): Scope to specific campaign (name or numeric ID)
2722
+ - **ad_group_id** (optional): Scope to specific ad group
2723
+ - **breakdown** (optional): network or device (one at a time \u2014 GAQL restriction)
2724
+ - **date_range** (optional): {start_date, end_date} in YYYY-MM-DD
2725
+ - **date_preset** (optional): LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH
2726
+ - **time_increment** (optional): daily, weekly, monthly, summary (default: summary)
2727
+ - **sort** (optional): cost_desc, conversions_desc, impressions_desc, ctr_desc
2728
+ - **limit** (optional): Max results (default 50, max 500)
2729
+
2730
+ ### get_google_assets
2731
+ List creative assets with metadata, performance labels, and slot utilization audit. Checks headlines, descriptions, images, videos against Google's per-slot maximums.
2732
+ - **customer_id** (required): Google Ads customer ID
2733
+ - **campaign_id** (optional): Scope to a specific campaign (name or numeric ID)
2734
+ - **ad_group_id** (optional): Scope to a specific ad group
2735
+ - **asset_type** (optional): IMAGE, YOUTUBE_VIDEO, TEXT, MEDIA_BUNDLE
2736
+ - **include_slot_audit** (optional): default true
2737
+ - **limit** (optional): Max results (default 50)
2738
+
2739
+ ### get_google_network_mix
2740
+ Analyze traffic distribution across Google's ad networks (Search, Display/AdMob, YouTube) over time. Flags significant shifts in spend share \u2014 a sudden shift to Display/MGDN typically tanks CPA.
2741
+ - **customer_id** (required): Google Ads customer ID
2742
+ - **campaign_id** (optional): Scope to one campaign (name or numeric ID)
2743
+ - **date_range** (optional): {start_date, end_date} \u2014 default last 14 days
2744
+ - **shift_threshold_pct** (optional): Flag networks with spend share change > this % (default 10)
2745
+
2746
+ ### get_google_asset_fatigue
2747
+ Detect creative asset fatigue by analyzing per-asset impression trends, CTR decline, and CPA deterioration. Checks asset age against Google's 2-week learning minimum and 2-3 month refresh cadence.
2748
+ - **customer_id** (required): Google Ads customer ID
2749
+ - **campaign_id** (required): Campaign to analyze (name or numeric ID)
2750
+ - **ad_group_id** (optional): Scope to specific ad group
2751
+ - **lookback_days** (optional): Days of data to analyze, 7-90 (default 14)
2752
+ - **ctr_decline_threshold_pct** (optional): CTR decline % to flag (default 30)
2753
+ - **impression_decay_threshold_pct** (optional): Impression drop % to flag (default 50)
2754
+ - **asset_type** (optional): IMAGE, YOUTUBE_VIDEO, TEXT
2755
+
2756
+ ---
2757
+
2412
2758
  ## Reports (MCP Prompts)
2413
2759
 
2414
- Pre-built analysis workflows. Select a prompt and provide your ad_account_id to run:
2760
+ Pre-built analysis workflows for Meta accounts. Select a prompt and provide your ad_account_id to run:
2415
2761
 
2416
2762
  | Prompt | What it does | API calls |
2417
2763
  |--------|-------------|-----------|
2418
2764
  | ad-fatigue-report | Detect creative fatigue with daily granularity | 1 |
2419
2765
  | weekly-performance | Week-over-week health comparison with diagnosis | 2 |
2420
2766
  | creative-performance | Categorize ads by health status | 1 |
2421
- | placement-efficiency | Identify placement waste and savings | 1 per campaign |
2422
- | audience-composition | Age \xD7 gender heatmap with CPA analysis | 1-2 |
2767
+ | audience-composition | Age x gender heatmap with CPA analysis | 1-2 |
2423
2768
  | architecture-review | Campaign structure evaluation | 3 (no insights) |
2424
2769
  | audit-meta-account | Comprehensive account audit | 6+ |
2425
2770
  | campaign-comparison | Side-by-side campaign comparison | 3+ |
2426
2771
  | placement-audit | Detailed placement audit with examples | 1 per campaign |
2427
2772
  | attribution-analysis | Conversion quality validation | 2+ |
2428
2773
 
2774
+ ---
2775
+
2429
2776
  ## Resources
2430
2777
 
2431
2778
  ### vocabulary://tags
@@ -2435,17 +2782,31 @@ Lists all topic tags, applies_to tags, and platforms with counts.
2435
2782
 
2436
2783
  When your response draws on knowledge base results, **always attribute visibly** so the user knows the value came from the curated KB, not your general training data:
2437
2784
 
2438
- - **Tell the user** the information comes from the Mobile Growth knowledge base (e.g. "According to the Mobile Growth KB\u2026" or "The knowledge base recommends\u2026")
2439
- - **Cite source author + slug** for key claims (e.g. "\u2026(source: Eric Seufert, \`mb-li-001\`)")
2440
- - **When multiple insights support a recommendation**, mention the count (e.g. "3 insights in the KB agree that\u2026")
2441
- - **Distinguish KB-sourced advice from your own reasoning** \u2014 if you're adding your own analysis on top of KB results, make that clear (e.g. "The KB covers X; based on that, my suggestion is Y")
2442
-
2443
- ## Tips
2444
- - Start with \`list_insights\` to see what's in the knowledge base
2445
- - Use \`search_insights\` to find specific advice grounded in expert knowledge
2446
- - Meta tools default to safe parameters (last_7d, active-only) to avoid rate limits
2447
- - Reports reference specific knowledge base insight IDs \u2014 use \`get_insight\` to read the full context
2448
- - For custom date ranges, use time_range instead of date_preset
2785
+ - **Tell the user** the information comes from the Mobile Growth knowledge base (e.g. "According to the Mobile Growth KB..." or "The knowledge base recommends...")
2786
+ - **Cite source author + slug** for key claims (e.g. "...(source: Eric Seufert, \`mb-li-001\`)")
2787
+ - **When multiple insights support a recommendation**, mention the count (e.g. "3 insights in the KB agree that...")
2788
+ - **Distinguish KB-sourced advice from your own reasoning** \u2014 if you're adding your own analysis on top of KB results, make that clear
2789
+
2790
+ ## Workflow Tips
2791
+
2792
+ ### For Google Ads analysis:
2793
+ 1. Start with \`get_google_ads_campaigns\` to see campaigns and get numeric IDs
2794
+ 2. Use \`get_google_insights\` with network breakdown to check traffic distribution
2795
+ 3. Use \`get_google_network_mix\` if you suspect network shifts
2796
+ 4. Use \`get_google_assets\` to audit creative slot utilization
2797
+ 5. Use \`get_google_asset_fatigue\` on specific campaigns to detect creative decay
2798
+
2799
+ ### 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
2803
+
2804
+ ### General:
2805
+ - Always \`search_insights\` before making recommendations \u2014 ground advice in expert knowledge
2806
+ - Use \`get_insight\` to read full context when reports reference insight IDs
2807
+ - Use \`suggest_insight\` (default) when a user shares valuable knowledge from articles or discussions
2808
+ - Use \`save_private_insight\` only for client-specific data the user explicitly wants private
2809
+ - If a tool errors unexpectedly, call \`submit_feedback\` with category \`bug_report\`
2449
2810
  `;
2450
2811
  function buildStatusSection(status2) {
2451
2812
  if (!status2) return "";
@@ -2466,20 +2827,20 @@ function buildStatusSection(status2) {
2466
2827
  lines.push("- **Meta Marketing API**: Token configured");
2467
2828
  } else {
2468
2829
  lines.push(
2469
- "- **Meta Marketing API**: Token not configured \u2014 Meta tools will return errors"
2830
+ "- **Meta Marketing API**: Not connected (optional \u2014 KB works without it)"
2470
2831
  );
2471
2832
  lines.push(
2472
- " - Fix: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
2833
+ " - To connect: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
2473
2834
  );
2474
2835
  }
2475
2836
  if (status2.google.configured) {
2476
2837
  lines.push("- **Google Ads API**: Configured");
2477
2838
  } else {
2478
2839
  lines.push(
2479
- "- **Google Ads API**: Not configured \u2014 Google Ads tools will return errors"
2840
+ "- **Google Ads API**: Not connected (optional \u2014 KB works without it)"
2480
2841
  );
2481
2842
  lines.push(
2482
- " - Fix: run `npx mobile-growth-mcp auth google` to set up credentials"
2843
+ " - To connect: run `npx mobile-growth-mcp auth google` to set up credentials"
2483
2844
  );
2484
2845
  }
2485
2846
  return lines.join("\n");
@@ -2546,7 +2907,7 @@ function getDotEnv() {
2546
2907
  }
2547
2908
  return dotEnvCache;
2548
2909
  }
2549
- function resolve(envName, cliName) {
2910
+ function resolve2(envName, cliName) {
2550
2911
  const cli = getCliArg(cliName);
2551
2912
  if (cli) return { value: cli, source: `--${cliName} argument` };
2552
2913
  const env = process.env[envName]?.trim();
@@ -2556,17 +2917,17 @@ function resolve(envName, cliName) {
2556
2917
  return { value: void 0, source: "not configured" };
2557
2918
  }
2558
2919
  function resolveApiKey() {
2559
- return resolve("API_KEY", "api-key");
2920
+ return resolve2("API_KEY", "api-key");
2560
2921
  }
2561
2922
  function resolveMetaToken() {
2562
- return resolve("META_ACCESS_TOKEN", "meta-token");
2923
+ return resolve2("META_ACCESS_TOKEN", "meta-token");
2563
2924
  }
2564
2925
  function resolveGoogleAdsConfig() {
2565
- const devToken = resolve("GOOGLE_ADS_DEVELOPER_TOKEN", "google-dev-token");
2566
- const clientId = resolve("GOOGLE_ADS_CLIENT_ID", "google-client-id");
2567
- const clientSecret = resolve("GOOGLE_ADS_CLIENT_SECRET", "google-client-secret");
2568
- const refreshToken = resolve("GOOGLE_ADS_REFRESH_TOKEN", "google-refresh-token");
2569
- const loginCustomerId = resolve("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "google-login-customer-id");
2926
+ const devToken = resolve2("GOOGLE_ADS_DEVELOPER_TOKEN", "google-dev-token");
2927
+ const clientId = resolve2("GOOGLE_ADS_CLIENT_ID", "google-client-id");
2928
+ const clientSecret = resolve2("GOOGLE_ADS_CLIENT_SECRET", "google-client-secret");
2929
+ const refreshToken = resolve2("GOOGLE_ADS_REFRESH_TOKEN", "google-refresh-token");
2930
+ const loginCustomerId = resolve2("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "google-login-customer-id");
2570
2931
  const required = [
2571
2932
  { name: "GOOGLE_ADS_DEVELOPER_TOKEN", result: devToken },
2572
2933
  { name: "GOOGLE_ADS_CLIENT_ID", result: clientId },
@@ -2609,8 +2970,8 @@ async function maybeRunAuthCommand() {
2609
2970
  process.exit(0);
2610
2971
  }
2611
2972
  function prompt(rl, question) {
2612
- return new Promise((resolve2) => {
2613
- rl.question(question, (answer) => resolve2(answer.trim()));
2973
+ return new Promise((resolve3) => {
2974
+ rl.question(question, (answer) => resolve3(answer.trim()));
2614
2975
  });
2615
2976
  }
2616
2977
  function openBrowser(url) {
@@ -2698,7 +3059,7 @@ function saveToEnv(vars) {
2698
3059
  }
2699
3060
  }
2700
3061
  async function waitForOAuthCallback(clientId, clientSecret) {
2701
- return new Promise((resolve2, reject) => {
3062
+ return new Promise((resolve3, reject) => {
2702
3063
  const server2 = createServer(
2703
3064
  async (req, res) => {
2704
3065
  const url = new URL(req.url ?? "/", `http://localhost:${OAUTH_PORT}`);
@@ -2753,7 +3114,7 @@ async function waitForOAuthCallback(clientId, clientSecret) {
2753
3114
  "<html><body><h2>Success!</h2><p>You can close this tab and return to the terminal.</p></body></html>"
2754
3115
  );
2755
3116
  server2.close();
2756
- resolve2({ refreshToken: tokenData.refresh_token });
3117
+ resolve3({ refreshToken: tokenData.refresh_token });
2757
3118
  } catch (err) {
2758
3119
  res.writeHead(500, { "Content-Type": "text/html" });
2759
3120
  res.end(
@@ -2788,7 +3149,7 @@ async function runGoogleAuthFlow() {
2788
3149
  });
2789
3150
  try {
2790
3151
  console.log("Step 1: Developer token");
2791
- console.log(" (Found in Google Ads \u2192 Tools \u2192 API Center)\n");
3152
+ console.log(" (Found in Google Ads \u2192 Admin \u2192 API Center, or https://ads.google.com/aw/apicenter)\n");
2792
3153
  const developerToken = await prompt(rl, " Developer token: ");
2793
3154
  if (!developerToken) {
2794
3155
  console.error("\n Developer token is required.");
@@ -2876,10 +3237,10 @@ console.error(
2876
3237
  apiKey ? `API key: ${apiKeyResult.source}` : "API key: not configured \u2014 KB tools will not be available"
2877
3238
  );
2878
3239
  console.error(
2879
- metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured \u2014 Meta tools will return errors when called"
3240
+ metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured (optional \u2014 KB works without it)"
2880
3241
  );
2881
3242
  console.error(
2882
- googleAdsResult.configured ? `Google Ads: configured` : `Google Ads: not configured (missing: ${googleAdsResult.missing.join(", ")}) \u2014 run \`npx mobile-growth-mcp auth google\` to set up`
3243
+ 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`
2883
3244
  );
2884
3245
  var server = new McpServer({
2885
3246
  name: "mobile-growth-mcp",
@@ -2921,6 +3282,7 @@ registerGetGoogleAdsAssets(server);
2921
3282
  registerGetGoogleAdsInsights(server);
2922
3283
  registerGetGoogleAdsNetworkMix(server);
2923
3284
  registerGetGoogleAdsAssetFatigue(server);
3285
+ registerUploadGoogleImageAssets(server);
2924
3286
  registerConnectionStatus(server, status);
2925
3287
  registerVocabularyResource(server);
2926
3288
  registerInstructionsResource(server, status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobile-growth-mcp",
3
- "version": "2.2.1",
3
+ "version": "2.3.4",
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",