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.
- package/dist/index.js +1321 -81
- 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(
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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:
|
|
54
|
-
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
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
961
|
-
|
|
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
|
|
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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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.
|
|
982
|
-
AND
|
|
983
|
-
|
|
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:
|
|
1118
|
+
text: "No campaigns found matching filters. Try expanding status filter to include PAUSED."
|
|
994
1119
|
}
|
|
995
1120
|
]
|
|
996
1121
|
};
|
|
997
1122
|
}
|
|
998
|
-
|
|
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
|
|
1006
|
-
const
|
|
1007
|
-
const
|
|
1008
|
-
const
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
|
1068
|
-
"-
|
|
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
|
|
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
|
|
1088
|
-
"-
|
|
1089
|
-
|
|
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
|
|
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 +
|
|
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.
|
|
1263
|
-
- **category** (required): missing_knowledge, missing_feature, search_quality, or other
|
|
1264
|
-
- **summary** (required): What was needed
|
|
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.
|
|
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
|
|
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,
|
|
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
|
-
|
|
|
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
|
|
1347
|
-
- **Cite source author + slug** for key claims (e.g. "
|
|
1348
|
-
- **When multiple insights support a recommendation**, mention the count (e.g. "3 insights in the KB agree that
|
|
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
|
|
1350
|
-
|
|
1351
|
-
## Tips
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
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**:
|
|
2612
|
+
"- **Meta Marketing API**: Not connected (optional \u2014 KB works without it)"
|
|
1378
2613
|
);
|
|
1379
2614
|
lines.push(
|
|
1380
|
-
" -
|
|
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
|
|
2622
|
+
"- **Google Ads API**: Not connected (optional \u2014 KB works without it)"
|
|
1388
2623
|
);
|
|
1389
2624
|
lines.push(
|
|
1390
|
-
" -
|
|
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
|
|
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
|
|
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 (
|
|
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);
|