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.
- package/dist/index.js +1710 -12
- 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
|
|
119
|
+
for (const prompt2 of prompts) {
|
|
120
120
|
const zodShape = {};
|
|
121
|
-
for (const arg of
|
|
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
|
-
|
|
130
|
-
|
|
129
|
+
prompt2.name,
|
|
130
|
+
prompt2.description,
|
|
131
131
|
zodShape,
|
|
132
132
|
async (args) => {
|
|
133
133
|
const messages = await getRemotePrompt(
|
|
134
134
|
apiKey2,
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
863
|
-
|
|
864
|
-
"3. `.env` file: add `META_ACCESS_TOKEN=...` to
|
|
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);
|