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