mobile-growth-mcp 2.0.8 → 2.2.1

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 +1710 -12
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -116,9 +116,9 @@ async function getRemotePrompt(apiKey2, name, args) {
116
116
  return resp.result?.messages ?? [];
117
117
  }
118
118
  function registerFetchedPrompts(server2, apiKey2, prompts) {
119
- for (const prompt of prompts) {
119
+ for (const prompt2 of prompts) {
120
120
  const zodShape = {};
121
- for (const arg of prompt.arguments) {
121
+ for (const arg of prompt2.arguments) {
122
122
  let field = z.string().describe(arg.description);
123
123
  if (!arg.required) {
124
124
  field = field.optional();
@@ -126,13 +126,13 @@ function registerFetchedPrompts(server2, apiKey2, prompts) {
126
126
  zodShape[arg.name] = field;
127
127
  }
128
128
  server2.prompt(
129
- prompt.name,
130
- prompt.description,
129
+ prompt2.name,
130
+ prompt2.description,
131
131
  zodShape,
132
132
  async (args) => {
133
133
  const messages = await getRemotePrompt(
134
134
  apiKey2,
135
- prompt.name,
135
+ prompt2.name,
136
136
  args
137
137
  );
138
138
  return {
@@ -160,7 +160,7 @@ function getMetaAccessToken() {
160
160
  const token = process.env.META_ACCESS_TOKEN;
161
161
  if (!token) {
162
162
  throw new Error(
163
- "Missing META_ACCESS_TOKEN environment variable. Provide a Meta Marketing API access token to use Meta tools."
163
+ 'Missing META_ACCESS_TOKEN. To fix, add it to your MCP config:\n \u2022 Claude Code / Cursor: add "META_ACCESS_TOKEN": "..." to the "env" block in .mcp.json\n \u2022 Claude Desktop: add "META_ACCESS_TOKEN": "..." to the "env" block in claude_desktop_config.json\n \u2022 CLI: add META_ACCESS_TOKEN=... to a .env file in your working directory\nThen restart your MCP client.'
164
164
  );
165
165
  }
166
166
  return token;
@@ -643,15 +643,22 @@ function registerGetMetaAdFatigue(server2) {
643
643
  const freqWarn = frequency_warning ?? 3;
644
644
  const freqCrit = frequency_critical ?? 5;
645
645
  const ctrThreshold = ctr_decline_threshold ?? 30;
646
- const parentPath = campaign_id ? `/${campaign_id}/insights` : `/${ad_account_id}/insights`;
646
+ const filtering = JSON.parse(activeFilter());
647
+ if (campaign_id) {
648
+ filtering.push({
649
+ field: "campaign.id",
650
+ operator: "EQUAL",
651
+ value: campaign_id
652
+ });
653
+ }
647
654
  const result = await metaApiGet({
648
- path: parentPath,
655
+ path: `/${ad_account_id}/insights`,
649
656
  params: {
650
657
  level: "ad",
651
658
  time_increment: "1",
652
659
  fields: "ad_id,ad_name,spend,impressions,clicks,ctr,cpm,frequency,actions,cost_per_action_type",
653
660
  date_preset: "last_7d",
654
- filtering: activeFilter(),
661
+ filtering: JSON.stringify(filtering),
655
662
  limit: "500"
656
663
  }
657
664
  });
@@ -819,6 +826,1301 @@ ${text}`;
819
826
  );
820
827
  }
821
828
 
829
+ // src/tools/google-campaigns.ts
830
+ import { z as z7 } from "zod";
831
+
832
+ // src/google/oauth.ts
833
+ var TOKEN_URL = "https://oauth2.googleapis.com/token";
834
+ var EXPIRY_SAFETY_MARGIN_MS = 6e4;
835
+ function createGoogleAdsAuth(config) {
836
+ let cached;
837
+ async function refreshAccessToken() {
838
+ const body = new URLSearchParams({
839
+ grant_type: "refresh_token",
840
+ client_id: config.clientId,
841
+ client_secret: config.clientSecret,
842
+ refresh_token: config.refreshToken
843
+ });
844
+ const response = await fetch(TOKEN_URL, {
845
+ method: "POST",
846
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
847
+ body: body.toString()
848
+ });
849
+ if (!response.ok) {
850
+ const text = await response.text();
851
+ throw new Error(
852
+ `Google OAuth token refresh failed (${response.status}): ${text}. Your refresh token may be expired \u2014 run \`npx mobile-growth-mcp auth google\` to re-authorize.`
853
+ );
854
+ }
855
+ const data = await response.json();
856
+ return {
857
+ accessToken: data.access_token,
858
+ expiresAt: Date.now() + data.expires_in * 1e3 - EXPIRY_SAFETY_MARGIN_MS
859
+ };
860
+ }
861
+ async function getAccessToken() {
862
+ if (!cached || Date.now() >= cached.expiresAt) {
863
+ cached = await refreshAccessToken();
864
+ }
865
+ return cached.accessToken;
866
+ }
867
+ async function getHeaders(customerId) {
868
+ const token = await getAccessToken();
869
+ const headers = {
870
+ Authorization: `Bearer ${token}`,
871
+ "developer-token": config.developerToken
872
+ };
873
+ if (config.loginCustomerId) {
874
+ headers["login-customer-id"] = config.loginCustomerId.replace(/-/g, "");
875
+ }
876
+ return headers;
877
+ }
878
+ return { getAccessToken, getHeaders };
879
+ }
880
+
881
+ // src/google/client.ts
882
+ var API_VERSION = "v19";
883
+ var BASE_URL = `https://googleads.googleapis.com/${API_VERSION}`;
884
+ var authSingleton;
885
+ function getGoogleAdsAuth() {
886
+ if (authSingleton) return authSingleton;
887
+ const developerToken = process.env.GOOGLE_ADS_DEVELOPER_TOKEN;
888
+ const clientId = process.env.GOOGLE_ADS_CLIENT_ID;
889
+ const clientSecret = process.env.GOOGLE_ADS_CLIENT_SECRET;
890
+ const refreshToken = process.env.GOOGLE_ADS_REFRESH_TOKEN;
891
+ const loginCustomerId = process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID;
892
+ if (!developerToken || !clientId || !clientSecret || !refreshToken) {
893
+ throw new Error(
894
+ 'Google Ads is not configured. To fix:\n 1. Run `npx mobile-growth-mcp auth google` to set up credentials (saves to .env)\n 2. Or add GOOGLE_ADS_* vars to the "env" block in your MCP config (.mcp.json or claude_desktop_config.json)\nThen restart your MCP client.'
895
+ );
896
+ }
897
+ const config = {
898
+ developerToken,
899
+ clientId,
900
+ clientSecret,
901
+ refreshToken,
902
+ loginCustomerId
903
+ };
904
+ authSingleton = createGoogleAdsAuth(config);
905
+ return authSingleton;
906
+ }
907
+ function normalizeCustomerId(customerId) {
908
+ return customerId.replace(/-/g, "");
909
+ }
910
+ function isGoogleAdsError(body) {
911
+ return typeof body === "object" && body !== null && "error" in body && typeof body.error?.message === "string";
912
+ }
913
+ function formatGoogleAdsError(err) {
914
+ const code = err.code;
915
+ if (code === 401) {
916
+ return "Authentication error: Your Google Ads credentials are invalid or expired. Run `npx mobile-growth-mcp auth google` to re-authorize.";
917
+ }
918
+ if (code === 403) {
919
+ return `Permission denied: ${err.message}. Check that your developer token is approved and the account ID is correct.`;
920
+ }
921
+ if (code === 429) {
922
+ return `Rate limit hit: ${err.message}. Wait a few minutes before retrying.`;
923
+ }
924
+ if (code === 400 && err.details?.length) {
925
+ const gaqlErrors = err.details.flatMap((d) => d.errors ?? []).map((e) => e.message).join("; ");
926
+ if (gaqlErrors) {
927
+ return `Invalid query: ${gaqlErrors}`;
928
+ }
929
+ }
930
+ return `Google Ads API error (${code}): ${err.message}`;
931
+ }
932
+ async function googleAdsQuery(customerId, query) {
933
+ const auth = getGoogleAdsAuth();
934
+ const normalizedId = normalizeCustomerId(customerId);
935
+ const headers = await auth.getHeaders(normalizedId);
936
+ const url = `${BASE_URL}/customers/${normalizedId}/googleAds:searchStream`;
937
+ const response = await fetch(url, {
938
+ method: "POST",
939
+ headers: {
940
+ ...headers,
941
+ "Content-Type": "application/json"
942
+ },
943
+ body: JSON.stringify({ query })
944
+ });
945
+ const body = await response.json();
946
+ if (!response.ok) {
947
+ if (isGoogleAdsError(body)) {
948
+ throw new Error(formatGoogleAdsError(body.error));
949
+ }
950
+ throw new Error(
951
+ `Google Ads API returned ${response.status}: ${JSON.stringify(body)}`
952
+ );
953
+ }
954
+ if (Array.isArray(body)) {
955
+ return body;
956
+ }
957
+ return [body];
958
+ }
959
+
960
+ // src/google/format.ts
961
+ function formatMicros(micros) {
962
+ if (!micros) return "\u2014";
963
+ const val = parseInt(micros, 10);
964
+ if (isNaN(val)) return "\u2014";
965
+ return `$${(val / 1e6).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
966
+ }
967
+ function formatPct(value) {
968
+ return `${value.toFixed(2)}%`;
969
+ }
970
+ function formatCompact(value) {
971
+ if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
972
+ if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
973
+ return value.toString();
974
+ }
975
+ function datePresetToRange(preset) {
976
+ const now = /* @__PURE__ */ new Date();
977
+ const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
978
+ switch (preset) {
979
+ case "LAST_7_DAYS": {
980
+ const end = new Date(now);
981
+ end.setDate(end.getDate() - 1);
982
+ const start = new Date(end);
983
+ start.setDate(start.getDate() - 6);
984
+ return { start: fmt(start), end: fmt(end) };
985
+ }
986
+ case "LAST_14_DAYS": {
987
+ const end = new Date(now);
988
+ end.setDate(end.getDate() - 1);
989
+ const start = new Date(end);
990
+ start.setDate(start.getDate() - 13);
991
+ return { start: fmt(start), end: fmt(end) };
992
+ }
993
+ case "LAST_30_DAYS": {
994
+ const end = new Date(now);
995
+ end.setDate(end.getDate() - 1);
996
+ const start = new Date(end);
997
+ start.setDate(start.getDate() - 29);
998
+ return { start: fmt(start), end: fmt(end) };
999
+ }
1000
+ case "THIS_MONTH": {
1001
+ const start = new Date(now.getFullYear(), now.getMonth(), 1);
1002
+ const end = new Date(now);
1003
+ end.setDate(end.getDate() - 1);
1004
+ return { start: fmt(start), end: fmt(end) };
1005
+ }
1006
+ case "LAST_MONTH": {
1007
+ const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
1008
+ const end = new Date(now.getFullYear(), now.getMonth(), 0);
1009
+ return { start: fmt(start), end: fmt(end) };
1010
+ }
1011
+ default:
1012
+ return datePresetToRange("LAST_7_DAYS");
1013
+ }
1014
+ }
1015
+
1016
+ // src/tools/google-campaigns.ts
1017
+ function channelSubTypeLabel(subType) {
1018
+ if (subType === "APP_CAMPAIGN") return "ACi";
1019
+ if (subType === "APP_CAMPAIGN_FOR_ENGAGEMENT") return "ACe";
1020
+ return subType ?? "\u2014";
1021
+ }
1022
+ function appStoreLabel(appStore) {
1023
+ if (appStore === "APPLE_APP_STORE") return "iOS";
1024
+ if (appStore === "GOOGLE_APP_STORE") return "Android";
1025
+ return appStore ?? "";
1026
+ }
1027
+ function formatTarget(row) {
1028
+ const c = row.campaign;
1029
+ if (!c) return "\u2014";
1030
+ if (c.targetCpa?.targetCpaMicros) {
1031
+ return formatMicros(c.targetCpa.targetCpaMicros);
1032
+ }
1033
+ if (c.targetRoas?.targetRoas !== void 0) {
1034
+ return `${(c.targetRoas.targetRoas * 100).toFixed(0)}%`;
1035
+ }
1036
+ return "\u2014";
1037
+ }
1038
+ function registerGetGoogleAdsCampaigns(server2) {
1039
+ server2.tool(
1040
+ "get_google_ads_campaigns",
1041
+ "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.",
1042
+ {
1043
+ customer_id: z7.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
1044
+ status: z7.array(z7.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by campaign status. Default: ["ENABLED"]'),
1045
+ 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'),
1046
+ limit: z7.number().min(1).max(100).optional().describe("Max campaigns to return (default 50)")
1047
+ },
1048
+ async ({ customer_id, status: status2, channel_sub_type, limit }) => {
1049
+ try {
1050
+ const statuses = status2 ?? ["ENABLED"];
1051
+ const subTypes = channel_sub_type ?? ["APP_CAMPAIGN", "APP_CAMPAIGN_FOR_ENGAGEMENT"];
1052
+ const rowLimit = limit ?? 50;
1053
+ const statusList = statuses.map((s) => `'${s}'`).join(", ");
1054
+ const subTypeList = subTypes.map((s) => `'${s}'`).join(", ");
1055
+ const query = `
1056
+ SELECT
1057
+ campaign.id,
1058
+ campaign.name,
1059
+ campaign.status,
1060
+ campaign.advertising_channel_type,
1061
+ campaign.advertising_channel_sub_type,
1062
+ campaign.bidding_strategy_type,
1063
+ campaign.target_cpa.target_cpa_micros,
1064
+ campaign.target_roas.target_roas,
1065
+ campaign.campaign_budget,
1066
+ campaign_budget.amount_micros,
1067
+ campaign_budget.type,
1068
+ campaign.app_campaign_setting.app_id,
1069
+ campaign.app_campaign_setting.app_store,
1070
+ campaign.app_campaign_setting.bidding_strategy_goal_type,
1071
+ campaign.start_date,
1072
+ campaign.end_date
1073
+ FROM campaign
1074
+ WHERE campaign.advertising_channel_type = 'MULTI_CHANNEL'
1075
+ AND campaign.advertising_channel_sub_type IN (${subTypeList})
1076
+ AND campaign.status IN (${statusList})
1077
+ LIMIT ${rowLimit}
1078
+ `;
1079
+ const chunks = await googleAdsQuery(customer_id, query);
1080
+ const rows = chunks.flatMap((c) => c.results ?? []);
1081
+ if (rows.length === 0) {
1082
+ return {
1083
+ content: [
1084
+ {
1085
+ type: "text",
1086
+ text: "No campaigns found matching filters. Try expanding status filter to include PAUSED."
1087
+ }
1088
+ ]
1089
+ };
1090
+ }
1091
+ const header = `## Google Campaigns (${rows.length} found)
1092
+
1093
+ `;
1094
+ const tableHeader = "| Campaign | Status | Type | Bid Strategy | Target | Daily Budget | App |\n|---|---|---|---|---|---|---|\n";
1095
+ let tableRows = "";
1096
+ for (const row of rows) {
1097
+ const c = row.campaign;
1098
+ if (!c) continue;
1099
+ const budget = row.campaignBudget?.amountMicros ? formatMicros(row.campaignBudget.amountMicros) : "\u2014";
1100
+ const budgetType = row.campaignBudget?.type;
1101
+ const budgetDisplay = budgetType === "TOTAL" ? `${budget} (lifetime)` : budget;
1102
+ const appId = c.appCampaignSetting?.appId ?? "";
1103
+ const store = appStoreLabel(c.appCampaignSetting?.appStore);
1104
+ const appDisplay = appId ? `${appId} (${store})` : "\u2014";
1105
+ tableRows += `| ${c.name} | ${c.status} | ${channelSubTypeLabel(c.advertisingChannelSubType)} | ${c.biddingStrategyType ?? "\u2014"} | ${formatTarget(row)} | ${budgetDisplay} | ${appDisplay} |
1106
+ `;
1107
+ }
1108
+ return {
1109
+ content: [{ type: "text", text: header + tableHeader + tableRows }]
1110
+ };
1111
+ } catch (err) {
1112
+ return {
1113
+ content: [
1114
+ {
1115
+ type: "text",
1116
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1117
+ }
1118
+ ],
1119
+ isError: true
1120
+ };
1121
+ }
1122
+ }
1123
+ );
1124
+ }
1125
+
1126
+ // src/tools/google-ad-groups.ts
1127
+ import { z as z8 } from "zod";
1128
+ function registerGetGoogleAdsAdGroups(server2) {
1129
+ server2.tool(
1130
+ "get_google_ad_groups",
1131
+ "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).",
1132
+ {
1133
+ customer_id: z8.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
1134
+ campaign_id: z8.string().optional().describe("Scope to a specific campaign. If omitted, returns ad groups across all app campaigns"),
1135
+ status: z8.array(z8.enum(["ENABLED", "PAUSED", "REMOVED"])).optional().describe('Filter by ad group status. Default: ["ENABLED"]'),
1136
+ limit: z8.number().min(1).max(100).optional().describe("Max results to return (default 50)")
1137
+ },
1138
+ async ({ customer_id, campaign_id, status: status2, limit }) => {
1139
+ try {
1140
+ const statuses = status2 ?? ["ENABLED"];
1141
+ const rowLimit = limit ?? 50;
1142
+ const statusList = statuses.map((s) => `'${s}'`).join(", ");
1143
+ let whereClause = `WHERE campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT')
1144
+ AND ad_group.status IN (${statusList})`;
1145
+ if (campaign_id) {
1146
+ whereClause += `
1147
+ AND campaign.id = ${campaign_id}`;
1148
+ }
1149
+ const query = `
1150
+ SELECT
1151
+ ad_group.id,
1152
+ ad_group.name,
1153
+ ad_group.status,
1154
+ ad_group.type,
1155
+ campaign.id,
1156
+ campaign.name,
1157
+ campaign.status
1158
+ FROM ad_group
1159
+ ${whereClause}
1160
+ LIMIT ${rowLimit}
1161
+ `;
1162
+ const chunks = await googleAdsQuery(customer_id, query);
1163
+ const rows = chunks.flatMap((c) => c.results ?? []);
1164
+ if (rows.length === 0) {
1165
+ return {
1166
+ content: [
1167
+ {
1168
+ type: "text",
1169
+ text: "No ad groups found matching filters. Try expanding status filter to include PAUSED."
1170
+ }
1171
+ ]
1172
+ };
1173
+ }
1174
+ const campaignName = campaign_id && rows[0]?.campaign?.name ? ` for "${rows[0].campaign.name}"` : "";
1175
+ const header = `## Ad Groups${campaignName} (${rows.length} found)
1176
+
1177
+ `;
1178
+ const tableHeader = "| Ad Group | Status | Type | Campaign |\n|---|---|---|---|\n";
1179
+ let tableRows = "";
1180
+ for (const row of rows) {
1181
+ const ag = row.adGroup;
1182
+ const c = row.campaign;
1183
+ if (!ag) continue;
1184
+ tableRows += `| ${ag.name} | ${ag.status ?? "\u2014"} | ${ag.type ?? "\u2014"} | ${c?.name ?? "\u2014"} |
1185
+ `;
1186
+ }
1187
+ return {
1188
+ content: [{ type: "text", text: header + tableHeader + tableRows }]
1189
+ };
1190
+ } catch (err) {
1191
+ return {
1192
+ content: [
1193
+ {
1194
+ type: "text",
1195
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1196
+ }
1197
+ ],
1198
+ isError: true
1199
+ };
1200
+ }
1201
+ }
1202
+ );
1203
+ }
1204
+
1205
+ // src/tools/google-assets.ts
1206
+ import { z as z9 } from "zod";
1207
+ var SLOT_LIMITS = {
1208
+ HEADLINE: 5,
1209
+ DESCRIPTION: 5,
1210
+ IMAGE: 20,
1211
+ YOUTUBE_VIDEO: 20,
1212
+ MEDIA_BUNDLE: 20
1213
+ };
1214
+ function classifyOrientation(w, h) {
1215
+ if (!w || !h) return "unknown";
1216
+ if (w > h * 1.05) return "landscape";
1217
+ if (h > w * 1.05) return "portrait";
1218
+ return "square";
1219
+ }
1220
+ function registerGetGoogleAdsAssets(server2) {
1221
+ server2.tool(
1222
+ "get_google_assets",
1223
+ "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).",
1224
+ {
1225
+ customer_id: z9.string().describe("Google Ads customer ID"),
1226
+ campaign_id: z9.string().optional().describe("Scope to a specific campaign"),
1227
+ ad_group_id: z9.string().optional().describe("Scope to a specific ad group"),
1228
+ asset_type: z9.array(z9.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT", "MEDIA_BUNDLE"])).optional().describe("Filter by asset type"),
1229
+ include_slot_audit: z9.boolean().optional().describe("Include slot utilization audit (default true)"),
1230
+ limit: z9.number().min(1).max(500).optional().describe("Max results to return (default 50)")
1231
+ },
1232
+ async ({ customer_id, campaign_id, ad_group_id, asset_type, include_slot_audit, limit }) => {
1233
+ try {
1234
+ const rowLimit = limit ?? 50;
1235
+ const showAudit = include_slot_audit !== false;
1236
+ const conditions = [
1237
+ "campaign.status = 'ENABLED'"
1238
+ ];
1239
+ if (campaign_id) conditions.push(`campaign.id = ${campaign_id}`);
1240
+ if (ad_group_id) conditions.push(`ad_group.id = ${ad_group_id}`);
1241
+ if (asset_type?.length) {
1242
+ const typeList = asset_type.map((t) => `'${t}'`).join(", ");
1243
+ conditions.push(`asset.type IN (${typeList})`);
1244
+ }
1245
+ const query = `
1246
+ SELECT
1247
+ asset.id,
1248
+ asset.name,
1249
+ asset.type,
1250
+ asset.text_asset.text,
1251
+ asset.image_asset.full_size.url,
1252
+ asset.image_asset.full_size.width_pixels,
1253
+ asset.image_asset.full_size.height_pixels,
1254
+ asset.youtube_video_asset.youtube_video_id,
1255
+ asset.youtube_video_asset.youtube_video_title,
1256
+ ad_group_ad_asset_view.field_type,
1257
+ ad_group_ad_asset_view.performance_label,
1258
+ ad_group.id,
1259
+ ad_group.name,
1260
+ campaign.id,
1261
+ campaign.name
1262
+ FROM ad_group_ad_asset_view
1263
+ WHERE ${conditions.join("\n AND ")}
1264
+ LIMIT ${rowLimit}
1265
+ `;
1266
+ const chunks = await googleAdsQuery(customer_id, query);
1267
+ const rows = chunks.flatMap((c) => c.results ?? []);
1268
+ if (rows.length === 0) {
1269
+ return {
1270
+ content: [{
1271
+ type: "text",
1272
+ text: "No assets found matching filters."
1273
+ }]
1274
+ };
1275
+ }
1276
+ const assets = rows.filter((r) => r.asset).map((r) => ({
1277
+ asset: r.asset,
1278
+ fieldType: r.adGroupAdAssetView?.fieldType ?? "\u2014",
1279
+ performanceLabel: r.adGroupAdAssetView?.performanceLabel ?? "\u2014",
1280
+ adGroupName: r.adGroup?.name ?? "\u2014"
1281
+ }));
1282
+ const textAssets = assets.filter((a) => a.asset.type === "TEXT");
1283
+ const imageAssets = assets.filter((a) => a.asset.type === "IMAGE");
1284
+ const videoAssets = assets.filter((a) => a.asset.type === "YOUTUBE_VIDEO");
1285
+ const bundleAssets = assets.filter((a) => a.asset.type === "MEDIA_BUNDLE");
1286
+ const campaignName = rows[0]?.campaign?.name ?? "Unknown Campaign";
1287
+ const adGroupName = ad_group_id && rows[0]?.adGroup?.name ? ` / "${rows[0].adGroup.name}"` : "";
1288
+ let text = `## Assets for "${campaignName}"${adGroupName} (${assets.length} found)
1289
+
1290
+ `;
1291
+ if (textAssets.length > 0) {
1292
+ text += "### Text Assets\n";
1293
+ text += "| Asset | Field | Content | Label |\n|---|---|---|---|\n";
1294
+ for (const a of textAssets) {
1295
+ const content = a.asset.textAsset?.text ?? "\u2014";
1296
+ text += `| ${a.asset.name} | ${a.fieldType} | ${content} | ${a.performanceLabel} |
1297
+ `;
1298
+ }
1299
+ text += "\n";
1300
+ }
1301
+ if (imageAssets.length > 0) {
1302
+ text += "### Image Assets\n";
1303
+ text += "| Asset | Dimensions | Ratio | Label |\n|---|---|---|---|\n";
1304
+ for (const a of imageAssets) {
1305
+ const img = a.asset.imageAsset?.fullSize;
1306
+ const w = img?.widthPixels;
1307
+ const h = img?.heightPixels;
1308
+ const dims = w && h ? `${w}x${h}` : "\u2014";
1309
+ const ratio = w && h ? `${(w / h).toFixed(2)}:1` : "\u2014";
1310
+ text += `| ${a.asset.name} | ${dims} | ${ratio} | ${a.performanceLabel} |
1311
+ `;
1312
+ }
1313
+ text += "\n";
1314
+ }
1315
+ if (videoAssets.length > 0) {
1316
+ text += "### Video Assets\n";
1317
+ text += "| Asset | YouTube ID | Title | Label |\n|---|---|---|---|\n";
1318
+ for (const a of videoAssets) {
1319
+ const yt = a.asset.youtubeVideoAsset;
1320
+ text += `| ${a.asset.name} | ${yt?.youtubeVideoId ?? "\u2014"} | ${yt?.youtubeVideoTitle ?? "\u2014"} | ${a.performanceLabel} |
1321
+ `;
1322
+ }
1323
+ text += "\n";
1324
+ }
1325
+ if (bundleAssets.length > 0) {
1326
+ text += "### HTML5 Assets\n";
1327
+ text += "| Asset | Field | Label |\n|---|---|---|\n";
1328
+ for (const a of bundleAssets) {
1329
+ text += `| ${a.asset.name} | ${a.fieldType} | ${a.performanceLabel} |
1330
+ `;
1331
+ }
1332
+ text += "\n";
1333
+ }
1334
+ if (showAudit) {
1335
+ const adGroups = /* @__PURE__ */ new Map();
1336
+ for (const a of assets) {
1337
+ const key = a.adGroupName;
1338
+ if (!adGroups.has(key)) adGroups.set(key, []);
1339
+ adGroups.get(key).push(a);
1340
+ }
1341
+ for (const [agName, agAssets] of adGroups) {
1342
+ const headlines = agAssets.filter((a) => a.fieldType === "HEADLINE");
1343
+ const descriptions = agAssets.filter((a) => a.fieldType === "DESCRIPTION");
1344
+ const images = agAssets.filter((a) => a.asset.type === "IMAGE");
1345
+ const videos = agAssets.filter((a) => a.asset.type === "YOUTUBE_VIDEO");
1346
+ const html5 = agAssets.filter((a) => a.asset.type === "MEDIA_BUNDLE");
1347
+ const orientations = /* @__PURE__ */ new Set();
1348
+ for (const v of videos) {
1349
+ orientations.add("unknown");
1350
+ }
1351
+ const hasLandscape = images.some((a) => classifyOrientation(a.asset.imageAsset?.fullSize?.widthPixels, a.asset.imageAsset?.fullSize?.heightPixels) === "landscape");
1352
+ const hasPortrait = images.some((a) => classifyOrientation(a.asset.imageAsset?.fullSize?.widthPixels, a.asset.imageAsset?.fullSize?.heightPixels) === "portrait");
1353
+ const hasSquare = images.some((a) => classifyOrientation(a.asset.imageAsset?.fullSize?.widthPixels, a.asset.imageAsset?.fullSize?.heightPixels) === "square");
1354
+ text += `### Slot Utilization \u2014 "${agName}"
1355
+ `;
1356
+ text += "| Asset Type | Filled | Max | Status |\n|---|---|---|---|\n";
1357
+ const slotRow = (label, count, max, extra) => {
1358
+ const pct = count >= max ? "\u2705 Full" : count >= Math.ceil(max * 0.6) ? "\u2705 OK" : `\u26A0\uFE0F Below Excellent (need ${max})`;
1359
+ return `| ${label} | ${count} | ${max} | ${extra ?? pct} |
1360
+ `;
1361
+ };
1362
+ text += slotRow("Headlines", headlines.length, SLOT_LIMITS.HEADLINE);
1363
+ text += slotRow("Descriptions", descriptions.length, SLOT_LIMITS.DESCRIPTION);
1364
+ text += slotRow(
1365
+ "Images",
1366
+ images.length,
1367
+ SLOT_LIMITS.IMAGE,
1368
+ 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"
1369
+ );
1370
+ text += slotRow(
1371
+ "Videos",
1372
+ videos.length,
1373
+ SLOT_LIMITS.YOUTUBE_VIDEO,
1374
+ videos.length > 0 ? "\u2705 OK" : "\u26A0\uFE0F No videos \u2014 missing YouTube inventory"
1375
+ );
1376
+ text += `| HTML5 | ${html5.length} | ${SLOT_LIMITS.MEDIA_BUNDLE} | ${html5.length > 0 ? "\u2705 OK" : "\u2014 Not required"} |
1377
+ `;
1378
+ const issues = [];
1379
+ if (headlines.length < 5) issues.push(`${5 - headlines.length} headlines`);
1380
+ if (descriptions.length < 5) issues.push(`${5 - descriptions.length} descriptions`);
1381
+ if (!hasLandscape) issues.push("landscape image");
1382
+ const strength = issues.length === 0 ? "Excellent" : issues.length <= 2 ? "Good" : "Poor";
1383
+ text += `
1384
+ **Ad Strength: ${strength}**`;
1385
+ if (issues.length > 0) {
1386
+ text += ` (missing ${issues.join(" and ")} for Excellent)`;
1387
+ }
1388
+ text += "\n\n";
1389
+ }
1390
+ }
1391
+ return { content: [{ type: "text", text }] };
1392
+ } catch (err) {
1393
+ return {
1394
+ content: [
1395
+ {
1396
+ type: "text",
1397
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1398
+ }
1399
+ ],
1400
+ isError: true
1401
+ };
1402
+ }
1403
+ }
1404
+ );
1405
+ }
1406
+
1407
+ // src/tools/google-insights.ts
1408
+ import { z as z10 } from "zod";
1409
+ function parseNum(v) {
1410
+ if (v === void 0 || v === null) return 0;
1411
+ if (typeof v === "number") return v;
1412
+ const n = parseFloat(v);
1413
+ return isNaN(n) ? 0 : n;
1414
+ }
1415
+ function buildAggKey(row, level, breakdown) {
1416
+ const parts = [];
1417
+ if (level === "campaign" || level === "ad_group" || level === "asset") {
1418
+ parts.push(row.campaign?.id ?? "?");
1419
+ }
1420
+ if (level === "ad_group" || level === "asset") {
1421
+ parts.push(row.adGroup?.id ?? "?");
1422
+ }
1423
+ if (level === "asset") {
1424
+ parts.push(row.asset?.id ?? "?");
1425
+ }
1426
+ if (breakdown) {
1427
+ const seg = row.segments;
1428
+ if (breakdown === "network") parts.push(seg?.adNetworkType ?? "?");
1429
+ if (breakdown === "device") parts.push(seg?.device ?? "?");
1430
+ }
1431
+ return parts.join("|");
1432
+ }
1433
+ function getLabel(row, level) {
1434
+ if (level === "asset") return row.asset?.name ?? row.asset?.id ?? "\u2014";
1435
+ if (level === "ad_group") return row.adGroup?.name ?? row.adGroup?.id ?? "\u2014";
1436
+ if (level === "campaign") return row.campaign?.name ?? row.campaign?.id ?? "\u2014";
1437
+ return "Account";
1438
+ }
1439
+ function getBreakdownValue(row, breakdown) {
1440
+ if (!breakdown) return void 0;
1441
+ const seg = row.segments;
1442
+ if (breakdown === "network") return seg?.adNetworkType;
1443
+ if (breakdown === "device") return seg?.device;
1444
+ return void 0;
1445
+ }
1446
+ function sortKeyForEnum(sort) {
1447
+ switch (sort) {
1448
+ case "cost_desc":
1449
+ return "metrics.cost_micros";
1450
+ case "conversions_desc":
1451
+ return "metrics.conversions";
1452
+ case "impressions_desc":
1453
+ return "metrics.impressions";
1454
+ case "ctr_desc":
1455
+ return "metrics.ctr";
1456
+ default:
1457
+ return "metrics.cost_micros";
1458
+ }
1459
+ }
1460
+ function registerGetGoogleAdsInsights(server2) {
1461
+ server2.tool(
1462
+ "get_google_insights",
1463
+ "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.",
1464
+ {
1465
+ customer_id: z10.string().describe("Google Ads customer ID"),
1466
+ level: z10.enum(["account", "campaign", "ad_group", "asset"]).optional().describe("Aggregation level (default: campaign)"),
1467
+ campaign_id: z10.string().optional().describe("Scope to specific campaign"),
1468
+ ad_group_id: z10.string().optional().describe("Scope to specific ad group"),
1469
+ breakdown: z10.enum(["network", "device"]).optional().describe("Segmentation dimension. Only one at a time (GAQL restriction)"),
1470
+ date_range: z10.object({
1471
+ start_date: z10.string().describe("YYYY-MM-DD"),
1472
+ end_date: z10.string().describe("YYYY-MM-DD")
1473
+ }).optional().describe("Custom date range. Overrides date_preset"),
1474
+ 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)"),
1475
+ time_increment: z10.enum(["daily", "weekly", "monthly", "summary"]).optional().describe("Time granularity (default: summary)"),
1476
+ sort: z10.enum(["cost_desc", "conversions_desc", "impressions_desc", "ctr_desc"]).optional().describe("Sort order (default: cost_desc)"),
1477
+ limit: z10.number().min(1).max(500).optional().describe("Max results (default 50)")
1478
+ },
1479
+ async ({ customer_id, level, campaign_id, ad_group_id, breakdown, date_range, date_preset, time_increment, sort, limit }) => {
1480
+ try {
1481
+ const lvl = level ?? "campaign";
1482
+ const ti = time_increment ?? "summary";
1483
+ const sortField = sort ?? "cost_desc";
1484
+ const rowLimit = limit ?? 50;
1485
+ const dr = date_range ?? datePresetToRange(date_preset ?? "LAST_7_DAYS");
1486
+ const dateLabel = date_range ? `${dr.start_date ?? dr.start} to ${dr.end_date ?? dr.end}` : date_preset ?? "Last 7 Days";
1487
+ const startDate = dr.start_date ?? dr.start;
1488
+ const endDate = dr.end_date ?? dr.end;
1489
+ const selectFields = [];
1490
+ if (lvl !== "account") {
1491
+ selectFields.push("campaign.id", "campaign.name");
1492
+ }
1493
+ if (lvl === "ad_group" || lvl === "asset") {
1494
+ selectFields.push("ad_group.id", "ad_group.name");
1495
+ }
1496
+ if (lvl === "asset") {
1497
+ selectFields.push(
1498
+ "asset.id",
1499
+ "asset.name",
1500
+ "asset.type",
1501
+ "ad_group_ad_asset_view.field_type",
1502
+ "ad_group_ad_asset_view.performance_label"
1503
+ );
1504
+ }
1505
+ if (breakdown === "network") selectFields.push("segments.ad_network_type");
1506
+ if (breakdown === "device") selectFields.push("segments.device");
1507
+ if (ti === "daily" || ti === "weekly" || ti === "monthly") {
1508
+ selectFields.push("segments.date");
1509
+ }
1510
+ selectFields.push(
1511
+ "metrics.impressions",
1512
+ "metrics.clicks",
1513
+ "metrics.ctr",
1514
+ "metrics.cost_micros",
1515
+ "metrics.conversions",
1516
+ "metrics.conversions_value",
1517
+ "metrics.all_conversions",
1518
+ "metrics.video_views",
1519
+ "metrics.interactions",
1520
+ "metrics.average_cpm",
1521
+ "metrics.average_cpc",
1522
+ "metrics.biddable_app_install_conversions",
1523
+ "metrics.biddable_app_post_install_conversions"
1524
+ );
1525
+ let fromClause;
1526
+ if (lvl === "asset") fromClause = "ad_group_ad_asset_view";
1527
+ else if (lvl === "ad_group") fromClause = "ad_group";
1528
+ else fromClause = "campaign";
1529
+ const conditions = [
1530
+ "campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT')",
1531
+ "campaign.status = 'ENABLED'",
1532
+ `segments.date BETWEEN '${startDate}' AND '${endDate}'`
1533
+ ];
1534
+ if (campaign_id) conditions.push(`campaign.id = ${campaign_id}`);
1535
+ if (ad_group_id) conditions.push(`ad_group.id = ${ad_group_id}`);
1536
+ const orderBy = `ORDER BY ${sortKeyForEnum(sortField)} DESC`;
1537
+ const query = `
1538
+ SELECT ${selectFields.join(", ")}
1539
+ FROM ${fromClause}
1540
+ WHERE ${conditions.join("\n AND ")}
1541
+ ${orderBy}
1542
+ LIMIT ${rowLimit}
1543
+ `;
1544
+ const chunks = await googleAdsQuery(customer_id, query);
1545
+ const rows = chunks.flatMap((c) => c.results ?? []);
1546
+ if (rows.length === 0) {
1547
+ return {
1548
+ content: [{
1549
+ type: "text",
1550
+ text: "No data found for the specified filters and date range."
1551
+ }]
1552
+ };
1553
+ }
1554
+ const aggMap = /* @__PURE__ */ new Map();
1555
+ for (const row of rows) {
1556
+ 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}` : "");
1557
+ if (!aggMap.has(key)) {
1558
+ aggMap.set(key, {
1559
+ label: getLabel(row, lvl),
1560
+ breakdown: getBreakdownValue(row, breakdown),
1561
+ date: row.segments?.date,
1562
+ impressions: 0,
1563
+ clicks: 0,
1564
+ costMicros: 0,
1565
+ conversions: 0,
1566
+ conversionsValue: 0,
1567
+ installs: 0,
1568
+ postInstalls: 0,
1569
+ videoViews: 0,
1570
+ averageCpmMicros: 0,
1571
+ averageCpcMicros: 0,
1572
+ rowCount: 0
1573
+ });
1574
+ }
1575
+ const agg = aggMap.get(key);
1576
+ const m = row.metrics;
1577
+ agg.impressions += parseNum(m?.impressions);
1578
+ agg.clicks += parseNum(m?.clicks);
1579
+ agg.costMicros += parseNum(m?.costMicros);
1580
+ agg.conversions += parseNum(m?.conversions);
1581
+ agg.conversionsValue += parseNum(m?.conversionsValue);
1582
+ agg.installs += parseNum(m?.biddableAppInstallConversions);
1583
+ agg.postInstalls += parseNum(m?.biddableAppPostInstallConversions);
1584
+ agg.videoViews += parseNum(m?.videoViews);
1585
+ agg.averageCpmMicros += parseNum(m?.averageCpm);
1586
+ agg.averageCpcMicros += parseNum(m?.averageCpc);
1587
+ agg.rowCount += 1;
1588
+ }
1589
+ const aggRows = Array.from(aggMap.values());
1590
+ const levelLabel = lvl.charAt(0).toUpperCase() + lvl.slice(1);
1591
+ const breakdownLabel = breakdown ? ` \u2014 By ${breakdown.charAt(0).toUpperCase() + breakdown.slice(1)}` : "";
1592
+ let text = `## Google Ads Performance \u2014 ${dateLabel}${breakdownLabel} (${aggRows.length} ${levelLabel}s)
1593
+
1594
+ `;
1595
+ const cols = [];
1596
+ if (ti === "daily") cols.push("Date");
1597
+ cols.push(levelLabel);
1598
+ if (breakdown) cols.push(breakdown.charAt(0).toUpperCase() + breakdown.slice(1));
1599
+ cols.push("Spend", "Impr", "Clicks", "CTR", "CPM", "CPC", "Installs", "CPI", "Post-Install", "CPA", "ROAS");
1600
+ text += `| ${cols.join(" | ")} |
1601
+ `;
1602
+ text += `|${cols.map(() => "---").join("|")}|
1603
+ `;
1604
+ for (const agg of aggRows) {
1605
+ const spend = agg.costMicros / 1e6;
1606
+ const ctr = agg.impressions > 0 ? agg.clicks / agg.impressions * 100 : 0;
1607
+ const cpm = agg.rowCount > 0 ? agg.averageCpmMicros / agg.rowCount / 1e6 : 0;
1608
+ const cpc = agg.rowCount > 0 ? agg.averageCpcMicros / agg.rowCount / 1e6 : 0;
1609
+ const cpi = agg.installs > 0 ? spend / agg.installs : 0;
1610
+ const cpa = agg.postInstalls > 0 ? spend / agg.postInstalls : 0;
1611
+ const roas = spend > 0 ? agg.conversionsValue / spend * 100 : 0;
1612
+ const vals = [];
1613
+ if (ti === "daily") vals.push(agg.date ?? "\u2014");
1614
+ vals.push(agg.label);
1615
+ if (breakdown) vals.push(agg.breakdown ?? "\u2014");
1616
+ vals.push(
1617
+ `$${spend.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`,
1618
+ formatCompact(agg.impressions),
1619
+ formatCompact(agg.clicks),
1620
+ formatPct(ctr),
1621
+ `$${cpm.toFixed(2)}`,
1622
+ `$${cpc.toFixed(2)}`,
1623
+ agg.installs > 0 ? formatCompact(agg.installs) : "\u2014",
1624
+ cpi > 0 ? `$${cpi.toFixed(2)}` : "\u2014",
1625
+ agg.postInstalls > 0 ? formatCompact(agg.postInstalls) : "\u2014",
1626
+ cpa > 0 ? `$${cpa.toFixed(2)}` : "\u2014",
1627
+ roas > 0 ? formatPct(roas) : "\u2014"
1628
+ );
1629
+ text += `| ${vals.join(" | ")} |
1630
+ `;
1631
+ }
1632
+ return { content: [{ type: "text", text }] };
1633
+ } catch (err) {
1634
+ return {
1635
+ content: [
1636
+ {
1637
+ type: "text",
1638
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1639
+ }
1640
+ ],
1641
+ isError: true
1642
+ };
1643
+ }
1644
+ }
1645
+ );
1646
+ }
1647
+
1648
+ // src/tools/google-network-mix.ts
1649
+ import { z as z11 } from "zod";
1650
+ function parseNum2(v) {
1651
+ if (v === void 0 || v === null) return 0;
1652
+ if (typeof v === "number") return v;
1653
+ const n = parseFloat(v);
1654
+ return isNaN(n) ? 0 : n;
1655
+ }
1656
+ function registerGetGoogleAdsNetworkMix(server2) {
1657
+ server2.tool(
1658
+ "get_google_network_mix",
1659
+ "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).",
1660
+ {
1661
+ customer_id: z11.string().describe("Google Ads customer ID"),
1662
+ campaign_id: z11.string().optional().describe("Scope to one campaign. If omitted, aggregates across all app campaigns"),
1663
+ date_range: z11.object({
1664
+ start_date: z11.string().describe("YYYY-MM-DD"),
1665
+ end_date: z11.string().describe("YYYY-MM-DD")
1666
+ }).optional().describe("Custom date range. Default: last 14 days"),
1667
+ shift_threshold_pct: z11.number().optional().describe("Flag networks whose spend share changed by more than this % (default 10)")
1668
+ },
1669
+ async ({ customer_id, campaign_id, date_range, shift_threshold_pct }) => {
1670
+ try {
1671
+ const threshold = shift_threshold_pct ?? 10;
1672
+ const dr = date_range ?? datePresetToRange("LAST_14_DAYS");
1673
+ const startDate = dr.start_date ?? dr.start;
1674
+ const endDate = dr.end_date ?? dr.end;
1675
+ const conditions = [
1676
+ "campaign.advertising_channel_sub_type IN ('APP_CAMPAIGN', 'APP_CAMPAIGN_FOR_ENGAGEMENT')",
1677
+ "campaign.status = 'ENABLED'",
1678
+ `segments.date BETWEEN '${startDate}' AND '${endDate}'`
1679
+ ];
1680
+ if (campaign_id) conditions.push(`campaign.id = ${campaign_id}`);
1681
+ const query = `
1682
+ SELECT
1683
+ campaign.id,
1684
+ campaign.name,
1685
+ segments.ad_network_type,
1686
+ segments.date,
1687
+ metrics.impressions,
1688
+ metrics.clicks,
1689
+ metrics.cost_micros,
1690
+ metrics.conversions,
1691
+ metrics.conversions_value,
1692
+ metrics.average_cpm,
1693
+ metrics.biddable_app_install_conversions,
1694
+ metrics.biddable_app_post_install_conversions
1695
+ FROM campaign
1696
+ WHERE ${conditions.join("\n AND ")}
1697
+ ORDER BY segments.date ASC
1698
+ `;
1699
+ const chunks = await googleAdsQuery(customer_id, query);
1700
+ const rows = chunks.flatMap((c) => c.results ?? []);
1701
+ if (rows.length === 0) {
1702
+ return {
1703
+ content: [{
1704
+ type: "text",
1705
+ text: "No network data found for the specified filters and date range."
1706
+ }]
1707
+ };
1708
+ }
1709
+ const dayNetMap = /* @__PURE__ */ new Map();
1710
+ for (const row of rows) {
1711
+ const date = row.segments?.date ?? "unknown";
1712
+ const network = row.segments?.adNetworkType ?? "UNKNOWN";
1713
+ const key = `${date}|${network}`;
1714
+ if (!dayNetMap.has(key)) {
1715
+ dayNetMap.set(key, { date, network, costMicros: 0, impressions: 0, clicks: 0, installs: 0 });
1716
+ }
1717
+ const dn = dayNetMap.get(key);
1718
+ dn.costMicros += parseNum2(row.metrics?.costMicros);
1719
+ dn.impressions += parseNum2(row.metrics?.impressions);
1720
+ dn.clicks += parseNum2(row.metrics?.clicks);
1721
+ dn.installs += parseNum2(row.metrics?.biddableAppInstallConversions);
1722
+ }
1723
+ const dayNets = Array.from(dayNetMap.values());
1724
+ const dates = [...new Set(dayNets.map((d) => d.date))].sort();
1725
+ const networks = [...new Set(dayNets.map((d) => d.network))].sort();
1726
+ const dailyTotals = /* @__PURE__ */ new Map();
1727
+ for (const dn of dayNets) {
1728
+ dailyTotals.set(dn.date, (dailyTotals.get(dn.date) ?? 0) + dn.costMicros);
1729
+ }
1730
+ const campaignLabel = campaign_id && rows[0]?.campaign?.name ? ` \u2014 ${rows[0].campaign.name}` : "";
1731
+ let text = `## Network Mix${campaignLabel} \u2014 ${startDate} to ${endDate}
1732
+
1733
+ `;
1734
+ const headerCols = ["Date"];
1735
+ for (const net of networks) {
1736
+ headerCols.push(net, `${net} %`);
1737
+ }
1738
+ headerCols.push("Total");
1739
+ text += `| ${headerCols.join(" | ")} |
1740
+ `;
1741
+ text += `|${headerCols.map(() => "---").join("|")}|
1742
+ `;
1743
+ const networkDatePct = /* @__PURE__ */ new Map();
1744
+ for (const net of networks) {
1745
+ networkDatePct.set(net, /* @__PURE__ */ new Map());
1746
+ }
1747
+ for (const date of dates) {
1748
+ const total = dailyTotals.get(date) ?? 1;
1749
+ const vals = [date];
1750
+ for (const net of networks) {
1751
+ const dn = dayNets.find((d) => d.date === date && d.network === net);
1752
+ const cost = dn?.costMicros ?? 0;
1753
+ const pct = total > 0 ? cost / total * 100 : 0;
1754
+ networkDatePct.get(net).set(date, pct);
1755
+ vals.push(
1756
+ `$${(cost / 1e6).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`,
1757
+ `${pct.toFixed(0)}%`
1758
+ );
1759
+ }
1760
+ vals.push(`$${(total / 1e6).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`);
1761
+ text += `| ${vals.join(" | ")} |
1762
+ `;
1763
+ }
1764
+ const midIdx = Math.floor(dates.length / 2);
1765
+ const firstHalfDates = dates.slice(0, midIdx);
1766
+ const secondHalfDates = dates.slice(midIdx);
1767
+ text += `
1768
+ ### Network Shift Analysis (first ${firstHalfDates.length} days vs. last ${secondHalfDates.length} days)
1769
+
1770
+ `;
1771
+ text += "| Network | First Half | Second Half | Shift | Status |\n|---|---|---|---|---|\n";
1772
+ const flagged = [];
1773
+ for (const net of networks) {
1774
+ const pctMap = networkDatePct.get(net);
1775
+ const firstAvg = firstHalfDates.length > 0 ? firstHalfDates.reduce((s, d) => s + (pctMap.get(d) ?? 0), 0) / firstHalfDates.length : 0;
1776
+ const secondAvg = secondHalfDates.length > 0 ? secondHalfDates.reduce((s, d) => s + (pctMap.get(d) ?? 0), 0) / secondHalfDates.length : 0;
1777
+ const shift = secondAvg - firstAvg;
1778
+ const isFlagged = Math.abs(shift) > threshold;
1779
+ if (isFlagged) {
1780
+ flagged.push({
1781
+ network: net,
1782
+ shift,
1783
+ direction: shift > 0 ? "increasing" : "declining"
1784
+ });
1785
+ }
1786
+ 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"} |
1787
+ `;
1788
+ }
1789
+ if (flagged.length > 0) {
1790
+ const increasing = flagged.filter((f) => f.shift > 0);
1791
+ const declining = flagged.filter((f) => f.shift < 0);
1792
+ text += "\n";
1793
+ const parts = [];
1794
+ for (const f of increasing) {
1795
+ parts.push(`${f.network} spend share increased by ${Math.abs(f.shift).toFixed(0)}%`);
1796
+ }
1797
+ for (const f of declining) {
1798
+ parts.push(`${f.network} decreased by ${Math.abs(f.shift).toFixed(0)}%`);
1799
+ }
1800
+ text += `\u26A0\uFE0F Significant network shift detected: ${parts.join(" while ")}.
1801
+ `;
1802
+ const hasDisplayIncrease = increasing.some(
1803
+ (f) => f.network.includes("DISPLAY") || f.network.includes("CONTENT")
1804
+ );
1805
+ if (hasDisplayIncrease) {
1806
+ text += "This may indicate the algorithm is shifting to lower-quality Display/MGDN inventory.\n";
1807
+ }
1808
+ text += "\nRecommended actions:\n";
1809
+ text += "- Check the placement report for underperforming apps/sites\n";
1810
+ text += "- Cross-reference with MMP data to verify conversion quality per network\n";
1811
+ text += "- If CPA has risen, the network shift is likely the cause\n";
1812
+ text += "\nSources: ab-pt-005, ab-pt-017, gg135-005\n";
1813
+ }
1814
+ return { content: [{ type: "text", text }] };
1815
+ } catch (err) {
1816
+ return {
1817
+ content: [
1818
+ {
1819
+ type: "text",
1820
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
1821
+ }
1822
+ ],
1823
+ isError: true
1824
+ };
1825
+ }
1826
+ }
1827
+ );
1828
+ }
1829
+
1830
+ // src/tools/google-asset-fatigue.ts
1831
+ import { z as z12 } from "zod";
1832
+ function parseNum3(v) {
1833
+ if (v === void 0 || v === null) return 0;
1834
+ if (typeof v === "number") return v;
1835
+ const n = parseFloat(v);
1836
+ return isNaN(n) ? 0 : n;
1837
+ }
1838
+ function rolling3DayAvg(values) {
1839
+ if (values.length === 0) return 0;
1840
+ if (values.length <= 3) return values.reduce((a, b) => a + b, 0) / values.length;
1841
+ let best = 0;
1842
+ for (let i = 0; i <= values.length - 3; i++) {
1843
+ const avg = (values[i] + values[i + 1] + values[i + 2]) / 3;
1844
+ if (avg > best) best = avg;
1845
+ }
1846
+ return best;
1847
+ }
1848
+ function rolling3DayMin(values) {
1849
+ if (values.length === 0) return 0;
1850
+ if (values.length <= 3) return values.reduce((a, b) => a + b, 0) / values.length;
1851
+ let best = Infinity;
1852
+ for (let i = 0; i <= values.length - 3; i++) {
1853
+ const avg = (values[i] + values[i + 1] + values[i + 2]) / 3;
1854
+ if (avg < best) best = avg;
1855
+ }
1856
+ return best;
1857
+ }
1858
+ function last3Avg(values) {
1859
+ if (values.length === 0) return 0;
1860
+ const slice = values.slice(-3);
1861
+ return slice.reduce((a, b) => a + b, 0) / slice.length;
1862
+ }
1863
+ function dateFmt(d) {
1864
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
1865
+ }
1866
+ function registerGetGoogleAdsAssetFatigue(server2) {
1867
+ server2.tool(
1868
+ "get_google_asset_fatigue",
1869
+ "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).",
1870
+ {
1871
+ customer_id: z12.string().describe("Google Ads customer ID"),
1872
+ campaign_id: z12.string().describe("Campaign to analyze"),
1873
+ ad_group_id: z12.string().optional().describe("Scope to specific ad group"),
1874
+ lookback_days: z12.number().min(7).max(90).optional().describe("Days of daily data to analyze (default 14)"),
1875
+ ctr_decline_threshold_pct: z12.number().optional().describe("CTR decline % from peak to flag fatigue (default 30)"),
1876
+ impression_decay_threshold_pct: z12.number().optional().describe("Impression volume drop % from peak to flag (default 50)"),
1877
+ asset_type: z12.array(z12.enum(["IMAGE", "YOUTUBE_VIDEO", "TEXT"])).optional().describe("Filter by asset type")
1878
+ },
1879
+ async ({ customer_id, campaign_id, ad_group_id, lookback_days, ctr_decline_threshold_pct, impression_decay_threshold_pct, asset_type }) => {
1880
+ try {
1881
+ const lookback = lookback_days ?? 14;
1882
+ const ctrThreshold = ctr_decline_threshold_pct ?? 30;
1883
+ const imprThreshold = impression_decay_threshold_pct ?? 50;
1884
+ const endDate = /* @__PURE__ */ new Date();
1885
+ endDate.setDate(endDate.getDate() - 1);
1886
+ const startDate = new Date(endDate);
1887
+ startDate.setDate(startDate.getDate() - lookback + 1);
1888
+ const ageStartDate = new Date(endDate);
1889
+ ageStartDate.setDate(ageStartDate.getDate() - 89);
1890
+ const conditions = [
1891
+ `campaign.id = ${campaign_id}`,
1892
+ `segments.date BETWEEN '${dateFmt(startDate)}' AND '${dateFmt(endDate)}'`
1893
+ ];
1894
+ if (ad_group_id) conditions.push(`ad_group.id = ${ad_group_id}`);
1895
+ if (asset_type?.length) {
1896
+ const typeList = asset_type.map((t) => `'${t}'`).join(", ");
1897
+ conditions.push(`asset.type IN (${typeList})`);
1898
+ }
1899
+ const query = `
1900
+ SELECT
1901
+ asset.id,
1902
+ asset.name,
1903
+ asset.type,
1904
+ ad_group_ad_asset_view.field_type,
1905
+ ad_group_ad_asset_view.performance_label,
1906
+ ad_group.id,
1907
+ ad_group.name,
1908
+ segments.date,
1909
+ metrics.impressions,
1910
+ metrics.clicks,
1911
+ metrics.cost_micros,
1912
+ metrics.conversions,
1913
+ metrics.biddable_app_install_conversions
1914
+ FROM ad_group_ad_asset_view
1915
+ WHERE ${conditions.join("\n AND ")}
1916
+ ORDER BY segments.date ASC
1917
+ `;
1918
+ const chunks = await googleAdsQuery(customer_id, query);
1919
+ const rows = chunks.flatMap((c) => c.results ?? []);
1920
+ if (rows.length === 0) {
1921
+ return {
1922
+ content: [{
1923
+ type: "text",
1924
+ text: "No asset performance data found for the specified campaign and date range."
1925
+ }]
1926
+ };
1927
+ }
1928
+ const assetDaily = /* @__PURE__ */ new Map();
1929
+ for (const row of rows) {
1930
+ const assetId = row.asset?.id ?? "?";
1931
+ if (!assetDaily.has(assetId)) {
1932
+ assetDaily.set(assetId, {
1933
+ name: row.asset?.name ?? "\u2014",
1934
+ type: row.asset?.type ?? "\u2014",
1935
+ fieldType: row.adGroupAdAssetView?.fieldType ?? "\u2014",
1936
+ performanceLabel: row.adGroupAdAssetView?.performanceLabel ?? "\u2014",
1937
+ days: /* @__PURE__ */ new Map()
1938
+ });
1939
+ }
1940
+ const entry = assetDaily.get(assetId);
1941
+ const date = row.segments?.date ?? "unknown";
1942
+ if (!entry.days.has(date)) {
1943
+ entry.days.set(date, { date, impressions: 0, clicks: 0, costMicros: 0, installs: 0 });
1944
+ }
1945
+ const dm = entry.days.get(date);
1946
+ dm.impressions += parseNum3(row.metrics?.impressions);
1947
+ dm.clicks += parseNum3(row.metrics?.clicks);
1948
+ dm.costMicros += parseNum3(row.metrics?.costMicros);
1949
+ dm.installs += parseNum3(row.metrics?.biddableAppInstallConversions);
1950
+ }
1951
+ const analyses = [];
1952
+ for (const [assetId, data] of assetDaily) {
1953
+ const sortedDays = [...data.days.values()].sort((a, b) => a.date.localeCompare(b.date));
1954
+ const daysWithImpressions = sortedDays.filter((d) => d.impressions > 0);
1955
+ if (daysWithImpressions.length < 3) continue;
1956
+ const firstDate = new Date(daysWithImpressions[0].date);
1957
+ const ageDays = Math.floor((endDate.getTime() - firstDate.getTime()) / (1e3 * 60 * 60 * 24));
1958
+ const dailyImpr = sortedDays.map((d) => d.impressions);
1959
+ const peakImpr = rolling3DayAvg(dailyImpr);
1960
+ const recentImpr = last3Avg(dailyImpr);
1961
+ const imprDecay = peakImpr > 0 ? (peakImpr - recentImpr) / peakImpr * 100 : 0;
1962
+ const dailyCtr = sortedDays.map((d) => d.impressions > 0 ? d.clicks / d.impressions : 0);
1963
+ const peakCtr = rolling3DayAvg(dailyCtr);
1964
+ const recentCtr = last3Avg(dailyCtr);
1965
+ const ctrDecline = peakCtr > 0 ? (peakCtr - recentCtr) / peakCtr * 100 : 0;
1966
+ const dailyCpa = sortedDays.map((d) => d.installs > 0 ? d.costMicros / d.installs / 1e6 : 0);
1967
+ const nonZeroCpa = dailyCpa.filter((c) => c > 0);
1968
+ const peakCpa = nonZeroCpa.length >= 3 ? rolling3DayMin(nonZeroCpa) : nonZeroCpa.length > 0 ? Math.min(...nonZeroCpa) : 0;
1969
+ const recentCpa = last3Avg(dailyCpa.filter((c) => c > 0).length >= 3 ? dailyCpa.slice(-3) : dailyCpa);
1970
+ const cpaChange = peakCpa > 0 ? (recentCpa - peakCpa) / peakCpa * 100 : 0;
1971
+ let status2;
1972
+ let action;
1973
+ if (ageDays < 14) {
1974
+ status2 = "LEARNING";
1975
+ action = `In learning phase \u2014 evaluate after day 14`;
1976
+ } else if (imprDecay > imprThreshold && (ctrDecline > ctrThreshold || cpaChange > 50)) {
1977
+ status2 = "FATIGUED";
1978
+ if (ageDays > 75) {
1979
+ action = "Replace \u2014 past refresh window, declining on all signals";
1980
+ } else {
1981
+ action = "Replace \u2014 impression decay + " + (ctrDecline > ctrThreshold ? "CTR decline" : "CPA deterioration");
1982
+ }
1983
+ } else if (ageDays > 75) {
1984
+ status2 = "NEEDS_REFRESH";
1985
+ action = "Approaching 75+ day age \u2014 evaluate for replacement";
1986
+ } else if (imprDecay > imprThreshold || ctrDecline > ctrThreshold || cpaChange > 30) {
1987
+ status2 = "WARNING";
1988
+ const signals = [];
1989
+ if (imprDecay > imprThreshold) signals.push("impression decay");
1990
+ if (ctrDecline > ctrThreshold) signals.push("CTR decline");
1991
+ if (cpaChange > 30) signals.push("CPA increase");
1992
+ action = `${signals.join(" + ")} beginning \u2014 watch next 7 days`;
1993
+ } else {
1994
+ status2 = "HEALTHY";
1995
+ action = "Stable performance";
1996
+ }
1997
+ analyses.push({
1998
+ assetId,
1999
+ assetName: data.name,
2000
+ assetType: data.type,
2001
+ fieldType: data.fieldType,
2002
+ performanceLabel: data.performanceLabel,
2003
+ status: status2,
2004
+ ageDays,
2005
+ impressionDecayPct: imprDecay,
2006
+ ctrDeclinePct: ctrDecline,
2007
+ cpaChangePct: cpaChange,
2008
+ action
2009
+ });
2010
+ }
2011
+ const priority = {
2012
+ FATIGUED: 0,
2013
+ NEEDS_REFRESH: 1,
2014
+ WARNING: 2,
2015
+ LEARNING: 3,
2016
+ HEALTHY: 4
2017
+ };
2018
+ analyses.sort((a, b) => priority[a.status] - priority[b.status]);
2019
+ const fatigued = analyses.filter((a) => a.status === "FATIGUED");
2020
+ const needsRefresh = analyses.filter((a) => a.status === "NEEDS_REFRESH");
2021
+ const warning = analyses.filter((a) => a.status === "WARNING");
2022
+ const learning = analyses.filter((a) => a.status === "LEARNING");
2023
+ const healthy = analyses.filter((a) => a.status === "HEALTHY");
2024
+ const campaignName = rows[0]?.campaign?.name ?? campaign_id;
2025
+ let text = `## Asset Fatigue Analysis \u2014 ${campaignName} \u2014 Last ${lookback} Days
2026
+
2027
+ `;
2028
+ text += `### Summary
2029
+ `;
2030
+ text += `- Total assets analyzed: ${analyses.length}
2031
+ `;
2032
+ if (fatigued.length > 0) text += `- \u{1F534} FATIGUED: ${fatigued.length} (replace these first)
2033
+ `;
2034
+ if (warning.length > 0) text += `- \u{1F7E1} WARNING: ${warning.length} (monitor closely)
2035
+ `;
2036
+ if (healthy.length > 0) text += `- \u{1F7E2} HEALTHY: ${healthy.length}
2037
+ `;
2038
+ if (learning.length > 0) text += `- \u{1F4D8} LEARNING: ${learning.length} (do not remove \u2014 need more time)
2039
+ `;
2040
+ if (needsRefresh.length > 0) text += `- \u{1F504} NEEDS_REFRESH: ${needsRefresh.length} (approaching 75+ day age)
2041
+ `;
2042
+ text += "\n";
2043
+ if (fatigued.length > 0) {
2044
+ text += "### Fatigued Assets (Action Required)\n";
2045
+ text += "| Asset | Type | Field | Label | Impr Decay | CTR Decline | CPA Change | Age | Action |\n";
2046
+ text += "|---|---|---|---|---|---|---|---|---|\n";
2047
+ for (const a of fatigued) {
2048
+ 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} |
2049
+ `;
2050
+ }
2051
+ text += "\n";
2052
+ }
2053
+ if (needsRefresh.length > 0) {
2054
+ text += "### Assets Needing Refresh (75+ days)\n";
2055
+ text += "| Asset | Type | Field | Label | Age | Action |\n";
2056
+ text += "|---|---|---|---|---|---|\n";
2057
+ for (const a of needsRefresh) {
2058
+ text += `| ${a.assetName} | ${a.assetType} | ${a.fieldType} | ${a.performanceLabel} | ${a.ageDays} days | ${a.action} |
2059
+ `;
2060
+ }
2061
+ text += "\n";
2062
+ }
2063
+ if (warning.length > 0) {
2064
+ text += "### Warning Assets (Monitor)\n";
2065
+ text += "| Asset | Type | Field | Label | Impr Decay | CTR Decline | CPA Change | Age | Note |\n";
2066
+ text += "|---|---|---|---|---|---|---|---|---|\n";
2067
+ for (const a of warning) {
2068
+ 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} |
2069
+ `;
2070
+ }
2071
+ text += "\n";
2072
+ }
2073
+ if (learning.length > 0) {
2074
+ text += "### Learning Assets (Do Not Remove)\n";
2075
+ text += "| Asset | Type | Field | Age | Note |\n";
2076
+ text += "|---|---|---|---|---|\n";
2077
+ for (const a of learning) {
2078
+ text += `| ${a.assetName} | ${a.assetType} | ${a.fieldType} | ${a.ageDays} days | ${a.action} |
2079
+ `;
2080
+ }
2081
+ text += "\n";
2082
+ }
2083
+ text += "### Recommendations\n";
2084
+ text += "Based on the analysis and Mobile Growth knowledge base:\n\n";
2085
+ if (fatigued.length > 0) {
2086
+ const pastRefresh = fatigued.filter((a) => a.ageDays > 75).length;
2087
+ text += `1. **Replace ${fatigued.length} fatigued assets**`;
2088
+ if (pastRefresh > 0) text += ` \u2014 prioritize the ${pastRefresh} past the 75-day refresh window`;
2089
+ text += "\n";
2090
+ }
2091
+ if (learning.length > 0) {
2092
+ text += `2. **Do NOT remove the ${learning.length} learning assets** \u2014 they need at least 14 days before evaluation (goog-pdf-018)
2093
+ `;
2094
+ }
2095
+ text += `3. **Replace 2-3 assets at a time**, not all at once \u2014 maintain algorithmic stability (goog-pdf-018)
2096
+ `;
2097
+ text += `4. **Add variants inspired by your "Best" rated assets** when replacing (goog-pdf-018)
2098
+ `;
2099
+ const mislabeled = fatigued.filter((a) => a.performanceLabel === "GOOD" || a.performanceLabel === "BEST");
2100
+ if (mislabeled.length > 0) {
2101
+ const names = mislabeled.map((a) => `"${a.assetName}"`).join(", ");
2102
+ 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)
2103
+ `;
2104
+ }
2105
+ text += `
2106
+ Sources: goog-pdf-018, ab-pt-008, goog-pdf-019, ab-pt-007
2107
+ `;
2108
+ return { content: [{ type: "text", text }] };
2109
+ } catch (err) {
2110
+ return {
2111
+ content: [
2112
+ {
2113
+ type: "text",
2114
+ text: `Error: ${err instanceof Error ? err.message : String(err)}`
2115
+ }
2116
+ ],
2117
+ isError: true
2118
+ };
2119
+ }
2120
+ }
2121
+ );
2122
+ }
2123
+
822
2124
  // src/tools/connection-status.ts
823
2125
  function registerConnectionStatus(server2, status2) {
824
2126
  server2.tool(
@@ -859,9 +2161,40 @@ function registerConnectionStatus(server2, status2) {
859
2161
  "",
860
2162
  "### How to fix",
861
2163
  "Provide your Meta access token using one of these methods:",
862
- "1. CLI argument: add `--meta-token=...` to the args array",
863
- '2. Environment variable: add `"META_ACCESS_TOKEN": "..."` to the env block',
864
- "3. `.env` file: add `META_ACCESS_TOKEN=...` to your `.env` file"
2164
+ '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
+ "2. CLI argument: add `--meta-token=...` to the args array",
2166
+ "3. `.env` file: add `META_ACCESS_TOKEN=...` to a `.env` file in your working directory",
2167
+ "",
2168
+ "Then restart your MCP client."
2169
+ );
2170
+ }
2171
+ lines.push("");
2172
+ if (status2.google.configured) {
2173
+ lines.push(
2174
+ "## Google Ads API: Configured",
2175
+ "- Google Ads tools are available and ready to use"
2176
+ );
2177
+ } else {
2178
+ lines.push(
2179
+ "## Google Ads API: Not Configured",
2180
+ "- Google Ads tools will return an error when called",
2181
+ `- Missing: ${status2.google.missing.join(", ")}`,
2182
+ "",
2183
+ "### How to fix",
2184
+ "Option 1 \u2014 Interactive setup (recommended):",
2185
+ "```",
2186
+ "npx mobile-growth-mcp auth google",
2187
+ "```",
2188
+ "This walks you through developer token, OAuth credentials, and authorization. Saves to `.env`.",
2189
+ "",
2190
+ 'Option 2 \u2014 Add credentials manually to the `"env"` block in your MCP config:',
2191
+ "- `GOOGLE_ADS_DEVELOPER_TOKEN`",
2192
+ "- `GOOGLE_ADS_CLIENT_ID`",
2193
+ "- `GOOGLE_ADS_CLIENT_SECRET`",
2194
+ "- `GOOGLE_ADS_REFRESH_TOKEN`",
2195
+ "- `GOOGLE_ADS_LOGIN_CUSTOMER_ID` (optional, for MCC accounts)",
2196
+ "",
2197
+ "Then restart your MCP client."
865
2198
  );
866
2199
  }
867
2200
  return {
@@ -983,6 +2316,19 @@ function registerVocabularyResource(server2) {
983
2316
  // src/resources/instructions.ts
984
2317
  var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Meta Ad Tools
985
2318
 
2319
+ ## Welcome
2320
+
2321
+ You're connected to the Mobile Growth knowledge base \u2014 curated expert insights on mobile advertising, campaign optimization, and subscription app growth.
2322
+
2323
+ **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
+
2325
+ Quick examples:
2326
+ - "subscription app creative fatigue signals"
2327
+ - "Meta CBO vs ABO tradeoffs for subscription apps"
2328
+ - "iOS attribution strategies post-ATT"
2329
+
2330
+ If you can't find what you need, call \`submit_feedback\` to report the gap \u2014 it helps us improve the knowledge base.
2331
+
986
2332
  ## What This Is
987
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.
988
2334
 
@@ -1004,6 +2350,12 @@ Browse all insights with optional filtering. Returns titles and metadata.
1004
2350
  Fetch the full content of a specific insight by ID or slug.
1005
2351
  - **id** (required): Numeric ID or string slug (e.g. "mb-li-001")
1006
2352
 
2353
+ ### 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)
2357
+ - **search_queries_tried** (optional): Search queries that returned poor/no results
2358
+
1007
2359
  ## Meta Marketing API Tools
1008
2360
 
1009
2361
  **Requires META_ACCESS_TOKEN env var** \u2014 without it, these tools return a clear error. Knowledge base tools work with just API_KEY.
@@ -1047,6 +2399,16 @@ Built-in report: detect creative fatigue via frequency, CTR decline, CPA trends.
1047
2399
  - **frequency_critical** (optional): default 5
1048
2400
  - **ctr_decline_threshold** (optional): default 30%
1049
2401
 
2402
+ ## Google Ads Tools
2403
+
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.
2405
+
2406
+ ### get_google_ads_campaigns
2407
+ List campaigns from a Google Ads account with key metrics (last 7 days).
2408
+ - **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)
2410
+ - **limit** (optional): Max campaigns to return (default 50)
2411
+
1050
2412
  ## Reports (MCP Prompts)
1051
2413
 
1052
2414
  Pre-built analysis workflows. Select a prompt and provide your ad_account_id to run:
@@ -1069,6 +2431,15 @@ Pre-built analysis workflows. Select a prompt and provide your ad_account_id to
1069
2431
  ### vocabulary://tags
1070
2432
  Lists all topic tags, applies_to tags, and platforms with counts.
1071
2433
 
2434
+ ## Presenting Results
2435
+
2436
+ 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
+
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
+
1072
2443
  ## Tips
1073
2444
  - Start with \`list_insights\` to see what's in the knowledge base
1074
2445
  - Use \`search_insights\` to find specific advice grounded in expert knowledge
@@ -1101,6 +2472,16 @@ function buildStatusSection(status2) {
1101
2472
  " - Fix: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
1102
2473
  );
1103
2474
  }
2475
+ if (status2.google.configured) {
2476
+ lines.push("- **Google Ads API**: Configured");
2477
+ } else {
2478
+ lines.push(
2479
+ "- **Google Ads API**: Not configured \u2014 Google Ads tools will return errors"
2480
+ );
2481
+ lines.push(
2482
+ " - Fix: run `npx mobile-growth-mcp auth google` to set up credentials"
2483
+ );
2484
+ }
1104
2485
  return lines.join("\n");
1105
2486
  }
1106
2487
  function registerInstructionsResource(server2, status2) {
@@ -1180,12 +2561,316 @@ function resolveApiKey() {
1180
2561
  function resolveMetaToken() {
1181
2562
  return resolve("META_ACCESS_TOKEN", "meta-token");
1182
2563
  }
2564
+ 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");
2570
+ const required = [
2571
+ { name: "GOOGLE_ADS_DEVELOPER_TOKEN", result: devToken },
2572
+ { name: "GOOGLE_ADS_CLIENT_ID", result: clientId },
2573
+ { name: "GOOGLE_ADS_CLIENT_SECRET", result: clientSecret },
2574
+ { name: "GOOGLE_ADS_REFRESH_TOKEN", result: refreshToken }
2575
+ ];
2576
+ const missing = required.filter((r) => !r.result.value).map((r) => r.name);
2577
+ const sources = {};
2578
+ for (const r of required) {
2579
+ sources[r.name] = r.result.source;
2580
+ }
2581
+ sources["GOOGLE_ADS_LOGIN_CUSTOMER_ID"] = loginCustomerId.source;
2582
+ return {
2583
+ configured: missing.length === 0,
2584
+ missing,
2585
+ sources,
2586
+ developerToken: devToken.value,
2587
+ clientId: clientId.value,
2588
+ clientSecret: clientSecret.value,
2589
+ refreshToken: refreshToken.value,
2590
+ loginCustomerId: loginCustomerId.value
2591
+ };
2592
+ }
2593
+
2594
+ // src/google/auth.ts
2595
+ import { createInterface } from "readline";
2596
+ import { createServer } from "http";
2597
+ import { exec } from "child_process";
2598
+ import { readFileSync as readFileSync2, appendFileSync, writeFileSync } from "fs";
2599
+ import { join as join2 } from "path";
2600
+ var OAUTH_PORT = 8549;
2601
+ var REDIRECT_URI = `http://localhost:${OAUTH_PORT}/callback`;
2602
+ var AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
2603
+ var TOKEN_URL2 = "https://oauth2.googleapis.com/token";
2604
+ var SCOPE = "https://www.googleapis.com/auth/adwords";
2605
+ async function maybeRunAuthCommand() {
2606
+ const args = process.argv.slice(2);
2607
+ if (args[0] !== "auth" || args[1] !== "google") return;
2608
+ await runGoogleAuthFlow();
2609
+ process.exit(0);
2610
+ }
2611
+ function prompt(rl, question) {
2612
+ return new Promise((resolve2) => {
2613
+ rl.question(question, (answer) => resolve2(answer.trim()));
2614
+ });
2615
+ }
2616
+ function openBrowser(url) {
2617
+ const platform = process.platform;
2618
+ const cmd = platform === "darwin" ? `open "${url}"` : platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
2619
+ exec(cmd, (err) => {
2620
+ if (err) {
2621
+ console.error(` Could not open browser automatically.`);
2622
+ console.error(` Open this URL manually:
2623
+ ${url}`);
2624
+ }
2625
+ });
2626
+ }
2627
+ function getDotEnvPath() {
2628
+ return join2(process.cwd(), ".env");
2629
+ }
2630
+ function readExistingEnv() {
2631
+ try {
2632
+ const content = readFileSync2(getDotEnvPath(), "utf-8");
2633
+ const vars = {};
2634
+ for (const line of content.split("\n")) {
2635
+ const trimmed = line.trim();
2636
+ if (!trimmed || trimmed.startsWith("#")) continue;
2637
+ const eq = trimmed.indexOf("=");
2638
+ if (eq === -1) continue;
2639
+ vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
2640
+ }
2641
+ return vars;
2642
+ } catch {
2643
+ return {};
2644
+ }
2645
+ }
2646
+ function saveToEnv(vars) {
2647
+ const envPath = getDotEnvPath();
2648
+ const existing = readExistingEnv();
2649
+ const overwritten = [];
2650
+ for (const key of Object.keys(vars)) {
2651
+ if (existing[key]) {
2652
+ overwritten.push(key);
2653
+ }
2654
+ }
2655
+ if (overwritten.length > 0) {
2656
+ console.log(`
2657
+ Overwriting existing keys: ${overwritten.join(", ")}`);
2658
+ let content;
2659
+ try {
2660
+ content = readFileSync2(envPath, "utf-8");
2661
+ } catch {
2662
+ content = "";
2663
+ }
2664
+ const lines = content.split("\n");
2665
+ const updated = /* @__PURE__ */ new Set();
2666
+ for (let i = 0; i < lines.length; i++) {
2667
+ const trimmed = lines[i].trim();
2668
+ if (!trimmed || trimmed.startsWith("#")) continue;
2669
+ const eq = trimmed.indexOf("=");
2670
+ if (eq === -1) continue;
2671
+ const key = trimmed.slice(0, eq).trim();
2672
+ if (key in vars) {
2673
+ lines[i] = `${key}=${vars[key]}`;
2674
+ updated.add(key);
2675
+ }
2676
+ }
2677
+ for (const [key, value] of Object.entries(vars)) {
2678
+ if (!updated.has(key)) {
2679
+ lines.push(`${key}=${value}`);
2680
+ }
2681
+ }
2682
+ writeFileSync(envPath, lines.join("\n"));
2683
+ } else {
2684
+ const block = Object.entries(vars).map(([k, v]) => `${k}=${v}`).join("\n");
2685
+ let prefix = "\n";
2686
+ try {
2687
+ const existing2 = readFileSync2(envPath, "utf-8");
2688
+ if (existing2.length > 0 && !existing2.endsWith("\n")) {
2689
+ prefix = "\n\n";
2690
+ } else if (existing2.endsWith("\n")) {
2691
+ prefix = "";
2692
+ }
2693
+ } catch {
2694
+ prefix = "";
2695
+ }
2696
+ appendFileSync(envPath, `${prefix}${block}
2697
+ `);
2698
+ }
2699
+ }
2700
+ async function waitForOAuthCallback(clientId, clientSecret) {
2701
+ return new Promise((resolve2, reject) => {
2702
+ const server2 = createServer(
2703
+ async (req, res) => {
2704
+ const url = new URL(req.url ?? "/", `http://localhost:${OAUTH_PORT}`);
2705
+ if (url.pathname !== "/callback") {
2706
+ res.writeHead(404);
2707
+ res.end("Not found");
2708
+ return;
2709
+ }
2710
+ const code = url.searchParams.get("code");
2711
+ const error = url.searchParams.get("error");
2712
+ if (error) {
2713
+ res.writeHead(200, { "Content-Type": "text/html" });
2714
+ res.end(
2715
+ "<html><body><h2>Authorization failed</h2><p>You can close this tab.</p></body></html>"
2716
+ );
2717
+ server2.close();
2718
+ reject(new Error(`OAuth authorization denied: ${error}`));
2719
+ return;
2720
+ }
2721
+ if (!code) {
2722
+ res.writeHead(400, { "Content-Type": "text/html" });
2723
+ res.end(
2724
+ "<html><body><h2>Missing authorization code</h2></body></html>"
2725
+ );
2726
+ return;
2727
+ }
2728
+ try {
2729
+ const tokenBody = new URLSearchParams({
2730
+ code,
2731
+ client_id: clientId,
2732
+ client_secret: clientSecret,
2733
+ redirect_uri: REDIRECT_URI,
2734
+ grant_type: "authorization_code"
2735
+ });
2736
+ const tokenRes = await fetch(TOKEN_URL2, {
2737
+ method: "POST",
2738
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2739
+ body: tokenBody.toString()
2740
+ });
2741
+ if (!tokenRes.ok) {
2742
+ const text = await tokenRes.text();
2743
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
2744
+ }
2745
+ const tokenData = await tokenRes.json();
2746
+ if (!tokenData.refresh_token) {
2747
+ throw new Error(
2748
+ "No refresh token received. Make sure prompt=consent is set and try again."
2749
+ );
2750
+ }
2751
+ res.writeHead(200, { "Content-Type": "text/html" });
2752
+ res.end(
2753
+ "<html><body><h2>Success!</h2><p>You can close this tab and return to the terminal.</p></body></html>"
2754
+ );
2755
+ server2.close();
2756
+ resolve2({ refreshToken: tokenData.refresh_token });
2757
+ } catch (err) {
2758
+ res.writeHead(500, { "Content-Type": "text/html" });
2759
+ res.end(
2760
+ "<html><body><h2>Token exchange failed</h2><p>Check the terminal for details.</p></body></html>"
2761
+ );
2762
+ server2.close();
2763
+ reject(err);
2764
+ }
2765
+ }
2766
+ );
2767
+ server2.listen(OAUTH_PORT, () => {
2768
+ });
2769
+ server2.on("error", (err) => {
2770
+ if (err.code === "EADDRINUSE") {
2771
+ reject(
2772
+ new Error(
2773
+ `Port ${OAUTH_PORT} is already in use. Close the process using it and try again.`
2774
+ )
2775
+ );
2776
+ } else {
2777
+ reject(err);
2778
+ }
2779
+ });
2780
+ });
2781
+ }
2782
+ async function runGoogleAuthFlow() {
2783
+ console.log("\nGoogle Ads Setup");
2784
+ console.log("================\n");
2785
+ const rl = createInterface({
2786
+ input: process.stdin,
2787
+ output: process.stdout
2788
+ });
2789
+ try {
2790
+ console.log("Step 1: Developer token");
2791
+ console.log(" (Found in Google Ads \u2192 Tools \u2192 API Center)\n");
2792
+ const developerToken = await prompt(rl, " Developer token: ");
2793
+ if (!developerToken) {
2794
+ console.error("\n Developer token is required.");
2795
+ return;
2796
+ }
2797
+ const loginCustomerId = await prompt(
2798
+ rl,
2799
+ " MCC account ID (optional, press Enter to skip): "
2800
+ );
2801
+ console.log("\nStep 2: OAuth credentials");
2802
+ console.log(
2803
+ " (Create in Google Cloud Console \u2192 APIs & Services \u2192 Credentials)\n"
2804
+ );
2805
+ console.log(` Redirect URI to register: ${REDIRECT_URI}
2806
+ `);
2807
+ const clientId = await prompt(rl, " Client ID: ");
2808
+ if (!clientId) {
2809
+ console.error("\n Client ID is required.");
2810
+ return;
2811
+ }
2812
+ const clientSecret = await prompt(rl, " Client secret: ");
2813
+ if (!clientSecret) {
2814
+ console.error("\n Client secret is required.");
2815
+ return;
2816
+ }
2817
+ rl.close();
2818
+ console.log("\nStep 3: Authorization");
2819
+ const authParams = new URLSearchParams({
2820
+ client_id: clientId,
2821
+ redirect_uri: REDIRECT_URI,
2822
+ response_type: "code",
2823
+ scope: SCOPE,
2824
+ access_type: "offline",
2825
+ prompt: "consent"
2826
+ });
2827
+ const authUrl = `${AUTH_URL}?${authParams.toString()}`;
2828
+ console.log(" Opening browser...\n");
2829
+ openBrowser(authUrl);
2830
+ console.log(` If the browser didn't open, visit:
2831
+ ${authUrl}
2832
+ `);
2833
+ const { refreshToken } = await waitForOAuthCallback(clientId, clientSecret);
2834
+ const envVars = {
2835
+ GOOGLE_ADS_DEVELOPER_TOKEN: developerToken,
2836
+ GOOGLE_ADS_CLIENT_ID: clientId,
2837
+ GOOGLE_ADS_CLIENT_SECRET: clientSecret,
2838
+ GOOGLE_ADS_REFRESH_TOKEN: refreshToken
2839
+ };
2840
+ if (loginCustomerId) {
2841
+ envVars.GOOGLE_ADS_LOGIN_CUSTOMER_ID = loginCustomerId;
2842
+ }
2843
+ saveToEnv(envVars);
2844
+ console.log(`
2845
+ \u2713 Saved to .env. Restart your MCP client to use Google Ads tools.`);
2846
+ } catch (err) {
2847
+ console.error(
2848
+ `
2849
+ Error: ${err instanceof Error ? err.message : String(err)}`
2850
+ );
2851
+ process.exit(1);
2852
+ } finally {
2853
+ rl.close();
2854
+ }
2855
+ }
1183
2856
 
1184
2857
  // src/index.ts
2858
+ await maybeRunAuthCommand();
1185
2859
  var apiKeyResult = resolveApiKey();
1186
2860
  var metaTokenResult = resolveMetaToken();
2861
+ var googleAdsResult = resolveGoogleAdsConfig();
1187
2862
  if (apiKeyResult.value) process.env.API_KEY = apiKeyResult.value;
1188
2863
  if (metaTokenResult.value) process.env.META_ACCESS_TOKEN = metaTokenResult.value;
2864
+ if (googleAdsResult.developerToken)
2865
+ process.env.GOOGLE_ADS_DEVELOPER_TOKEN = googleAdsResult.developerToken;
2866
+ if (googleAdsResult.clientId)
2867
+ process.env.GOOGLE_ADS_CLIENT_ID = googleAdsResult.clientId;
2868
+ if (googleAdsResult.clientSecret)
2869
+ process.env.GOOGLE_ADS_CLIENT_SECRET = googleAdsResult.clientSecret;
2870
+ if (googleAdsResult.refreshToken)
2871
+ process.env.GOOGLE_ADS_REFRESH_TOKEN = googleAdsResult.refreshToken;
2872
+ if (googleAdsResult.loginCustomerId)
2873
+ process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID = googleAdsResult.loginCustomerId;
1189
2874
  var apiKey = apiKeyResult.value;
1190
2875
  console.error(
1191
2876
  apiKey ? `API key: ${apiKeyResult.source}` : "API key: not configured \u2014 KB tools will not be available"
@@ -1193,6 +2878,9 @@ console.error(
1193
2878
  console.error(
1194
2879
  metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured \u2014 Meta tools will return errors when called"
1195
2880
  );
2881
+ 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`
2883
+ );
1196
2884
  var server = new McpServer({
1197
2885
  name: "mobile-growth-mcp",
1198
2886
  version: "2.0.0"
@@ -1200,6 +2888,10 @@ var server = new McpServer({
1200
2888
  var status = {
1201
2889
  kb: { connected: false, toolCount: 0, promptCount: 0 },
1202
2890
  meta: { tokenConfigured: !!metaTokenResult.value },
2891
+ google: {
2892
+ configured: googleAdsResult.configured,
2893
+ missing: googleAdsResult.missing
2894
+ },
1203
2895
  apiKey: { source: apiKeyResult.source }
1204
2896
  };
1205
2897
  if (apiKey) {
@@ -1223,6 +2915,12 @@ registerGetMetaAdSets(server);
1223
2915
  registerGetMetaAds(server);
1224
2916
  registerGetMetaInsights(server);
1225
2917
  registerGetMetaAdFatigue(server);
2918
+ registerGetGoogleAdsCampaigns(server);
2919
+ registerGetGoogleAdsAdGroups(server);
2920
+ registerGetGoogleAdsAssets(server);
2921
+ registerGetGoogleAdsInsights(server);
2922
+ registerGetGoogleAdsNetworkMix(server);
2923
+ registerGetGoogleAdsAssetFatigue(server);
1226
2924
  registerConnectionStatus(server, status);
1227
2925
  registerVocabularyResource(server);
1228
2926
  registerInstructionsResource(server, status);