mobile-growth-mcp 2.1.2 → 2.2.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 +1321 -81
  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
  }
@@ -643,15 +658,22 @@ function registerGetMetaAdFatigue(server2) {
643
658
  const freqWarn = frequency_warning ?? 3;
644
659
  const freqCrit = frequency_critical ?? 5;
645
660
  const ctrThreshold = ctr_decline_threshold ?? 30;
646
- const parentPath = campaign_id ? `/${campaign_id}/insights` : `/${ad_account_id}/insights`;
661
+ const filtering = JSON.parse(activeFilter());
662
+ if (campaign_id) {
663
+ filtering.push({
664
+ field: "campaign.id",
665
+ operator: "EQUAL",
666
+ value: campaign_id
667
+ });
668
+ }
647
669
  const result = await metaApiGet({
648
- path: parentPath,
670
+ path: `/${ad_account_id}/insights`,
649
671
  params: {
650
672
  level: "ad",
651
673
  time_increment: "1",
652
674
  fields: "ad_id,ad_name,spend,impressions,clicks,ctr,cpm,frequency,actions,cost_per_action_type",
653
675
  date_preset: "last_7d",
654
- filtering: activeFilter(),
676
+ filtering: JSON.stringify(filtering),
655
677
  limit: "500"
656
678
  }
657
679
  });
@@ -950,37 +972,140 @@ async function googleAdsQuery(customerId, query) {
950
972
  return [body];
951
973
  }
952
974
 
975
+ // src/google/format.ts
976
+ async function resolveCampaignId(customerId, value) {
977
+ if (/^\d+$/.test(value)) return value;
978
+ const query = `SELECT campaign.id, campaign.name FROM campaign WHERE campaign.name = '${value.replace(/'/g, "\\'")}' LIMIT 1`;
979
+ const chunks = await googleAdsQuery(customerId, query);
980
+ const rows = chunks.flatMap((c) => c.results ?? []);
981
+ if (rows.length === 0) throw new Error(`Campaign not found by name: "${value}"`);
982
+ return rows[0].campaign.id;
983
+ }
984
+ async function resolveAdGroupId(customerId, value, campaignId) {
985
+ if (/^\d+$/.test(value)) return value;
986
+ let query = `SELECT ad_group.id, ad_group.name FROM ad_group WHERE ad_group.name = '${value.replace(/'/g, "\\'")}' LIMIT 1`;
987
+ if (campaignId) query = query.replace("LIMIT 1", `AND campaign.id = ${campaignId} LIMIT 1`);
988
+ const chunks = await googleAdsQuery(customerId, query);
989
+ const rows = chunks.flatMap((c) => c.results ?? []);
990
+ if (rows.length === 0) throw new Error(`Ad group not found by name: "${value}"`);
991
+ return rows[0].adGroup.id;
992
+ }
993
+ function formatMicros(micros) {
994
+ if (!micros) return "\u2014";
995
+ const val = parseInt(micros, 10);
996
+ if (isNaN(val)) return "\u2014";
997
+ return `$${(val / 1e6).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
998
+ }
999
+ function formatPct(value) {
1000
+ return `${value.toFixed(2)}%`;
1001
+ }
1002
+ function formatCompact(value) {
1003
+ if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
1004
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
1005
+ return value.toString();
1006
+ }
1007
+ function datePresetToRange(preset) {
1008
+ const now = /* @__PURE__ */ new Date();
1009
+ const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1010
+ switch (preset) {
1011
+ case "LAST_7_DAYS": {
1012
+ const end = new Date(now);
1013
+ end.setDate(end.getDate() - 1);
1014
+ const start = new Date(end);
1015
+ start.setDate(start.getDate() - 6);
1016
+ return { start: fmt(start), end: fmt(end) };
1017
+ }
1018
+ case "LAST_14_DAYS": {
1019
+ const end = new Date(now);
1020
+ end.setDate(end.getDate() - 1);
1021
+ const start = new Date(end);
1022
+ start.setDate(start.getDate() - 13);
1023
+ return { start: fmt(start), end: fmt(end) };
1024
+ }
1025
+ case "LAST_30_DAYS": {
1026
+ const end = new Date(now);
1027
+ end.setDate(end.getDate() - 1);
1028
+ const start = new Date(end);
1029
+ start.setDate(start.getDate() - 29);
1030
+ return { start: fmt(start), end: fmt(end) };
1031
+ }
1032
+ case "THIS_MONTH": {
1033
+ const start = new Date(now.getFullYear(), now.getMonth(), 1);
1034
+ const end = new Date(now);
1035
+ end.setDate(end.getDate() - 1);
1036
+ return { start: fmt(start), end: fmt(end) };
1037
+ }
1038
+ case "LAST_MONTH": {
1039
+ const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
1040
+ const end = new Date(now.getFullYear(), now.getMonth(), 0);
1041
+ return { start: fmt(start), end: fmt(end) };
1042
+ }
1043
+ default:
1044
+ return datePresetToRange("LAST_7_DAYS");
1045
+ }
1046
+ }
1047
+
953
1048
  // src/tools/google-campaigns.ts
1049
+ function channelSubTypeLabel(subType) {
1050
+ if (subType === "APP_CAMPAIGN") return "ACi";
1051
+ if (subType === "APP_CAMPAIGN_FOR_ENGAGEMENT") return "ACe";
1052
+ return subType ?? "\u2014";
1053
+ }
1054
+ function appStoreLabel(appStore) {
1055
+ if (appStore === "APPLE_APP_STORE") return "iOS";
1056
+ if (appStore === "GOOGLE_APP_STORE") return "Android";
1057
+ return appStore ?? "";
1058
+ }
1059
+ function formatTarget(row) {
1060
+ const c = row.campaign;
1061
+ if (!c) return "\u2014";
1062
+ if (c.targetCpa?.targetCpaMicros) {
1063
+ return formatMicros(c.targetCpa.targetCpaMicros);
1064
+ }
1065
+ if (c.targetRoas?.targetRoas !== void 0) {
1066
+ return `${(c.targetRoas.targetRoas * 100).toFixed(0)}%`;
1067
+ }
1068
+ return "\u2014";
1069
+ }
954
1070
  function registerGetGoogleAdsCampaigns(server2) {
955
1071
  server2.tool(
956
1072
  "get_google_ads_campaigns",
957
- "List campaigns from a Google Ads account with key metrics. Requires Google Ads credentials \u2014 run `npx mobile-growth-mcp auth google` to set up.",
1073
+ "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.",
958
1074
  {
959
1075
  customer_id: z7.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
960
- status: z7.enum(["ENABLED", "PAUSED", "REMOVED"]).optional().describe("Filter by campaign status. Default: ENABLED"),
961
- limit: z7.number().min(1).max(1e3).optional().describe("Max campaigns to return (default 50)")
1076
+ status: z7.array(z7.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by campaign status. Default: ["ENABLED"]'),
1077
+ channel_sub_type: z7.array(z7.enum(["APP_CAMPAIGN", "APP_CAMPAIGN_FOR_ENGAGEMENT"])).optional().describe('Filter by sub-type. Default: ["APP_CAMPAIGN", "APP_CAMPAIGN_FOR_ENGAGEMENT"]. Set to include all app campaign types'),
1078
+ limit: z7.number().min(1).max(100).optional().describe("Max campaigns to return (default 50)")
962
1079
  },
963
- async ({ customer_id, status: status2, limit }) => {
1080
+ async ({ customer_id, status: status2, channel_sub_type, limit }) => {
964
1081
  try {
965
- const statusFilter = status2 ?? "ENABLED";
1082
+ const statuses = status2 ?? ["ENABLED"];
1083
+ const subTypes = channel_sub_type ?? ["APP_CAMPAIGN", "APP_CAMPAIGN_FOR_ENGAGEMENT"];
966
1084
  const rowLimit = limit ?? 50;
1085
+ const statusList = statuses.map((s) => `'${s}'`).join(", ");
1086
+ const subTypeList = subTypes.map((s) => `'${s}'`).join(", ");
967
1087
  const query = `
968
1088
  SELECT
969
1089
  campaign.id,
970
1090
  campaign.name,
971
1091
  campaign.status,
972
1092
  campaign.advertising_channel_type,
1093
+ campaign.advertising_channel_sub_type,
973
1094
  campaign.bidding_strategy_type,
974
- metrics.impressions,
975
- metrics.clicks,
976
- metrics.cost_micros,
977
- metrics.conversions,
978
- metrics.ctr,
979
- metrics.average_cpc
1095
+ campaign.target_cpa.target_cpa_micros,
1096
+ campaign.target_roas.target_roas,
1097
+ campaign.campaign_budget,
1098
+ campaign_budget.amount_micros,
1099
+ campaign_budget.type,
1100
+ campaign.app_campaign_setting.app_id,
1101
+ campaign.app_campaign_setting.app_store,
1102
+ campaign.app_campaign_setting.bidding_strategy_goal_type,
1103
+ campaign.start_date,
1104
+ campaign.end_date
980
1105
  FROM campaign
981
- WHERE campaign.status = '${statusFilter}'
982
- AND segments.date DURING LAST_7_DAYS
983
- ORDER BY metrics.cost_micros DESC
1106
+ WHERE campaign.advertising_channel_type = 'MULTI_CHANNEL'
1107
+ AND campaign.advertising_channel_sub_type IN (${subTypeList})
1108
+ AND campaign.status IN (${statusList})
984
1109
  LIMIT ${rowLimit}
985
1110
  `;
986
1111
  const chunks = await googleAdsQuery(customer_id, query);
@@ -990,29 +1115,1048 @@ function registerGetGoogleAdsCampaigns(server2) {
990
1115
  content: [
991
1116
  {
992
1117
  type: "text",
993
- text: `No ${statusFilter.toLowerCase()} campaigns found for customer ${customer_id} in the last 7 days.`
1118
+ text: "No campaigns found matching filters. Try expanding status filter to include PAUSED."
994
1119
  }
995
1120
  ]
996
1121
  };
997
1122
  }
998
- let text = `Found ${rows.length} ${statusFilter.toLowerCase()} campaigns (last 7 days):
1123
+ const header = `## Google Campaigns (${rows.length} found)
999
1124
 
1000
1125
  `;
1126
+ const tableHeader = "| ID | Campaign | Status | Type | Bid Strategy | Target | Daily Budget | App |\n|---|---|---|---|---|---|---|---|\n";
1127
+ let tableRows = "";
1001
1128
  for (const row of rows) {
1002
1129
  const c = row.campaign;
1003
- const m = row.metrics;
1004
1130
  if (!c) continue;
1005
- const cost = m?.costMicros ? `$${(parseInt(m.costMicros) / 1e6).toFixed(2)}` : "$0.00";
1006
- const impressions = m?.impressions ?? "0";
1007
- const clicks = m?.clicks ?? "0";
1008
- const conversions = m?.conversions !== void 0 ? m.conversions.toFixed(1) : "0";
1009
- const ctr = m?.ctr !== void 0 ? `${(m.ctr * 100).toFixed(2)}%` : "\u2014";
1010
- const avgCpc = m?.averageCpc ? `$${(parseInt(m.averageCpc) / 1e6).toFixed(2)}` : "\u2014";
1011
- text += `- **${c.name}** (${c.id})
1012
- Status: ${c.status}` + (c.advertisingChannelType ? ` | Channel: ${c.advertisingChannelType}` : "") + (c.biddingStrategyType ? ` | Bidding: ${c.biddingStrategyType}` : "") + `
1013
- Spend: ${cost} | Impr: ${impressions} | Clicks: ${clicks} | CTR: ${ctr} | Avg CPC: ${avgCpc} | Conv: ${conversions}
1131
+ const budget = row.campaignBudget?.amountMicros ? formatMicros(row.campaignBudget.amountMicros) : "\u2014";
1132
+ const budgetType = row.campaignBudget?.type;
1133
+ const budgetDisplay = budgetType === "TOTAL" ? `${budget} (lifetime)` : budget;
1134
+ const appId = c.appCampaignSetting?.appId ?? "";
1135
+ const store = appStoreLabel(c.appCampaignSetting?.appStore);
1136
+ const appDisplay = appId ? `${appId} (${store})` : "\u2014";
1137
+ tableRows += `| ${c.id} | ${c.name} | ${c.status} | ${channelSubTypeLabel(c.advertisingChannelSubType)} | ${c.biddingStrategyType ?? "\u2014"} | ${formatTarget(row)} | ${budgetDisplay} | ${appDisplay} |
1138
+ `;
1139
+ }
1140
+ return {
1141
+ content: [{ type: "text", text: header + tableHeader + tableRows }]
1142
+ };
1143
+ } catch (err) {
1144
+ return {
1145
+ content: [
1146
+ {
1147
+ type: "text",
1148
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1149
+ }
1150
+ ],
1151
+ isError: true
1152
+ };
1153
+ }
1154
+ }
1155
+ );
1156
+ }
1157
+
1158
+ // src/tools/google-ad-groups.ts
1159
+ import { z as z8 } from "zod";
1160
+ function registerGetGoogleAdsAdGroups(server2) {
1161
+ server2.tool(
1162
+ "get_google_ad_groups",
1163
+ "List ad groups within Google App Campaigns. In UAC, ad groups represent creative themes (e.g. 'at_home_workouts'). The algorithm allocates spend across ad groups based on which themes resonate \u2014 observe spend distribution to identify winning messaging angles. To scale, add new ad groups with different text-asset strategies rather than aggressively increasing bids (ab-pt-006).",
1164
+ {
1165
+ customer_id: z8.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
1166
+ campaign_id: z8.string().optional().describe("Scope to a specific campaign. If omitted, returns ad groups across all app campaigns"),
1167
+ status: z8.array(z8.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by ad group status. Default: ["ENABLED"]'),
1168
+ limit: z8.number().min(1).max(100).optional().describe("Max results to return (default 50)")
1169
+ },
1170
+ async ({ customer_id, campaign_id, status: status2, limit }) => {
1171
+ try {
1172
+ const statuses = status2 ?? ["ENABLED"];
1173
+ const rowLimit = limit ?? 50;
1174
+ const statusList = statuses.map((s) => `'${s}'`).join(", ");
1175
+ let whereClause = `WHERE campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT')
1176
+ AND ad_group.status IN (${statusList})`;
1177
+ if (campaign_id) {
1178
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1179
+ whereClause += `
1180
+ AND campaign.id = ${resolvedCampaignId}`;
1181
+ }
1182
+ const query = `
1183
+ SELECT
1184
+ ad_group.id,
1185
+ ad_group.name,
1186
+ ad_group.status,
1187
+ ad_group.type,
1188
+ campaign.id,
1189
+ campaign.name,
1190
+ campaign.status
1191
+ FROM ad_group
1192
+ ${whereClause}
1193
+ LIMIT ${rowLimit}
1194
+ `;
1195
+ const chunks = await googleAdsQuery(customer_id, query);
1196
+ const rows = chunks.flatMap((c) => c.results ?? []);
1197
+ if (rows.length === 0) {
1198
+ return {
1199
+ content: [
1200
+ {
1201
+ type: "text",
1202
+ text: "No ad groups found matching filters. Try expanding status filter to include PAUSED."
1203
+ }
1204
+ ]
1205
+ };
1206
+ }
1207
+ const campaignName = campaign_id && rows[0]?.campaign?.name ? ` for "${rows[0].campaign.name}"` : "";
1208
+ const header = `## Ad Groups${campaignName} (${rows.length} found)
1209
+
1210
+ `;
1211
+ const tableHeader = "| Ad Group ID | Ad Group | Status | Type | Campaign ID | Campaign |\n|---|---|---|---|---|---|\n";
1212
+ let tableRows = "";
1213
+ for (const row of rows) {
1214
+ const ag = row.adGroup;
1215
+ const c = row.campaign;
1216
+ if (!ag) continue;
1217
+ tableRows += `| ${ag.id} | ${ag.name} | ${ag.status ?? "\u2014"} | ${ag.type ?? "\u2014"} | ${c?.id ?? "\u2014"} | ${c?.name ?? "\u2014"} |
1014
1218
  `;
1015
1219
  }
1220
+ return {
1221
+ content: [{ type: "text", text: header + tableHeader + tableRows }]
1222
+ };
1223
+ } catch (err) {
1224
+ return {
1225
+ content: [
1226
+ {
1227
+ type: "text",
1228
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1229
+ }
1230
+ ],
1231
+ isError: true
1232
+ };
1233
+ }
1234
+ }
1235
+ );
1236
+ }
1237
+
1238
+ // src/tools/google-assets.ts
1239
+ import { z as z9 } from "zod";
1240
+ var SLOT_LIMITS = {
1241
+ HEADLINE: 5,
1242
+ DESCRIPTION: 5,
1243
+ IMAGE: 20,
1244
+ YOUTUBE_VIDEO: 20,
1245
+ MEDIA_BUNDLE: 20
1246
+ };
1247
+ function classifyOrientation(w, h) {
1248
+ if (!w || !h) return "unknown";
1249
+ if (w > h * 1.05) return "landscape";
1250
+ if (h > w * 1.05) return "portrait";
1251
+ return "square";
1252
+ }
1253
+ function registerGetGoogleAdsAssets(server2) {
1254
+ server2.tool(
1255
+ "get_google_assets",
1256
+ "List creative assets linked to a campaign or ad group with metadata, performance labels, and slot utilization audit. Google allows up to 5 headlines, 5 descriptions, 20 images, 20 videos, 20 HTML5 per ad group. Missing asset types mean missing inventory channels. Google's built-in asset ratings (Low/Good/Best) measure scalability, not conversion value \u2014 evaluate by your own CPA/ROAS (ab-pt-008).",
1257
+ {
1258
+ customer_id: z9.string().describe("Google Ads customer ID"),
1259
+ campaign_id: z9.string().optional().describe("Scope to a specific campaign"),
1260
+ ad_group_id: z9.string().optional().describe("Scope to a specific ad group"),
1261
+ asset_type: z9.array(z9.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT", "MEDIA_BUNDLE"])).optional().describe("Filter by asset type"),
1262
+ include_slot_audit: z9.boolean().optional().describe("Include slot utilization audit (default true)"),
1263
+ limit: z9.number().min(1).max(500).optional().describe("Max results to return (default 50)")
1264
+ },
1265
+ async ({ customer_id, campaign_id, ad_group_id, asset_type, include_slot_audit, limit }) => {
1266
+ try {
1267
+ const rowLimit = limit ?? 50;
1268
+ const showAudit = include_slot_audit !== false;
1269
+ const conditions = [
1270
+ "campaign.status = 'ENABLED'"
1271
+ ];
1272
+ if (campaign_id) {
1273
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1274
+ conditions.push(`campaign.id = ${resolvedCampaignId}`);
1275
+ }
1276
+ if (ad_group_id) {
1277
+ const resolvedAdGroupId = await resolveAdGroupId(customer_id, ad_group_id, campaign_id);
1278
+ conditions.push(`ad_group.id = ${resolvedAdGroupId}`);
1279
+ }
1280
+ if (asset_type?.length) {
1281
+ const typeList = asset_type.map((t) => `'${t}'`).join(", ");
1282
+ conditions.push(`asset.type IN (${typeList})`);
1283
+ }
1284
+ const query = `
1285
+ SELECT
1286
+ asset.id,
1287
+ asset.name,
1288
+ asset.type,
1289
+ asset.text_asset.text,
1290
+ asset.image_asset.full_size.url,
1291
+ asset.image_asset.full_size.width_pixels,
1292
+ asset.image_asset.full_size.height_pixels,
1293
+ asset.youtube_video_asset.youtube_video_id,
1294
+ asset.youtube_video_asset.youtube_video_title,
1295
+ ad_group_ad_asset_view.field_type,
1296
+ ad_group_ad_asset_view.performance_label,
1297
+ ad_group.id,
1298
+ ad_group.name,
1299
+ campaign.id,
1300
+ campaign.name
1301
+ FROM ad_group_ad_asset_view
1302
+ WHERE ${conditions.join("\n AND ")}
1303
+ LIMIT ${rowLimit}
1304
+ `;
1305
+ const chunks = await googleAdsQuery(customer_id, query);
1306
+ const rows = chunks.flatMap((c) => c.results ?? []);
1307
+ if (rows.length === 0) {
1308
+ return {
1309
+ content: [{
1310
+ type: "text",
1311
+ text: "No assets found matching filters."
1312
+ }]
1313
+ };
1314
+ }
1315
+ const assets = rows.filter((r) => r.asset).map((r) => ({
1316
+ asset: r.asset,
1317
+ fieldType: r.adGroupAdAssetView?.fieldType ?? "\u2014",
1318
+ performanceLabel: r.adGroupAdAssetView?.performanceLabel ?? "\u2014",
1319
+ adGroupName: r.adGroup?.name ?? "\u2014"
1320
+ }));
1321
+ const textAssets = assets.filter((a) => a.asset.type === "TEXT");
1322
+ const imageAssets = assets.filter((a) => a.asset.type === "IMAGE");
1323
+ const videoAssets = assets.filter((a) => a.asset.type === "YOUTUBE_VIDEO");
1324
+ const bundleAssets = assets.filter((a) => a.asset.type === "MEDIA_BUNDLE");
1325
+ const campaignName = rows[0]?.campaign?.name ?? "Unknown Campaign";
1326
+ const adGroupName = ad_group_id && rows[0]?.adGroup?.name ? ` / "${rows[0].adGroup.name}"` : "";
1327
+ let text = `## Assets for "${campaignName}"${adGroupName} (${assets.length} found)
1328
+
1329
+ `;
1330
+ if (textAssets.length > 0) {
1331
+ text += "### Text Assets\n";
1332
+ text += "| Asset | Field | Content | Label |\n|---|---|---|---|\n";
1333
+ for (const a of textAssets) {
1334
+ const content = a.asset.textAsset?.text ?? "\u2014";
1335
+ text += `| ${a.asset.name} | ${a.fieldType} | ${content} | ${a.performanceLabel} |
1336
+ `;
1337
+ }
1338
+ text += "\n";
1339
+ }
1340
+ if (imageAssets.length > 0) {
1341
+ text += "### Image Assets\n";
1342
+ text += "| Asset | Dimensions | Ratio | Label |\n|---|---|---|---|\n";
1343
+ for (const a of imageAssets) {
1344
+ const img = a.asset.imageAsset?.fullSize;
1345
+ const w = img?.widthPixels;
1346
+ const h = img?.heightPixels;
1347
+ const dims = w && h ? `${w}x${h}` : "\u2014";
1348
+ const ratio = w && h ? `${(w / h).toFixed(2)}:1` : "\u2014";
1349
+ text += `| ${a.asset.name} | ${dims} | ${ratio} | ${a.performanceLabel} |
1350
+ `;
1351
+ }
1352
+ text += "\n";
1353
+ }
1354
+ if (videoAssets.length > 0) {
1355
+ text += "### Video Assets\n";
1356
+ text += "| Asset | YouTube ID | Title | Label |\n|---|---|---|---|\n";
1357
+ for (const a of videoAssets) {
1358
+ const yt = a.asset.youtubeVideoAsset;
1359
+ text += `| ${a.asset.name} | ${yt?.youtubeVideoId ?? "\u2014"} | ${yt?.youtubeVideoTitle ?? "\u2014"} | ${a.performanceLabel} |
1360
+ `;
1361
+ }
1362
+ text += "\n";
1363
+ }
1364
+ if (bundleAssets.length > 0) {
1365
+ text += "### HTML5 Assets\n";
1366
+ text += "| Asset | Field | Label |\n|---|---|---|\n";
1367
+ for (const a of bundleAssets) {
1368
+ text += `| ${a.asset.name} | ${a.fieldType} | ${a.performanceLabel} |
1369
+ `;
1370
+ }
1371
+ text += "\n";
1372
+ }
1373
+ if (showAudit) {
1374
+ const adGroups = /* @__PURE__ */ new Map();
1375
+ for (const a of assets) {
1376
+ const key = a.adGroupName;
1377
+ if (!adGroups.has(key)) adGroups.set(key, []);
1378
+ adGroups.get(key).push(a);
1379
+ }
1380
+ for (const [agName, agAssets] of adGroups) {
1381
+ const headlines = agAssets.filter((a) => a.fieldType === "HEADLINE");
1382
+ const descriptions = agAssets.filter((a) => a.fieldType === "DESCRIPTION");
1383
+ const images = agAssets.filter((a) => a.asset.type === "IMAGE");
1384
+ const videos = agAssets.filter((a) => a.asset.type === "YOUTUBE_VIDEO");
1385
+ const html5 = agAssets.filter((a) => a.asset.type === "MEDIA_BUNDLE");
1386
+ const orientations = /* @__PURE__ */ new Set();
1387
+ for (const v of videos) {
1388
+ orientations.add("unknown");
1389
+ }
1390
+ const hasLandscape = images.some((a) => classifyOrientation(a.asset.imageAsset?.fullSize?.widthPixels, a.asset.imageAsset?.fullSize?.heightPixels) === "landscape");
1391
+ const hasPortrait = images.some((a) => classifyOrientation(a.asset.imageAsset?.fullSize?.widthPixels, a.asset.imageAsset?.fullSize?.heightPixels) === "portrait");
1392
+ const hasSquare = images.some((a) => classifyOrientation(a.asset.imageAsset?.fullSize?.widthPixels, a.asset.imageAsset?.fullSize?.heightPixels) === "square");
1393
+ text += `### Slot Utilization \u2014 "${agName}"
1394
+ `;
1395
+ text += "| Asset Type | Filled | Max | Status |\n|---|---|---|---|\n";
1396
+ const slotRow = (label, count, max, extra) => {
1397
+ const pct = count >= max ? "\u2705 Full" : count >= Math.ceil(max * 0.6) ? "\u2705 OK" : `\u26A0\uFE0F Below Excellent (need ${max})`;
1398
+ return `| ${label} | ${count} | ${max} | ${extra ?? pct} |
1399
+ `;
1400
+ };
1401
+ text += slotRow("Headlines", headlines.length, SLOT_LIMITS.HEADLINE);
1402
+ text += slotRow("Descriptions", descriptions.length, SLOT_LIMITS.DESCRIPTION);
1403
+ text += slotRow(
1404
+ "Images",
1405
+ images.length,
1406
+ SLOT_LIMITS.IMAGE,
1407
+ images.length > 0 ? hasLandscape && hasPortrait && hasSquare ? "\u2705 OK (has landscape + portrait + square)" : `\u26A0\uFE0F Missing orientations: ${[!hasLandscape && "landscape", !hasPortrait && "portrait", !hasSquare && "square"].filter(Boolean).join(", ")}` : "\u26A0\uFE0F No images"
1408
+ );
1409
+ text += slotRow(
1410
+ "Videos",
1411
+ videos.length,
1412
+ SLOT_LIMITS.YOUTUBE_VIDEO,
1413
+ videos.length > 0 ? "\u2705 OK" : "\u26A0\uFE0F No videos \u2014 missing YouTube inventory"
1414
+ );
1415
+ text += `| HTML5 | ${html5.length} | ${SLOT_LIMITS.MEDIA_BUNDLE} | ${html5.length > 0 ? "\u2705 OK" : "\u2014 Not required"} |
1416
+ `;
1417
+ const issues = [];
1418
+ if (headlines.length < 5) issues.push(`${5 - headlines.length} headlines`);
1419
+ if (descriptions.length < 5) issues.push(`${5 - descriptions.length} descriptions`);
1420
+ if (!hasLandscape) issues.push("landscape image");
1421
+ const strength = issues.length === 0 ? "Excellent" : issues.length <= 2 ? "Good" : "Poor";
1422
+ text += `
1423
+ **Ad Strength: ${strength}**`;
1424
+ if (issues.length > 0) {
1425
+ text += ` (missing ${issues.join(" and ")} for Excellent)`;
1426
+ }
1427
+ text += "\n\n";
1428
+ }
1429
+ }
1430
+ return { content: [{ type: "text", text }] };
1431
+ } catch (err) {
1432
+ return {
1433
+ content: [
1434
+ {
1435
+ type: "text",
1436
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1437
+ }
1438
+ ],
1439
+ isError: true
1440
+ };
1441
+ }
1442
+ }
1443
+ );
1444
+ }
1445
+
1446
+ // src/tools/google-insights.ts
1447
+ import { z as z10 } from "zod";
1448
+ function parseNum(v) {
1449
+ if (v === void 0 || v === null) return 0;
1450
+ if (typeof v === "number") return v;
1451
+ const n = parseFloat(v);
1452
+ return isNaN(n) ? 0 : n;
1453
+ }
1454
+ function buildAggKey(row, level, breakdown) {
1455
+ const parts = [];
1456
+ if (level === "campaign" || level === "ad_group" || level === "asset") {
1457
+ parts.push(row.campaign?.id ?? "?");
1458
+ }
1459
+ if (level === "ad_group" || level === "asset") {
1460
+ parts.push(row.adGroup?.id ?? "?");
1461
+ }
1462
+ if (level === "asset") {
1463
+ parts.push(row.asset?.id ?? "?");
1464
+ }
1465
+ if (breakdown) {
1466
+ const seg = row.segments;
1467
+ if (breakdown === "network") parts.push(seg?.adNetworkType ?? "?");
1468
+ if (breakdown === "device") parts.push(seg?.device ?? "?");
1469
+ }
1470
+ return parts.join("|");
1471
+ }
1472
+ function getLabel(row, level) {
1473
+ if (level === "asset") return row.asset?.name ?? row.asset?.id ?? "\u2014";
1474
+ if (level === "ad_group") return row.adGroup?.name ?? row.adGroup?.id ?? "\u2014";
1475
+ if (level === "campaign") return row.campaign?.name ?? row.campaign?.id ?? "\u2014";
1476
+ return "Account";
1477
+ }
1478
+ function getBreakdownValue(row, breakdown) {
1479
+ if (!breakdown) return void 0;
1480
+ const seg = row.segments;
1481
+ if (breakdown === "network") return seg?.adNetworkType;
1482
+ if (breakdown === "device") return seg?.device;
1483
+ return void 0;
1484
+ }
1485
+ function sortKeyForEnum(sort) {
1486
+ switch (sort) {
1487
+ case "cost_desc":
1488
+ return "metrics.cost_micros";
1489
+ case "conversions_desc":
1490
+ return "metrics.conversions";
1491
+ case "impressions_desc":
1492
+ return "metrics.impressions";
1493
+ case "ctr_desc":
1494
+ return "metrics.ctr";
1495
+ default:
1496
+ return "metrics.cost_micros";
1497
+ }
1498
+ }
1499
+ function registerGetGoogleAdsInsights(server2) {
1500
+ server2.tool(
1501
+ "get_google_insights",
1502
+ "Pull performance metrics from Google Ads with configurable level, breakdowns, date ranges, and time granularity. Use network breakdown to detect traffic shifts between Search, Display/AdMob, and YouTube \u2014 the #1 diagnostic lever for Google campaigns (ab-pt-005). Supports campaign, ad_group, asset, and account levels.",
1503
+ {
1504
+ customer_id: z10.string().describe("Google Ads customer ID"),
1505
+ level: z10.enum(["account", "campaign", "ad_group", "asset"]).optional().describe("Aggregation level (default: campaign)"),
1506
+ campaign_id: z10.string().optional().describe("Scope to specific campaign"),
1507
+ ad_group_id: z10.string().optional().describe("Scope to specific ad group"),
1508
+ breakdown: z10.enum(["network", "device"]).optional().describe("Segmentation dimension. Only one at a time (GAQL restriction)"),
1509
+ date_range: z10.object({
1510
+ start_date: z10.string().describe("YYYY-MM-DD"),
1511
+ end_date: z10.string().describe("YYYY-MM-DD")
1512
+ }).optional().describe("Custom date range. Overrides date_preset"),
1513
+ date_preset: z10.enum(["LAST_7_DAYS", "LAST_14_DAYS", "LAST_30_DAYS", "THIS_MONTH", "LAST_MONTH"]).optional().describe("Predefined date range (default: LAST_7_DAYS)"),
1514
+ time_increment: z10.enum(["daily", "weekly", "monthly", "summary"]).optional().describe("Time granularity (default: summary)"),
1515
+ sort: z10.enum(["cost_desc", "conversions_desc", "impressions_desc", "ctr_desc"]).optional().describe("Sort order (default: cost_desc)"),
1516
+ limit: z10.number().min(1).max(500).optional().describe("Max results (default 50)")
1517
+ },
1518
+ async ({ customer_id, level, campaign_id, ad_group_id, breakdown, date_range, date_preset, time_increment, sort, limit }) => {
1519
+ try {
1520
+ const lvl = level ?? "campaign";
1521
+ const ti = time_increment ?? "summary";
1522
+ const sortField = sort ?? "cost_desc";
1523
+ const rowLimit = limit ?? 50;
1524
+ const dr = date_range ?? datePresetToRange(date_preset ?? "LAST_7_DAYS");
1525
+ const dateLabel = date_range ? `${dr.start_date ?? dr.start} to ${dr.end_date ?? dr.end}` : date_preset ?? "Last 7 Days";
1526
+ const startDate = dr.start_date ?? dr.start;
1527
+ const endDate = dr.end_date ?? dr.end;
1528
+ const selectFields = [];
1529
+ if (lvl !== "account") {
1530
+ selectFields.push("campaign.id", "campaign.name");
1531
+ }
1532
+ if (lvl === "ad_group" || lvl === "asset") {
1533
+ selectFields.push("ad_group.id", "ad_group.name");
1534
+ }
1535
+ if (lvl === "asset") {
1536
+ selectFields.push(
1537
+ "asset.id",
1538
+ "asset.name",
1539
+ "asset.type",
1540
+ "ad_group_ad_asset_view.field_type",
1541
+ "ad_group_ad_asset_view.performance_label"
1542
+ );
1543
+ }
1544
+ if (breakdown === "network") selectFields.push("segments.ad_network_type");
1545
+ if (breakdown === "device") selectFields.push("segments.device");
1546
+ if (ti === "daily" || ti === "weekly" || ti === "monthly") {
1547
+ selectFields.push("segments.date");
1548
+ }
1549
+ selectFields.push(
1550
+ "metrics.impressions",
1551
+ "metrics.clicks",
1552
+ "metrics.ctr",
1553
+ "metrics.cost_micros",
1554
+ "metrics.conversions",
1555
+ "metrics.conversions_value",
1556
+ "metrics.all_conversions",
1557
+ "metrics.video_views",
1558
+ "metrics.interactions",
1559
+ "metrics.average_cpm",
1560
+ "metrics.average_cpc",
1561
+ "metrics.biddable_app_install_conversions",
1562
+ "metrics.biddable_app_post_install_conversions"
1563
+ );
1564
+ let fromClause;
1565
+ if (lvl === "asset") fromClause = "ad_group_ad_asset_view";
1566
+ else if (lvl === "ad_group") fromClause = "ad_group";
1567
+ else fromClause = "campaign";
1568
+ const conditions = [
1569
+ "campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT')",
1570
+ "campaign.status = 'ENABLED'",
1571
+ `segments.date BETWEEN '${startDate}' AND '${endDate}'`
1572
+ ];
1573
+ if (campaign_id) {
1574
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1575
+ conditions.push(`campaign.id = ${resolvedCampaignId}`);
1576
+ }
1577
+ if (ad_group_id) {
1578
+ const resolvedAdGroupId = await resolveAdGroupId(customer_id, ad_group_id, campaign_id);
1579
+ conditions.push(`ad_group.id = ${resolvedAdGroupId}`);
1580
+ }
1581
+ const orderBy = `ORDER BY ${sortKeyForEnum(sortField)} DESC`;
1582
+ const query = `
1583
+ SELECT ${selectFields.join(", ")}
1584
+ FROM ${fromClause}
1585
+ WHERE ${conditions.join("\n AND ")}
1586
+ ${orderBy}
1587
+ LIMIT ${rowLimit}
1588
+ `;
1589
+ const chunks = await googleAdsQuery(customer_id, query);
1590
+ const rows = chunks.flatMap((c) => c.results ?? []);
1591
+ if (rows.length === 0) {
1592
+ return {
1593
+ content: [{
1594
+ type: "text",
1595
+ text: "No data found for the specified filters and date range."
1596
+ }]
1597
+ };
1598
+ }
1599
+ const aggMap = /* @__PURE__ */ new Map();
1600
+ for (const row of rows) {
1601
+ const key = lvl === "account" ? (breakdown ? getBreakdownValue(row, breakdown) ?? "all" : "all") + (row.segments?.date ? `|${row.segments.date}` : "") : buildAggKey(row, lvl, breakdown) + (row.segments?.date && ti !== "summary" ? `|${row.segments.date}` : "");
1602
+ if (!aggMap.has(key)) {
1603
+ aggMap.set(key, {
1604
+ label: getLabel(row, lvl),
1605
+ breakdown: getBreakdownValue(row, breakdown),
1606
+ date: row.segments?.date,
1607
+ impressions: 0,
1608
+ clicks: 0,
1609
+ costMicros: 0,
1610
+ conversions: 0,
1611
+ conversionsValue: 0,
1612
+ installs: 0,
1613
+ postInstalls: 0,
1614
+ videoViews: 0,
1615
+ averageCpmMicros: 0,
1616
+ averageCpcMicros: 0,
1617
+ rowCount: 0
1618
+ });
1619
+ }
1620
+ const agg = aggMap.get(key);
1621
+ const m = row.metrics;
1622
+ agg.impressions += parseNum(m?.impressions);
1623
+ agg.clicks += parseNum(m?.clicks);
1624
+ agg.costMicros += parseNum(m?.costMicros);
1625
+ agg.conversions += parseNum(m?.conversions);
1626
+ agg.conversionsValue += parseNum(m?.conversionsValue);
1627
+ agg.installs += parseNum(m?.biddableAppInstallConversions);
1628
+ agg.postInstalls += parseNum(m?.biddableAppPostInstallConversions);
1629
+ agg.videoViews += parseNum(m?.videoViews);
1630
+ agg.averageCpmMicros += parseNum(m?.averageCpm);
1631
+ agg.averageCpcMicros += parseNum(m?.averageCpc);
1632
+ agg.rowCount += 1;
1633
+ }
1634
+ const aggRows = Array.from(aggMap.values());
1635
+ const levelLabel = lvl.charAt(0).toUpperCase() + lvl.slice(1);
1636
+ const breakdownLabel = breakdown ? ` \u2014 By ${breakdown.charAt(0).toUpperCase() + breakdown.slice(1)}` : "";
1637
+ let text = `## Google Ads Performance \u2014 ${dateLabel}${breakdownLabel} (${aggRows.length} ${levelLabel}s)
1638
+
1639
+ `;
1640
+ const cols = [];
1641
+ if (ti === "daily") cols.push("Date");
1642
+ cols.push(levelLabel);
1643
+ if (breakdown) cols.push(breakdown.charAt(0).toUpperCase() + breakdown.slice(1));
1644
+ cols.push("Spend", "Impr", "Clicks", "CTR", "CPM", "CPC", "Installs", "CPI", "Post-Install", "CPA", "ROAS");
1645
+ text += `| ${cols.join(" | ")} |
1646
+ `;
1647
+ text += `|${cols.map(() => "---").join("|")}|
1648
+ `;
1649
+ for (const agg of aggRows) {
1650
+ const spend = agg.costMicros / 1e6;
1651
+ const ctr = agg.impressions > 0 ? agg.clicks / agg.impressions * 100 : 0;
1652
+ const cpm = agg.rowCount > 0 ? agg.averageCpmMicros / agg.rowCount / 1e6 : 0;
1653
+ const cpc = agg.rowCount > 0 ? agg.averageCpcMicros / agg.rowCount / 1e6 : 0;
1654
+ const cpi = agg.installs > 0 ? spend / agg.installs : 0;
1655
+ const cpa = agg.postInstalls > 0 ? spend / agg.postInstalls : 0;
1656
+ const roas = spend > 0 ? agg.conversionsValue / spend * 100 : 0;
1657
+ const vals = [];
1658
+ if (ti === "daily") vals.push(agg.date ?? "\u2014");
1659
+ vals.push(agg.label);
1660
+ if (breakdown) vals.push(agg.breakdown ?? "\u2014");
1661
+ vals.push(
1662
+ `$${spend.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`,
1663
+ formatCompact(agg.impressions),
1664
+ formatCompact(agg.clicks),
1665
+ formatPct(ctr),
1666
+ `$${cpm.toFixed(2)}`,
1667
+ `$${cpc.toFixed(2)}`,
1668
+ agg.installs > 0 ? formatCompact(agg.installs) : "\u2014",
1669
+ cpi > 0 ? `$${cpi.toFixed(2)}` : "\u2014",
1670
+ agg.postInstalls > 0 ? formatCompact(agg.postInstalls) : "\u2014",
1671
+ cpa > 0 ? `$${cpa.toFixed(2)}` : "\u2014",
1672
+ roas > 0 ? formatPct(roas) : "\u2014"
1673
+ );
1674
+ text += `| ${vals.join(" | ")} |
1675
+ `;
1676
+ }
1677
+ return { content: [{ type: "text", text }] };
1678
+ } catch (err) {
1679
+ return {
1680
+ content: [
1681
+ {
1682
+ type: "text",
1683
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1684
+ }
1685
+ ],
1686
+ isError: true
1687
+ };
1688
+ }
1689
+ }
1690
+ );
1691
+ }
1692
+
1693
+ // src/tools/google-network-mix.ts
1694
+ import { z as z11 } from "zod";
1695
+ function parseNum2(v) {
1696
+ if (v === void 0 || v === null) return 0;
1697
+ if (typeof v === "number") return v;
1698
+ const n = parseFloat(v);
1699
+ return isNaN(n) ? 0 : n;
1700
+ }
1701
+ function registerGetGoogleAdsNetworkMix(server2) {
1702
+ server2.tool(
1703
+ "get_google_network_mix",
1704
+ "Analyze traffic distribution across Google's ad networks (Search, Display/AdMob, YouTube) over time. Computes spend share % per network per day and flags significant shifts. Traffic shifts signal performance problems \u2014 a sudden shift to Display/MGDN tanks CPA. Google optimizes for Google's revenue alongside yours; use this to detect shifts to low-quality inventory (ab-pt-005, ab-pt-017).",
1705
+ {
1706
+ customer_id: z11.string().describe("Google Ads customer ID"),
1707
+ campaign_id: z11.string().optional().describe("Scope to one campaign. If omitted, aggregates across all app campaigns"),
1708
+ date_range: z11.object({
1709
+ start_date: z11.string().describe("YYYY-MM-DD"),
1710
+ end_date: z11.string().describe("YYYY-MM-DD")
1711
+ }).optional().describe("Custom date range. Default: last 14 days"),
1712
+ shift_threshold_pct: z11.number().optional().describe("Flag networks whose spend share changed by more than this % (default 10)")
1713
+ },
1714
+ async ({ customer_id, campaign_id, date_range, shift_threshold_pct }) => {
1715
+ try {
1716
+ const threshold = shift_threshold_pct ?? 10;
1717
+ const dr = date_range ?? datePresetToRange("LAST_14_DAYS");
1718
+ const startDate = dr.start_date ?? dr.start;
1719
+ const endDate = dr.end_date ?? dr.end;
1720
+ const conditions = [
1721
+ "campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT')",
1722
+ "campaign.status = 'ENABLED'",
1723
+ `segments.date BETWEEN '${startDate}' AND '${endDate}'`
1724
+ ];
1725
+ if (campaign_id) {
1726
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1727
+ conditions.push(`campaign.id = ${resolvedCampaignId}`);
1728
+ }
1729
+ const query = `
1730
+ SELECT
1731
+ campaign.id,
1732
+ campaign.name,
1733
+ segments.ad_network_type,
1734
+ segments.date,
1735
+ metrics.impressions,
1736
+ metrics.clicks,
1737
+ metrics.cost_micros,
1738
+ metrics.conversions,
1739
+ metrics.conversions_value,
1740
+ metrics.average_cpm,
1741
+ metrics.biddable_app_install_conversions,
1742
+ metrics.biddable_app_post_install_conversions
1743
+ FROM campaign
1744
+ WHERE ${conditions.join("\n AND ")}
1745
+ ORDER BY segments.date ASC
1746
+ `;
1747
+ const chunks = await googleAdsQuery(customer_id, query);
1748
+ const rows = chunks.flatMap((c) => c.results ?? []);
1749
+ if (rows.length === 0) {
1750
+ return {
1751
+ content: [{
1752
+ type: "text",
1753
+ text: "No network data found for the specified filters and date range."
1754
+ }]
1755
+ };
1756
+ }
1757
+ const dayNetMap = /* @__PURE__ */ new Map();
1758
+ for (const row of rows) {
1759
+ const date = row.segments?.date ?? "unknown";
1760
+ const network = row.segments?.adNetworkType ?? "UNKNOWN";
1761
+ const key = `${date}|${network}`;
1762
+ if (!dayNetMap.has(key)) {
1763
+ dayNetMap.set(key, { date, network, costMicros: 0, impressions: 0, clicks: 0, installs: 0 });
1764
+ }
1765
+ const dn = dayNetMap.get(key);
1766
+ dn.costMicros += parseNum2(row.metrics?.costMicros);
1767
+ dn.impressions += parseNum2(row.metrics?.impressions);
1768
+ dn.clicks += parseNum2(row.metrics?.clicks);
1769
+ dn.installs += parseNum2(row.metrics?.biddableAppInstallConversions);
1770
+ }
1771
+ const dayNets = Array.from(dayNetMap.values());
1772
+ const dates = [...new Set(dayNets.map((d) => d.date))].sort();
1773
+ const networks = [...new Set(dayNets.map((d) => d.network))].sort();
1774
+ const dailyTotals = /* @__PURE__ */ new Map();
1775
+ for (const dn of dayNets) {
1776
+ dailyTotals.set(dn.date, (dailyTotals.get(dn.date) ?? 0) + dn.costMicros);
1777
+ }
1778
+ const campaignLabel = campaign_id && rows[0]?.campaign?.name ? ` \u2014 ${rows[0].campaign.name}` : "";
1779
+ let text = `## Network Mix${campaignLabel} \u2014 ${startDate} to ${endDate}
1780
+
1781
+ `;
1782
+ const headerCols = ["Date"];
1783
+ for (const net of networks) {
1784
+ headerCols.push(net, `${net} %`);
1785
+ }
1786
+ headerCols.push("Total");
1787
+ text += `| ${headerCols.join(" | ")} |
1788
+ `;
1789
+ text += `|${headerCols.map(() => "---").join("|")}|
1790
+ `;
1791
+ const networkDatePct = /* @__PURE__ */ new Map();
1792
+ for (const net of networks) {
1793
+ networkDatePct.set(net, /* @__PURE__ */ new Map());
1794
+ }
1795
+ for (const date of dates) {
1796
+ const total = dailyTotals.get(date) ?? 1;
1797
+ const vals = [date];
1798
+ for (const net of networks) {
1799
+ const dn = dayNets.find((d) => d.date === date && d.network === net);
1800
+ const cost = dn?.costMicros ?? 0;
1801
+ const pct = total > 0 ? cost / total * 100 : 0;
1802
+ networkDatePct.get(net).set(date, pct);
1803
+ vals.push(
1804
+ `$${(cost / 1e6).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`,
1805
+ `${pct.toFixed(0)}%`
1806
+ );
1807
+ }
1808
+ vals.push(`$${(total / 1e6).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`);
1809
+ text += `| ${vals.join(" | ")} |
1810
+ `;
1811
+ }
1812
+ const midIdx = Math.floor(dates.length / 2);
1813
+ const firstHalfDates = dates.slice(0, midIdx);
1814
+ const secondHalfDates = dates.slice(midIdx);
1815
+ text += `
1816
+ ### Network Shift Analysis (first ${firstHalfDates.length} days vs. last ${secondHalfDates.length} days)
1817
+
1818
+ `;
1819
+ text += "| Network | First Half | Second Half | Shift | Status |\n|---|---|---|---|---|\n";
1820
+ const flagged = [];
1821
+ for (const net of networks) {
1822
+ const pctMap = networkDatePct.get(net);
1823
+ const firstAvg = firstHalfDates.length > 0 ? firstHalfDates.reduce((s, d) => s + (pctMap.get(d) ?? 0), 0) / firstHalfDates.length : 0;
1824
+ const secondAvg = secondHalfDates.length > 0 ? secondHalfDates.reduce((s, d) => s + (pctMap.get(d) ?? 0), 0) / secondHalfDates.length : 0;
1825
+ const shift = secondAvg - firstAvg;
1826
+ const isFlagged = Math.abs(shift) > threshold;
1827
+ if (isFlagged) {
1828
+ flagged.push({
1829
+ network: net,
1830
+ shift,
1831
+ direction: shift > 0 ? "increasing" : "declining"
1832
+ });
1833
+ }
1834
+ text += `| ${net} | ${firstAvg.toFixed(0)}% | ${secondAvg.toFixed(0)}% | ${shift > 0 ? "+" : ""}${shift.toFixed(0)}% | ${isFlagged ? `\u26A0\uFE0F FLAGGED \u2014 ${net} share ${shift > 0 ? "increasing" : "declining"}` : "\u2705 Stable"} |
1835
+ `;
1836
+ }
1837
+ if (flagged.length > 0) {
1838
+ const increasing = flagged.filter((f) => f.shift > 0);
1839
+ const declining = flagged.filter((f) => f.shift < 0);
1840
+ text += "\n";
1841
+ const parts = [];
1842
+ for (const f of increasing) {
1843
+ parts.push(`${f.network} spend share increased by ${Math.abs(f.shift).toFixed(0)}%`);
1844
+ }
1845
+ for (const f of declining) {
1846
+ parts.push(`${f.network} decreased by ${Math.abs(f.shift).toFixed(0)}%`);
1847
+ }
1848
+ text += `\u26A0\uFE0F Significant network shift detected: ${parts.join(" while ")}.
1849
+ `;
1850
+ const hasDisplayIncrease = increasing.some(
1851
+ (f) => f.network.includes("DISPLAY") || f.network.includes("CONTENT")
1852
+ );
1853
+ if (hasDisplayIncrease) {
1854
+ text += "This may indicate the algorithm is shifting to lower-quality Display/MGDN inventory.\n";
1855
+ }
1856
+ text += "\nRecommended actions:\n";
1857
+ text += "- Check the placement report for underperforming apps/sites\n";
1858
+ text += "- Cross-reference with MMP data to verify conversion quality per network\n";
1859
+ text += "- If CPA has risen, the network shift is likely the cause\n";
1860
+ text += "\nSources: ab-pt-005, ab-pt-017, gg135-005\n";
1861
+ }
1862
+ return { content: [{ type: "text", text }] };
1863
+ } catch (err) {
1864
+ return {
1865
+ content: [
1866
+ {
1867
+ type: "text",
1868
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1869
+ }
1870
+ ],
1871
+ isError: true
1872
+ };
1873
+ }
1874
+ }
1875
+ );
1876
+ }
1877
+
1878
+ // src/tools/google-asset-fatigue.ts
1879
+ import { z as z12 } from "zod";
1880
+ function parseNum3(v) {
1881
+ if (v === void 0 || v === null) return 0;
1882
+ if (typeof v === "number") return v;
1883
+ const n = parseFloat(v);
1884
+ return isNaN(n) ? 0 : n;
1885
+ }
1886
+ function rolling3DayAvg(values) {
1887
+ if (values.length === 0) return 0;
1888
+ if (values.length <= 3) return values.reduce((a, b) => a + b, 0) / values.length;
1889
+ let best = 0;
1890
+ for (let i = 0; i <= values.length - 3; i++) {
1891
+ const avg = (values[i] + values[i + 1] + values[i + 2]) / 3;
1892
+ if (avg > best) best = avg;
1893
+ }
1894
+ return best;
1895
+ }
1896
+ function rolling3DayMin(values) {
1897
+ if (values.length === 0) return 0;
1898
+ if (values.length <= 3) return values.reduce((a, b) => a + b, 0) / values.length;
1899
+ let best = Infinity;
1900
+ for (let i = 0; i <= values.length - 3; i++) {
1901
+ const avg = (values[i] + values[i + 1] + values[i + 2]) / 3;
1902
+ if (avg < best) best = avg;
1903
+ }
1904
+ return best;
1905
+ }
1906
+ function last3Avg(values) {
1907
+ if (values.length === 0) return 0;
1908
+ const slice = values.slice(-3);
1909
+ return slice.reduce((a, b) => a + b, 0) / slice.length;
1910
+ }
1911
+ function dateFmt(d) {
1912
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1913
+ }
1914
+ function registerGetGoogleAdsAssetFatigue(server2) {
1915
+ server2.tool(
1916
+ "get_google_asset_fatigue",
1917
+ "Detect creative asset fatigue in Google UAC campaigns by analyzing per-asset impression trends, CTR decline, and CPA deterioration. Also checks asset age against Google's 2-week learning minimum and 2-3 month refresh cadence. Google doesn't expose per-asset frequency \u2014 uses impression volume decay as the primary fatigue signal. Never remove assets within first 2 weeks (goog-pdf-018). Google's performance label measures scalability, not fatigue (ab-pt-008).",
1918
+ {
1919
+ customer_id: z12.string().describe("Google Ads customer ID"),
1920
+ campaign_id: z12.string().describe("Campaign to analyze"),
1921
+ ad_group_id: z12.string().optional().describe("Scope to specific ad group"),
1922
+ lookback_days: z12.number().min(7).max(90).optional().describe("Days of daily data to analyze (default 14)"),
1923
+ ctr_decline_threshold_pct: z12.number().optional().describe("CTR decline % from peak to flag fatigue (default 30)"),
1924
+ impression_decay_threshold_pct: z12.number().optional().describe("Impression volume drop % from peak to flag (default 50)"),
1925
+ asset_type: z12.array(z12.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT"])).optional().describe("Filter by asset type")
1926
+ },
1927
+ async ({ customer_id, campaign_id, ad_group_id, lookback_days, ctr_decline_threshold_pct, impression_decay_threshold_pct, asset_type }) => {
1928
+ try {
1929
+ const lookback = lookback_days ?? 14;
1930
+ const ctrThreshold = ctr_decline_threshold_pct ?? 30;
1931
+ const imprThreshold = impression_decay_threshold_pct ?? 50;
1932
+ const endDate = /* @__PURE__ */ new Date();
1933
+ endDate.setDate(endDate.getDate() - 1);
1934
+ const startDate = new Date(endDate);
1935
+ startDate.setDate(startDate.getDate() - lookback + 1);
1936
+ const ageStartDate = new Date(endDate);
1937
+ ageStartDate.setDate(ageStartDate.getDate() - 89);
1938
+ const resolvedCampaignId = await resolveCampaignId(customer_id, campaign_id);
1939
+ const conditions = [
1940
+ `campaign.id = ${resolvedCampaignId}`,
1941
+ `segments.date BETWEEN '${dateFmt(startDate)}' AND '${dateFmt(endDate)}'`
1942
+ ];
1943
+ if (ad_group_id) {
1944
+ const resolvedAdGroupId = await resolveAdGroupId(customer_id, ad_group_id, resolvedCampaignId);
1945
+ conditions.push(`ad_group.id = ${resolvedAdGroupId}`);
1946
+ }
1947
+ if (asset_type?.length) {
1948
+ const typeList = asset_type.map((t) => `'${t}'`).join(", ");
1949
+ conditions.push(`asset.type IN (${typeList})`);
1950
+ }
1951
+ const query = `
1952
+ SELECT
1953
+ asset.id,
1954
+ asset.name,
1955
+ asset.type,
1956
+ ad_group_ad_asset_view.field_type,
1957
+ ad_group_ad_asset_view.performance_label,
1958
+ ad_group.id,
1959
+ ad_group.name,
1960
+ segments.date,
1961
+ metrics.impressions,
1962
+ metrics.clicks,
1963
+ metrics.cost_micros,
1964
+ metrics.conversions,
1965
+ metrics.biddable_app_install_conversions
1966
+ FROM ad_group_ad_asset_view
1967
+ WHERE ${conditions.join("\n AND ")}
1968
+ ORDER BY segments.date ASC
1969
+ `;
1970
+ const chunks = await googleAdsQuery(customer_id, query);
1971
+ const rows = chunks.flatMap((c) => c.results ?? []);
1972
+ if (rows.length === 0) {
1973
+ return {
1974
+ content: [{
1975
+ type: "text",
1976
+ text: "No asset performance data found for the specified campaign and date range."
1977
+ }]
1978
+ };
1979
+ }
1980
+ const assetDaily = /* @__PURE__ */ new Map();
1981
+ for (const row of rows) {
1982
+ const assetId = row.asset?.id ?? "?";
1983
+ if (!assetDaily.has(assetId)) {
1984
+ assetDaily.set(assetId, {
1985
+ name: row.asset?.name ?? "\u2014",
1986
+ type: row.asset?.type ?? "\u2014",
1987
+ fieldType: row.adGroupAdAssetView?.fieldType ?? "\u2014",
1988
+ performanceLabel: row.adGroupAdAssetView?.performanceLabel ?? "\u2014",
1989
+ days: /* @__PURE__ */ new Map()
1990
+ });
1991
+ }
1992
+ const entry = assetDaily.get(assetId);
1993
+ const date = row.segments?.date ?? "unknown";
1994
+ if (!entry.days.has(date)) {
1995
+ entry.days.set(date, { date, impressions: 0, clicks: 0, costMicros: 0, installs: 0 });
1996
+ }
1997
+ const dm = entry.days.get(date);
1998
+ dm.impressions += parseNum3(row.metrics?.impressions);
1999
+ dm.clicks += parseNum3(row.metrics?.clicks);
2000
+ dm.costMicros += parseNum3(row.metrics?.costMicros);
2001
+ dm.installs += parseNum3(row.metrics?.biddableAppInstallConversions);
2002
+ }
2003
+ const analyses = [];
2004
+ for (const [assetId, data] of assetDaily) {
2005
+ const sortedDays = [...data.days.values()].sort((a, b) => a.date.localeCompare(b.date));
2006
+ const daysWithImpressions = sortedDays.filter((d) => d.impressions > 0);
2007
+ if (daysWithImpressions.length < 3) continue;
2008
+ const firstDate = new Date(daysWithImpressions[0].date);
2009
+ const ageDays = Math.floor((endDate.getTime() - firstDate.getTime()) / (1e3 * 60 * 60 * 24));
2010
+ const dailyImpr = sortedDays.map((d) => d.impressions);
2011
+ const peakImpr = rolling3DayAvg(dailyImpr);
2012
+ const recentImpr = last3Avg(dailyImpr);
2013
+ const imprDecay = peakImpr > 0 ? (peakImpr - recentImpr) / peakImpr * 100 : 0;
2014
+ const dailyCtr = sortedDays.map((d) => d.impressions > 0 ? d.clicks / d.impressions : 0);
2015
+ const peakCtr = rolling3DayAvg(dailyCtr);
2016
+ const recentCtr = last3Avg(dailyCtr);
2017
+ const ctrDecline = peakCtr > 0 ? (peakCtr - recentCtr) / peakCtr * 100 : 0;
2018
+ const dailyCpa = sortedDays.map((d) => d.installs > 0 ? d.costMicros / d.installs / 1e6 : 0);
2019
+ const nonZeroCpa = dailyCpa.filter((c) => c > 0);
2020
+ const peakCpa = nonZeroCpa.length >= 3 ? rolling3DayMin(nonZeroCpa) : nonZeroCpa.length > 0 ? Math.min(...nonZeroCpa) : 0;
2021
+ const recentCpa = last3Avg(dailyCpa.filter((c) => c > 0).length >= 3 ? dailyCpa.slice(-3) : dailyCpa);
2022
+ const cpaChange = peakCpa > 0 ? (recentCpa - peakCpa) / peakCpa * 100 : 0;
2023
+ let status2;
2024
+ let action;
2025
+ if (ageDays < 14) {
2026
+ status2 = "LEARNING";
2027
+ action = `In learning phase \u2014 evaluate after day 14`;
2028
+ } else if (imprDecay > imprThreshold && (ctrDecline > ctrThreshold || cpaChange > 50)) {
2029
+ status2 = "FATIGUED";
2030
+ if (ageDays > 75) {
2031
+ action = "Replace \u2014 past refresh window, declining on all signals";
2032
+ } else {
2033
+ action = "Replace \u2014 impression decay + " + (ctrDecline > ctrThreshold ? "CTR decline" : "CPA deterioration");
2034
+ }
2035
+ } else if (ageDays > 75) {
2036
+ status2 = "NEEDS_REFRESH";
2037
+ action = "Approaching 75+ day age \u2014 evaluate for replacement";
2038
+ } else if (imprDecay > imprThreshold || ctrDecline > ctrThreshold || cpaChange > 30) {
2039
+ status2 = "WARNING";
2040
+ const signals = [];
2041
+ if (imprDecay > imprThreshold) signals.push("impression decay");
2042
+ if (ctrDecline > ctrThreshold) signals.push("CTR decline");
2043
+ if (cpaChange > 30) signals.push("CPA increase");
2044
+ action = `${signals.join(" + ")} beginning \u2014 watch next 7 days`;
2045
+ } else {
2046
+ status2 = "HEALTHY";
2047
+ action = "Stable performance";
2048
+ }
2049
+ analyses.push({
2050
+ assetId,
2051
+ assetName: data.name,
2052
+ assetType: data.type,
2053
+ fieldType: data.fieldType,
2054
+ performanceLabel: data.performanceLabel,
2055
+ status: status2,
2056
+ ageDays,
2057
+ impressionDecayPct: imprDecay,
2058
+ ctrDeclinePct: ctrDecline,
2059
+ cpaChangePct: cpaChange,
2060
+ action
2061
+ });
2062
+ }
2063
+ const priority = {
2064
+ FATIGUED: 0,
2065
+ NEEDS_REFRESH: 1,
2066
+ WARNING: 2,
2067
+ LEARNING: 3,
2068
+ HEALTHY: 4
2069
+ };
2070
+ analyses.sort((a, b) => priority[a.status] - priority[b.status]);
2071
+ const fatigued = analyses.filter((a) => a.status === "FATIGUED");
2072
+ const needsRefresh = analyses.filter((a) => a.status === "NEEDS_REFRESH");
2073
+ const warning = analyses.filter((a) => a.status === "WARNING");
2074
+ const learning = analyses.filter((a) => a.status === "LEARNING");
2075
+ const healthy = analyses.filter((a) => a.status === "HEALTHY");
2076
+ const campaignName = rows[0]?.campaign?.name ?? campaign_id;
2077
+ let text = `## Asset Fatigue Analysis \u2014 ${campaignName} \u2014 Last ${lookback} Days
2078
+
2079
+ `;
2080
+ text += `### Summary
2081
+ `;
2082
+ text += `- Total assets analyzed: ${analyses.length}
2083
+ `;
2084
+ if (fatigued.length > 0) text += `- \u{1F534} FATIGUED: ${fatigued.length} (replace these first)
2085
+ `;
2086
+ if (warning.length > 0) text += `- \u{1F7E1} WARNING: ${warning.length} (monitor closely)
2087
+ `;
2088
+ if (healthy.length > 0) text += `- \u{1F7E2} HEALTHY: ${healthy.length}
2089
+ `;
2090
+ if (learning.length > 0) text += `- \u{1F4D8} LEARNING: ${learning.length} (do not remove \u2014 need more time)
2091
+ `;
2092
+ if (needsRefresh.length > 0) text += `- \u{1F504} NEEDS_REFRESH: ${needsRefresh.length} (approaching 75+ day age)
2093
+ `;
2094
+ text += "\n";
2095
+ if (fatigued.length > 0) {
2096
+ text += "### Fatigued Assets (Action Required)\n";
2097
+ text += "| Asset | Type | Field | Label | Impr Decay | CTR Decline | CPA Change | Age | Action |\n";
2098
+ text += "|---|---|---|---|---|---|---|---|---|\n";
2099
+ for (const a of fatigued) {
2100
+ text += `| ${a.assetName} | ${a.assetType} | ${a.fieldType} | ${a.performanceLabel} | -${a.impressionDecayPct.toFixed(0)}% | -${a.ctrDeclinePct.toFixed(0)}% | +${a.cpaChangePct.toFixed(0)}% | ${a.ageDays} days | ${a.action} |
2101
+ `;
2102
+ }
2103
+ text += "\n";
2104
+ }
2105
+ if (needsRefresh.length > 0) {
2106
+ text += "### Assets Needing Refresh (75+ days)\n";
2107
+ text += "| Asset | Type | Field | Label | Age | Action |\n";
2108
+ text += "|---|---|---|---|---|---|\n";
2109
+ for (const a of needsRefresh) {
2110
+ text += `| ${a.assetName} | ${a.assetType} | ${a.fieldType} | ${a.performanceLabel} | ${a.ageDays} days | ${a.action} |
2111
+ `;
2112
+ }
2113
+ text += "\n";
2114
+ }
2115
+ if (warning.length > 0) {
2116
+ text += "### Warning Assets (Monitor)\n";
2117
+ text += "| Asset | Type | Field | Label | Impr Decay | CTR Decline | CPA Change | Age | Note |\n";
2118
+ text += "|---|---|---|---|---|---|---|---|---|\n";
2119
+ for (const a of warning) {
2120
+ text += `| ${a.assetName} | ${a.assetType} | ${a.fieldType} | ${a.performanceLabel} | -${a.impressionDecayPct.toFixed(0)}% | -${a.ctrDeclinePct.toFixed(0)}% | +${a.cpaChangePct.toFixed(0)}% | ${a.ageDays} days | ${a.action} |
2121
+ `;
2122
+ }
2123
+ text += "\n";
2124
+ }
2125
+ if (learning.length > 0) {
2126
+ text += "### Learning Assets (Do Not Remove)\n";
2127
+ text += "| Asset | Type | Field | Age | Note |\n";
2128
+ text += "|---|---|---|---|---|\n";
2129
+ for (const a of learning) {
2130
+ text += `| ${a.assetName} | ${a.assetType} | ${a.fieldType} | ${a.ageDays} days | ${a.action} |
2131
+ `;
2132
+ }
2133
+ text += "\n";
2134
+ }
2135
+ text += "### Recommendations\n";
2136
+ text += "Based on the analysis and Mobile Growth knowledge base:\n\n";
2137
+ if (fatigued.length > 0) {
2138
+ const pastRefresh = fatigued.filter((a) => a.ageDays > 75).length;
2139
+ text += `1. **Replace ${fatigued.length} fatigued assets**`;
2140
+ if (pastRefresh > 0) text += ` \u2014 prioritize the ${pastRefresh} past the 75-day refresh window`;
2141
+ text += "\n";
2142
+ }
2143
+ if (learning.length > 0) {
2144
+ text += `2. **Do NOT remove the ${learning.length} learning assets** \u2014 they need at least 14 days before evaluation (goog-pdf-018)
2145
+ `;
2146
+ }
2147
+ text += `3. **Replace 2-3 assets at a time**, not all at once \u2014 maintain algorithmic stability (goog-pdf-018)
2148
+ `;
2149
+ text += `4. **Add variants inspired by your "Best" rated assets** when replacing (goog-pdf-018)
2150
+ `;
2151
+ const mislabeled = fatigued.filter((a) => a.performanceLabel === "GOOD" || a.performanceLabel === "BEST");
2152
+ if (mislabeled.length > 0) {
2153
+ const names = mislabeled.map((a) => `"${a.assetName}"`).join(", ");
2154
+ text += `5. Note: ${names} rated "${mislabeled[0].performanceLabel}" by Google but fatigued by actual CTR/CPA metrics \u2014 Google's label measures scalability, not value (ab-pt-008)
2155
+ `;
2156
+ }
2157
+ text += `
2158
+ Sources: goog-pdf-018, ab-pt-008, goog-pdf-019, ab-pt-007
2159
+ `;
1016
2160
  return { content: [{ type: "text", text }] };
1017
2161
  } catch (err) {
1018
2162
  return {
@@ -1064,10 +2208,11 @@ function registerConnectionStatus(server2, status2) {
1064
2208
  );
1065
2209
  } else {
1066
2210
  lines.push(
1067
- "## Meta Marketing API: Not Configured",
1068
- "- Meta tools will return an error when called",
2211
+ "## Meta Marketing API: Not Connected (Optional)",
2212
+ "- KB, suggestions, and private insights work without it",
2213
+ "- Connect Meta to unlock live campaign data and reports",
1069
2214
  "",
1070
- "### How to fix",
2215
+ "### How to connect",
1071
2216
  "Provide your Meta access token using one of these methods:",
1072
2217
  '1. MCP config: add `"META_ACCESS_TOKEN": "..."` to the `"env"` block in `.mcp.json` (Claude Code/Cursor) or `claude_desktop_config.json` (Claude Desktop)',
1073
2218
  "2. CLI argument: add `--meta-token=...` to the args array",
@@ -1084,11 +2229,11 @@ function registerConnectionStatus(server2, status2) {
1084
2229
  );
1085
2230
  } else {
1086
2231
  lines.push(
1087
- "## Google Ads API: Not Configured",
1088
- "- Google Ads tools will return an error when called",
1089
- `- Missing: ${status2.google.missing.join(", ")}`,
2232
+ "## Google Ads API: Not Connected (Optional)",
2233
+ "- KB, suggestions, and private insights work without it",
2234
+ "- Connect Google Ads to unlock campaign data and network analysis",
1090
2235
  "",
1091
- "### How to fix",
2236
+ "### How to connect",
1092
2237
  "Option 1 \u2014 Interactive setup (recommended):",
1093
2238
  "```",
1094
2239
  "npx mobile-growth-mcp auth google",
@@ -1222,35 +2367,40 @@ function registerVocabularyResource(server2) {
1222
2367
  }
1223
2368
 
1224
2369
  // src/resources/instructions.ts
1225
- var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Meta Ad Tools
2370
+ var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Ad Platform Tools
1226
2371
 
1227
2372
  ## Welcome
1228
2373
 
1229
- You're connected to the Mobile Growth knowledge base \u2014 curated expert insights on mobile advertising, campaign optimization, and subscription app growth.
2374
+ You're connected to the Mobile Growth knowledge base \u2014 curated expert insights on mobile advertising, campaign optimization, and subscription app growth \u2014 plus direct Meta and Google Ads API integration.
1230
2375
 
1231
2376
  **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.
1232
2377
 
2378
+ > **Note:** Connecting Meta or Google Ads is optional. The knowledge base, community suggestions, and private insights all work with just your API key. Add Meta or Google Ads credentials later if you want live campaign data and reports.
2379
+
1233
2380
  Quick examples:
1234
2381
  - "subscription app creative fatigue signals"
1235
2382
  - "Meta CBO vs ABO tradeoffs for subscription apps"
2383
+ - "Google UAC network shift detection"
1236
2384
  - "iOS attribution strategies post-ATT"
1237
2385
 
1238
2386
  If you can't find what you need, call \`submit_feedback\` to report the gap \u2014 it helps us improve the knowledge base.
1239
2387
 
1240
2388
  ## What This Is
1241
- 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.
2389
+ A curated knowledge base of mobile advertising insights + direct Meta Marketing API and Google Ads API integration. Query expert knowledge, pull live campaign data, and run pre-built reports \u2014 all from your LLM.
2390
+
2391
+ ---
1242
2392
 
1243
2393
  ## Knowledge Base Tools
1244
2394
 
1245
2395
  ### search_insights
1246
- Semantic + keyword hybrid search across the knowledge base.
2396
+ Semantic + keyword hybrid search across the knowledge base. **Use this first** for any question about mobile advertising before searching the web.
1247
2397
  - **query** (required): Natural language search query
1248
2398
  - **topics** (optional): Filter by topic tags, e.g. ["creative_strategy", "scaling"]
1249
2399
  - **applies_to** (optional): Filter by applicability, e.g. ["subscription_apps", "ios"]
1250
2400
  - **limit** (optional): Max results, 1-30, default 10
1251
2401
 
1252
2402
  ### list_insights
1253
- Browse all insights with optional filtering. Returns titles and metadata.
2403
+ Browse all insights with optional filtering. Returns titles and metadata. Private insights are marked with a lock icon.
1254
2404
  - **topic** (optional): Filter by a single topic tag
1255
2405
  - **applies_to** (optional): Filter by a single applies_to value
1256
2406
 
@@ -1258,12 +2408,29 @@ Browse all insights with optional filtering. Returns titles and metadata.
1258
2408
  Fetch the full content of a specific insight by ID or slug.
1259
2409
  - **id** (required): Numeric ID or string slug (e.g. "mb-li-001")
1260
2410
 
2411
+ ### get_vocabulary_counts
2412
+ Returns counts of how many insights use each topic and applies_to tag. Lightweight way to explore what's in the KB.
2413
+
1261
2414
  ### submit_feedback
1262
- Report a gap in the knowledge base or a missing capability. Helps improve the product.
1263
- - **category** (required): missing_knowledge, missing_feature, search_quality, or other
1264
- - **summary** (required): What was needed but not available (anonymized \u2014 no account IDs or tokens)
2415
+ Report a gap in the knowledge base, a bug in any tool, or a missing capability.
2416
+ - **category** (required): missing_knowledge, missing_feature, search_quality, bug_report, or other
2417
+ - **summary** (required): What was needed or what went wrong (anonymized \u2014 no account IDs or tokens)
1265
2418
  - **search_queries_tried** (optional): Search queries that returned poor/no results
1266
2419
 
2420
+ ## Community Knowledge
2421
+
2422
+ ### suggest_insight \u2B50 DEFAULT for saving knowledge
2423
+ 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.
2424
+ - Use this when the user shares an article, post, or discussion with valuable mobile growth knowledge
2425
+ - Keep raw_excerpt concise (under 500 chars) for reliability
2426
+
2427
+ ### save_private_insight
2428
+ 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.
2429
+ - Same full schema as suggest_insight
2430
+ - No admin approval needed \u2014 saved instantly
2431
+
2432
+ ---
2433
+
1267
2434
  ## Meta Marketing API Tools
1268
2435
 
1269
2436
  **Requires META_ACCESS_TOKEN env var** \u2014 without it, these tools return a clear error. Knowledge base tools work with just API_KEY.
@@ -1307,33 +2474,87 @@ Built-in report: detect creative fatigue via frequency, CTR decline, CPA trends.
1307
2474
  - **frequency_critical** (optional): default 5
1308
2475
  - **ctr_decline_threshold** (optional): default 30%
1309
2476
 
2477
+ ---
2478
+
1310
2479
  ## Google Ads Tools
1311
2480
 
1312
- **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.
2481
+ **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.
2482
+
2483
+ 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.
1313
2484
 
1314
2485
  ### get_google_ads_campaigns
1315
- List campaigns from a Google Ads account with key metrics (last 7 days).
2486
+ List Google App Campaigns with status, bid strategy, budgets, and app info. Returns **numeric campaign IDs** needed by other tools.
1316
2487
  - **customer_id** (required): Google Ads customer ID (e.g. "123-456-7890")
1317
- - **status** (optional): Filter by status \u2014 ENABLED, PAUSED, or REMOVED (default: ENABLED)
2488
+ - **status** (optional): Filter by status \u2014 ENABLED, PAUSED, REMOVED (default: ["ENABLED"])
2489
+ - **channel_sub_type** (optional): Filter by campaign type \u2014 APP_CAMPAIGN (ACi), APP_CAMPAIGN_FOR_ENGAGEMENT (ACe)
1318
2490
  - **limit** (optional): Max campaigns to return (default 50)
1319
2491
 
2492
+ ### get_google_ad_groups
2493
+ 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.
2494
+ - **customer_id** (required): Google Ads customer ID
2495
+ - **campaign_id** (optional): Scope to a specific campaign (name or numeric ID)
2496
+ - **status** (optional): Filter by status (default: ["ENABLED"])
2497
+ - **limit** (optional): Max results (default 50)
2498
+
2499
+ ### get_google_insights
2500
+ 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.
2501
+ - **customer_id** (required): Google Ads customer ID
2502
+ - **level** (optional): account, campaign, ad_group, asset (default: campaign)
2503
+ - **campaign_id** (optional): Scope to specific campaign (name or numeric ID)
2504
+ - **ad_group_id** (optional): Scope to specific ad group
2505
+ - **breakdown** (optional): network or device (one at a time \u2014 GAQL restriction)
2506
+ - **date_range** (optional): {start_date, end_date} in YYYY-MM-DD
2507
+ - **date_preset** (optional): LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH
2508
+ - **time_increment** (optional): daily, weekly, monthly, summary (default: summary)
2509
+ - **sort** (optional): cost_desc, conversions_desc, impressions_desc, ctr_desc
2510
+ - **limit** (optional): Max results (default 50, max 500)
2511
+
2512
+ ### get_google_assets
2513
+ List creative assets with metadata, performance labels, and slot utilization audit. Checks headlines, descriptions, images, videos against Google's per-slot maximums.
2514
+ - **customer_id** (required): Google Ads customer ID
2515
+ - **campaign_id** (optional): Scope to a specific campaign (name or numeric ID)
2516
+ - **ad_group_id** (optional): Scope to a specific ad group
2517
+ - **asset_type** (optional): IMAGE, YOUTUBE_VIDEO, TEXT, MEDIA_BUNDLE
2518
+ - **include_slot_audit** (optional): default true
2519
+ - **limit** (optional): Max results (default 50)
2520
+
2521
+ ### get_google_network_mix
2522
+ 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.
2523
+ - **customer_id** (required): Google Ads customer ID
2524
+ - **campaign_id** (optional): Scope to one campaign (name or numeric ID)
2525
+ - **date_range** (optional): {start_date, end_date} \u2014 default last 14 days
2526
+ - **shift_threshold_pct** (optional): Flag networks with spend share change > this % (default 10)
2527
+
2528
+ ### get_google_asset_fatigue
2529
+ 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.
2530
+ - **customer_id** (required): Google Ads customer ID
2531
+ - **campaign_id** (required): Campaign to analyze (name or numeric ID)
2532
+ - **ad_group_id** (optional): Scope to specific ad group
2533
+ - **lookback_days** (optional): Days of data to analyze, 7-90 (default 14)
2534
+ - **ctr_decline_threshold_pct** (optional): CTR decline % to flag (default 30)
2535
+ - **impression_decay_threshold_pct** (optional): Impression drop % to flag (default 50)
2536
+ - **asset_type** (optional): IMAGE, YOUTUBE_VIDEO, TEXT
2537
+
2538
+ ---
2539
+
1320
2540
  ## Reports (MCP Prompts)
1321
2541
 
1322
- Pre-built analysis workflows. Select a prompt and provide your ad_account_id to run:
2542
+ Pre-built analysis workflows for Meta accounts. Select a prompt and provide your ad_account_id to run:
1323
2543
 
1324
2544
  | Prompt | What it does | API calls |
1325
2545
  |--------|-------------|-----------|
1326
2546
  | ad-fatigue-report | Detect creative fatigue with daily granularity | 1 |
1327
2547
  | weekly-performance | Week-over-week health comparison with diagnosis | 2 |
1328
2548
  | creative-performance | Categorize ads by health status | 1 |
1329
- | placement-efficiency | Identify placement waste and savings | 1 per campaign |
1330
- | audience-composition | Age \xD7 gender heatmap with CPA analysis | 1-2 |
2549
+ | audience-composition | Age x gender heatmap with CPA analysis | 1-2 |
1331
2550
  | architecture-review | Campaign structure evaluation | 3 (no insights) |
1332
2551
  | audit-meta-account | Comprehensive account audit | 6+ |
1333
2552
  | campaign-comparison | Side-by-side campaign comparison | 3+ |
1334
2553
  | placement-audit | Detailed placement audit with examples | 1 per campaign |
1335
2554
  | attribution-analysis | Conversion quality validation | 2+ |
1336
2555
 
2556
+ ---
2557
+
1337
2558
  ## Resources
1338
2559
 
1339
2560
  ### vocabulary://tags
@@ -1343,17 +2564,31 @@ Lists all topic tags, applies_to tags, and platforms with counts.
1343
2564
 
1344
2565
  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:
1345
2566
 
1346
- - **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")
1347
- - **Cite source author + slug** for key claims (e.g. "\u2026(source: Eric Seufert, \`mb-li-001\`)")
1348
- - **When multiple insights support a recommendation**, mention the count (e.g. "3 insights in the KB agree that\u2026")
1349
- - **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")
1350
-
1351
- ## Tips
1352
- - Start with \`list_insights\` to see what's in the knowledge base
1353
- - Use \`search_insights\` to find specific advice grounded in expert knowledge
1354
- - Meta tools default to safe parameters (last_7d, active-only) to avoid rate limits
1355
- - Reports reference specific knowledge base insight IDs \u2014 use \`get_insight\` to read the full context
1356
- - For custom date ranges, use time_range instead of date_preset
2567
+ - **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...")
2568
+ - **Cite source author + slug** for key claims (e.g. "...(source: Eric Seufert, \`mb-li-001\`)")
2569
+ - **When multiple insights support a recommendation**, mention the count (e.g. "3 insights in the KB agree that...")
2570
+ - **Distinguish KB-sourced advice from your own reasoning** \u2014 if you're adding your own analysis on top of KB results, make that clear
2571
+
2572
+ ## Workflow Tips
2573
+
2574
+ ### For Google Ads analysis:
2575
+ 1. Start with \`get_google_ads_campaigns\` to see campaigns and get numeric IDs
2576
+ 2. Use \`get_google_insights\` with network breakdown to check traffic distribution
2577
+ 3. Use \`get_google_network_mix\` if you suspect network shifts
2578
+ 4. Use \`get_google_assets\` to audit creative slot utilization
2579
+ 5. Use \`get_google_asset_fatigue\` on specific campaigns to detect creative decay
2580
+
2581
+ ### For Meta analysis:
2582
+ 1. Start with \`get_meta_campaigns\` to see account structure
2583
+ 2. Use reports (MCP prompts) for comprehensive analysis
2584
+ 3. For custom analysis, use \`get_meta_insights\` with breakdowns
2585
+
2586
+ ### General:
2587
+ - Always \`search_insights\` before making recommendations \u2014 ground advice in expert knowledge
2588
+ - Use \`get_insight\` to read full context when reports reference insight IDs
2589
+ - Use \`suggest_insight\` (default) when a user shares valuable knowledge from articles or discussions
2590
+ - Use \`save_private_insight\` only for client-specific data the user explicitly wants private
2591
+ - If a tool errors unexpectedly, call \`submit_feedback\` with category \`bug_report\`
1357
2592
  `;
1358
2593
  function buildStatusSection(status2) {
1359
2594
  if (!status2) return "";
@@ -1374,20 +2609,20 @@ function buildStatusSection(status2) {
1374
2609
  lines.push("- **Meta Marketing API**: Token configured");
1375
2610
  } else {
1376
2611
  lines.push(
1377
- "- **Meta Marketing API**: Token not configured \u2014 Meta tools will return errors"
2612
+ "- **Meta Marketing API**: Not connected (optional \u2014 KB works without it)"
1378
2613
  );
1379
2614
  lines.push(
1380
- " - Fix: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
2615
+ " - To connect: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
1381
2616
  );
1382
2617
  }
1383
2618
  if (status2.google.configured) {
1384
2619
  lines.push("- **Google Ads API**: Configured");
1385
2620
  } else {
1386
2621
  lines.push(
1387
- "- **Google Ads API**: Not configured \u2014 Google Ads tools will return errors"
2622
+ "- **Google Ads API**: Not connected (optional \u2014 KB works without it)"
1388
2623
  );
1389
2624
  lines.push(
1390
- " - Fix: run `npx mobile-growth-mcp auth google` to set up credentials"
2625
+ " - To connect: run `npx mobile-growth-mcp auth google` to set up credentials"
1391
2626
  );
1392
2627
  }
1393
2628
  return lines.join("\n");
@@ -1696,7 +2931,7 @@ async function runGoogleAuthFlow() {
1696
2931
  });
1697
2932
  try {
1698
2933
  console.log("Step 1: Developer token");
1699
- console.log(" (Found in Google Ads \u2192 Tools \u2192 API Center)\n");
2934
+ console.log(" (Found in Google Ads \u2192 Admin \u2192 API Center, or https://ads.google.com/aw/apicenter)\n");
1700
2935
  const developerToken = await prompt(rl, " Developer token: ");
1701
2936
  if (!developerToken) {
1702
2937
  console.error("\n Developer token is required.");
@@ -1784,10 +3019,10 @@ console.error(
1784
3019
  apiKey ? `API key: ${apiKeyResult.source}` : "API key: not configured \u2014 KB tools will not be available"
1785
3020
  );
1786
3021
  console.error(
1787
- metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured \u2014 Meta tools will return errors when called"
3022
+ metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured (optional \u2014 KB works without it)"
1788
3023
  );
1789
3024
  console.error(
1790
- googleAdsResult.configured ? `Google Ads: configured` : `Google Ads: not configured (missing: ${googleAdsResult.missing.join(", ")}) \u2014 run \`npx mobile-growth-mcp auth google\` to set up`
3025
+ 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`
1791
3026
  );
1792
3027
  var server = new McpServer({
1793
3028
  name: "mobile-growth-mcp",
@@ -1824,6 +3059,11 @@ registerGetMetaAds(server);
1824
3059
  registerGetMetaInsights(server);
1825
3060
  registerGetMetaAdFatigue(server);
1826
3061
  registerGetGoogleAdsCampaigns(server);
3062
+ registerGetGoogleAdsAdGroups(server);
3063
+ registerGetGoogleAdsAssets(server);
3064
+ registerGetGoogleAdsInsights(server);
3065
+ registerGetGoogleAdsNetworkMix(server);
3066
+ registerGetGoogleAdsAssetFatigue(server);
1827
3067
  registerConnectionStatus(server, status);
1828
3068
  registerVocabularyResource(server);
1829
3069
  registerInstructionsResource(server, status);