mobile-growth-mcp 2.0.6 → 2.1.2
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 +610 -9
- package/package.json +9 -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;
|
|
@@ -819,6 +819,216 @@ ${text}`;
|
|
|
819
819
|
);
|
|
820
820
|
}
|
|
821
821
|
|
|
822
|
+
// src/tools/google-campaigns.ts
|
|
823
|
+
import { z as z7 } from "zod";
|
|
824
|
+
|
|
825
|
+
// src/google/oauth.ts
|
|
826
|
+
var TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
827
|
+
var EXPIRY_SAFETY_MARGIN_MS = 6e4;
|
|
828
|
+
function createGoogleAdsAuth(config) {
|
|
829
|
+
let cached;
|
|
830
|
+
async function refreshAccessToken() {
|
|
831
|
+
const body = new URLSearchParams({
|
|
832
|
+
grant_type: "refresh_token",
|
|
833
|
+
client_id: config.clientId,
|
|
834
|
+
client_secret: config.clientSecret,
|
|
835
|
+
refresh_token: config.refreshToken
|
|
836
|
+
});
|
|
837
|
+
const response = await fetch(TOKEN_URL, {
|
|
838
|
+
method: "POST",
|
|
839
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
840
|
+
body: body.toString()
|
|
841
|
+
});
|
|
842
|
+
if (!response.ok) {
|
|
843
|
+
const text = await response.text();
|
|
844
|
+
throw new Error(
|
|
845
|
+
`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.`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
const data = await response.json();
|
|
849
|
+
return {
|
|
850
|
+
accessToken: data.access_token,
|
|
851
|
+
expiresAt: Date.now() + data.expires_in * 1e3 - EXPIRY_SAFETY_MARGIN_MS
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
async function getAccessToken() {
|
|
855
|
+
if (!cached || Date.now() >= cached.expiresAt) {
|
|
856
|
+
cached = await refreshAccessToken();
|
|
857
|
+
}
|
|
858
|
+
return cached.accessToken;
|
|
859
|
+
}
|
|
860
|
+
async function getHeaders(customerId) {
|
|
861
|
+
const token = await getAccessToken();
|
|
862
|
+
const headers = {
|
|
863
|
+
Authorization: `Bearer ${token}`,
|
|
864
|
+
"developer-token": config.developerToken
|
|
865
|
+
};
|
|
866
|
+
if (config.loginCustomerId) {
|
|
867
|
+
headers["login-customer-id"] = config.loginCustomerId.replace(/-/g, "");
|
|
868
|
+
}
|
|
869
|
+
return headers;
|
|
870
|
+
}
|
|
871
|
+
return { getAccessToken, getHeaders };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// src/google/client.ts
|
|
875
|
+
var API_VERSION = "v19";
|
|
876
|
+
var BASE_URL = `https://googleads.googleapis.com/${API_VERSION}`;
|
|
877
|
+
var authSingleton;
|
|
878
|
+
function getGoogleAdsAuth() {
|
|
879
|
+
if (authSingleton) return authSingleton;
|
|
880
|
+
const developerToken = process.env.GOOGLE_ADS_DEVELOPER_TOKEN;
|
|
881
|
+
const clientId = process.env.GOOGLE_ADS_CLIENT_ID;
|
|
882
|
+
const clientSecret = process.env.GOOGLE_ADS_CLIENT_SECRET;
|
|
883
|
+
const refreshToken = process.env.GOOGLE_ADS_REFRESH_TOKEN;
|
|
884
|
+
const loginCustomerId = process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID;
|
|
885
|
+
if (!developerToken || !clientId || !clientSecret || !refreshToken) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
'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.'
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
const config = {
|
|
891
|
+
developerToken,
|
|
892
|
+
clientId,
|
|
893
|
+
clientSecret,
|
|
894
|
+
refreshToken,
|
|
895
|
+
loginCustomerId
|
|
896
|
+
};
|
|
897
|
+
authSingleton = createGoogleAdsAuth(config);
|
|
898
|
+
return authSingleton;
|
|
899
|
+
}
|
|
900
|
+
function normalizeCustomerId(customerId) {
|
|
901
|
+
return customerId.replace(/-/g, "");
|
|
902
|
+
}
|
|
903
|
+
function isGoogleAdsError(body) {
|
|
904
|
+
return typeof body === "object" && body !== null && "error" in body && typeof body.error?.message === "string";
|
|
905
|
+
}
|
|
906
|
+
function formatGoogleAdsError(err) {
|
|
907
|
+
const code = err.code;
|
|
908
|
+
if (code === 401) {
|
|
909
|
+
return "Authentication error: Your Google Ads credentials are invalid or expired. Run `npx mobile-growth-mcp auth google` to re-authorize.";
|
|
910
|
+
}
|
|
911
|
+
if (code === 403) {
|
|
912
|
+
return `Permission denied: ${err.message}. Check that your developer token is approved and the account ID is correct.`;
|
|
913
|
+
}
|
|
914
|
+
if (code === 429) {
|
|
915
|
+
return `Rate limit hit: ${err.message}. Wait a few minutes before retrying.`;
|
|
916
|
+
}
|
|
917
|
+
if (code === 400 && err.details?.length) {
|
|
918
|
+
const gaqlErrors = err.details.flatMap((d) => d.errors ?? []).map((e) => e.message).join("; ");
|
|
919
|
+
if (gaqlErrors) {
|
|
920
|
+
return `Invalid query: ${gaqlErrors}`;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return `Google Ads API error (${code}): ${err.message}`;
|
|
924
|
+
}
|
|
925
|
+
async function googleAdsQuery(customerId, query) {
|
|
926
|
+
const auth = getGoogleAdsAuth();
|
|
927
|
+
const normalizedId = normalizeCustomerId(customerId);
|
|
928
|
+
const headers = await auth.getHeaders(normalizedId);
|
|
929
|
+
const url = `${BASE_URL}/customers/${normalizedId}/googleAds:searchStream`;
|
|
930
|
+
const response = await fetch(url, {
|
|
931
|
+
method: "POST",
|
|
932
|
+
headers: {
|
|
933
|
+
...headers,
|
|
934
|
+
"Content-Type": "application/json"
|
|
935
|
+
},
|
|
936
|
+
body: JSON.stringify({ query })
|
|
937
|
+
});
|
|
938
|
+
const body = await response.json();
|
|
939
|
+
if (!response.ok) {
|
|
940
|
+
if (isGoogleAdsError(body)) {
|
|
941
|
+
throw new Error(formatGoogleAdsError(body.error));
|
|
942
|
+
}
|
|
943
|
+
throw new Error(
|
|
944
|
+
`Google Ads API returned ${response.status}: ${JSON.stringify(body)}`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
if (Array.isArray(body)) {
|
|
948
|
+
return body;
|
|
949
|
+
}
|
|
950
|
+
return [body];
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// src/tools/google-campaigns.ts
|
|
954
|
+
function registerGetGoogleAdsCampaigns(server2) {
|
|
955
|
+
server2.tool(
|
|
956
|
+
"get_google_ads_campaigns",
|
|
957
|
+
"List campaigns from a Google Ads account with key metrics. Requires Google Ads credentials \u2014 run `npx mobile-growth-mcp auth google` to set up.",
|
|
958
|
+
{
|
|
959
|
+
customer_id: z7.string().describe("Google Ads customer ID (e.g. 123-456-7890 or 1234567890)"),
|
|
960
|
+
status: z7.enum(["ENABLED", "PAUSED", "REMOVED"]).optional().describe("Filter by campaign status. Default: ENABLED"),
|
|
961
|
+
limit: z7.number().min(1).max(1e3).optional().describe("Max campaigns to return (default 50)")
|
|
962
|
+
},
|
|
963
|
+
async ({ customer_id, status: status2, limit }) => {
|
|
964
|
+
try {
|
|
965
|
+
const statusFilter = status2 ?? "ENABLED";
|
|
966
|
+
const rowLimit = limit ?? 50;
|
|
967
|
+
const query = `
|
|
968
|
+
SELECT
|
|
969
|
+
campaign.id,
|
|
970
|
+
campaign.name,
|
|
971
|
+
campaign.status,
|
|
972
|
+
campaign.advertising_channel_type,
|
|
973
|
+
campaign.bidding_strategy_type,
|
|
974
|
+
metrics.impressions,
|
|
975
|
+
metrics.clicks,
|
|
976
|
+
metrics.cost_micros,
|
|
977
|
+
metrics.conversions,
|
|
978
|
+
metrics.ctr,
|
|
979
|
+
metrics.average_cpc
|
|
980
|
+
FROM campaign
|
|
981
|
+
WHERE campaign.status = '${statusFilter}'
|
|
982
|
+
AND segments.date DURING LAST_7_DAYS
|
|
983
|
+
ORDER BY metrics.cost_micros DESC
|
|
984
|
+
LIMIT ${rowLimit}
|
|
985
|
+
`;
|
|
986
|
+
const chunks = await googleAdsQuery(customer_id, query);
|
|
987
|
+
const rows = chunks.flatMap((c) => c.results ?? []);
|
|
988
|
+
if (rows.length === 0) {
|
|
989
|
+
return {
|
|
990
|
+
content: [
|
|
991
|
+
{
|
|
992
|
+
type: "text",
|
|
993
|
+
text: `No ${statusFilter.toLowerCase()} campaigns found for customer ${customer_id} in the last 7 days.`
|
|
994
|
+
}
|
|
995
|
+
]
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
let text = `Found ${rows.length} ${statusFilter.toLowerCase()} campaigns (last 7 days):
|
|
999
|
+
|
|
1000
|
+
`;
|
|
1001
|
+
for (const row of rows) {
|
|
1002
|
+
const c = row.campaign;
|
|
1003
|
+
const m = row.metrics;
|
|
1004
|
+
if (!c) continue;
|
|
1005
|
+
const cost = m?.costMicros ? `$${(parseInt(m.costMicros) / 1e6).toFixed(2)}` : "$0.00";
|
|
1006
|
+
const impressions = m?.impressions ?? "0";
|
|
1007
|
+
const clicks = m?.clicks ?? "0";
|
|
1008
|
+
const conversions = m?.conversions !== void 0 ? m.conversions.toFixed(1) : "0";
|
|
1009
|
+
const ctr = m?.ctr !== void 0 ? `${(m.ctr * 100).toFixed(2)}%` : "\u2014";
|
|
1010
|
+
const avgCpc = m?.averageCpc ? `$${(parseInt(m.averageCpc) / 1e6).toFixed(2)}` : "\u2014";
|
|
1011
|
+
text += `- **${c.name}** (${c.id})
|
|
1012
|
+
Status: ${c.status}` + (c.advertisingChannelType ? ` | Channel: ${c.advertisingChannelType}` : "") + (c.biddingStrategyType ? ` | Bidding: ${c.biddingStrategyType}` : "") + `
|
|
1013
|
+
Spend: ${cost} | Impr: ${impressions} | Clicks: ${clicks} | CTR: ${ctr} | Avg CPC: ${avgCpc} | Conv: ${conversions}
|
|
1014
|
+
`;
|
|
1015
|
+
}
|
|
1016
|
+
return { content: [{ type: "text", text }] };
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
return {
|
|
1019
|
+
content: [
|
|
1020
|
+
{
|
|
1021
|
+
type: "text",
|
|
1022
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
1023
|
+
}
|
|
1024
|
+
],
|
|
1025
|
+
isError: true
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
822
1032
|
// src/tools/connection-status.ts
|
|
823
1033
|
function registerConnectionStatus(server2, status2) {
|
|
824
1034
|
server2.tool(
|
|
@@ -859,9 +1069,40 @@ function registerConnectionStatus(server2, status2) {
|
|
|
859
1069
|
"",
|
|
860
1070
|
"### How to fix",
|
|
861
1071
|
"Provide your Meta access token using one of these methods:",
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
"3. `.env` file: add `META_ACCESS_TOKEN=...` to
|
|
1072
|
+
'1. MCP config: add `"META_ACCESS_TOKEN": "..."` to the `"env"` block in `.mcp.json` (Claude Code/Cursor) or `claude_desktop_config.json` (Claude Desktop)',
|
|
1073
|
+
"2. CLI argument: add `--meta-token=...` to the args array",
|
|
1074
|
+
"3. `.env` file: add `META_ACCESS_TOKEN=...` to a `.env` file in your working directory",
|
|
1075
|
+
"",
|
|
1076
|
+
"Then restart your MCP client."
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
lines.push("");
|
|
1080
|
+
if (status2.google.configured) {
|
|
1081
|
+
lines.push(
|
|
1082
|
+
"## Google Ads API: Configured",
|
|
1083
|
+
"- Google Ads tools are available and ready to use"
|
|
1084
|
+
);
|
|
1085
|
+
} else {
|
|
1086
|
+
lines.push(
|
|
1087
|
+
"## Google Ads API: Not Configured",
|
|
1088
|
+
"- Google Ads tools will return an error when called",
|
|
1089
|
+
`- Missing: ${status2.google.missing.join(", ")}`,
|
|
1090
|
+
"",
|
|
1091
|
+
"### How to fix",
|
|
1092
|
+
"Option 1 \u2014 Interactive setup (recommended):",
|
|
1093
|
+
"```",
|
|
1094
|
+
"npx mobile-growth-mcp auth google",
|
|
1095
|
+
"```",
|
|
1096
|
+
"This walks you through developer token, OAuth credentials, and authorization. Saves to `.env`.",
|
|
1097
|
+
"",
|
|
1098
|
+
'Option 2 \u2014 Add credentials manually to the `"env"` block in your MCP config:',
|
|
1099
|
+
"- `GOOGLE_ADS_DEVELOPER_TOKEN`",
|
|
1100
|
+
"- `GOOGLE_ADS_CLIENT_ID`",
|
|
1101
|
+
"- `GOOGLE_ADS_CLIENT_SECRET`",
|
|
1102
|
+
"- `GOOGLE_ADS_REFRESH_TOKEN`",
|
|
1103
|
+
"- `GOOGLE_ADS_LOGIN_CUSTOMER_ID` (optional, for MCC accounts)",
|
|
1104
|
+
"",
|
|
1105
|
+
"Then restart your MCP client."
|
|
865
1106
|
);
|
|
866
1107
|
}
|
|
867
1108
|
return {
|
|
@@ -983,6 +1224,19 @@ function registerVocabularyResource(server2) {
|
|
|
983
1224
|
// src/resources/instructions.ts
|
|
984
1225
|
var INSTRUCTIONS = `# Mobile Growth MCP \u2014 Knowledge Base + Meta Ad Tools
|
|
985
1226
|
|
|
1227
|
+
## Welcome
|
|
1228
|
+
|
|
1229
|
+
You're connected to the Mobile Growth knowledge base \u2014 curated expert insights on mobile advertising, campaign optimization, and subscription app growth.
|
|
1230
|
+
|
|
1231
|
+
**The knowledge base is always on.** Use \`search_insights\` freely \u2014 before making recommendations, when diagnosing issues, or exploring strategies. The more specific your query, the better the results.
|
|
1232
|
+
|
|
1233
|
+
Quick examples:
|
|
1234
|
+
- "subscription app creative fatigue signals"
|
|
1235
|
+
- "Meta CBO vs ABO tradeoffs for subscription apps"
|
|
1236
|
+
- "iOS attribution strategies post-ATT"
|
|
1237
|
+
|
|
1238
|
+
If you can't find what you need, call \`submit_feedback\` to report the gap \u2014 it helps us improve the knowledge base.
|
|
1239
|
+
|
|
986
1240
|
## What This Is
|
|
987
1241
|
A curated knowledge base of mobile advertising insights + direct Meta Marketing API integration. Query expert knowledge, pull live campaign data, and run pre-built reports \u2014 all from your LLM.
|
|
988
1242
|
|
|
@@ -1004,6 +1258,12 @@ Browse all insights with optional filtering. Returns titles and metadata.
|
|
|
1004
1258
|
Fetch the full content of a specific insight by ID or slug.
|
|
1005
1259
|
- **id** (required): Numeric ID or string slug (e.g. "mb-li-001")
|
|
1006
1260
|
|
|
1261
|
+
### submit_feedback
|
|
1262
|
+
Report a gap in the knowledge base or a missing capability. Helps improve the product.
|
|
1263
|
+
- **category** (required): missing_knowledge, missing_feature, search_quality, or other
|
|
1264
|
+
- **summary** (required): What was needed but not available (anonymized \u2014 no account IDs or tokens)
|
|
1265
|
+
- **search_queries_tried** (optional): Search queries that returned poor/no results
|
|
1266
|
+
|
|
1007
1267
|
## Meta Marketing API Tools
|
|
1008
1268
|
|
|
1009
1269
|
**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 +1307,16 @@ Built-in report: detect creative fatigue via frequency, CTR decline, CPA trends.
|
|
|
1047
1307
|
- **frequency_critical** (optional): default 5
|
|
1048
1308
|
- **ctr_decline_threshold** (optional): default 30%
|
|
1049
1309
|
|
|
1310
|
+
## Google Ads Tools
|
|
1311
|
+
|
|
1312
|
+
**Requires Google Ads credentials** \u2014 run \`npx mobile-growth-mcp auth google\` to set up interactively. This walks you through developer token, OAuth app, and authorization. Credentials are saved to \`.env\` and never leave the user's machine.
|
|
1313
|
+
|
|
1314
|
+
### get_google_ads_campaigns
|
|
1315
|
+
List campaigns from a Google Ads account with key metrics (last 7 days).
|
|
1316
|
+
- **customer_id** (required): Google Ads customer ID (e.g. "123-456-7890")
|
|
1317
|
+
- **status** (optional): Filter by status \u2014 ENABLED, PAUSED, or REMOVED (default: ENABLED)
|
|
1318
|
+
- **limit** (optional): Max campaigns to return (default 50)
|
|
1319
|
+
|
|
1050
1320
|
## Reports (MCP Prompts)
|
|
1051
1321
|
|
|
1052
1322
|
Pre-built analysis workflows. Select a prompt and provide your ad_account_id to run:
|
|
@@ -1069,6 +1339,15 @@ Pre-built analysis workflows. Select a prompt and provide your ad_account_id to
|
|
|
1069
1339
|
### vocabulary://tags
|
|
1070
1340
|
Lists all topic tags, applies_to tags, and platforms with counts.
|
|
1071
1341
|
|
|
1342
|
+
## Presenting Results
|
|
1343
|
+
|
|
1344
|
+
When your response draws on knowledge base results, **always attribute visibly** so the user knows the value came from the curated KB, not your general training data:
|
|
1345
|
+
|
|
1346
|
+
- **Tell the user** the information comes from the Mobile Growth knowledge base (e.g. "According to the Mobile Growth KB\u2026" or "The knowledge base recommends\u2026")
|
|
1347
|
+
- **Cite source author + slug** for key claims (e.g. "\u2026(source: Eric Seufert, \`mb-li-001\`)")
|
|
1348
|
+
- **When multiple insights support a recommendation**, mention the count (e.g. "3 insights in the KB agree that\u2026")
|
|
1349
|
+
- **Distinguish KB-sourced advice from your own reasoning** \u2014 if you're adding your own analysis on top of KB results, make that clear (e.g. "The KB covers X; based on that, my suggestion is Y")
|
|
1350
|
+
|
|
1072
1351
|
## Tips
|
|
1073
1352
|
- Start with \`list_insights\` to see what's in the knowledge base
|
|
1074
1353
|
- Use \`search_insights\` to find specific advice grounded in expert knowledge
|
|
@@ -1101,6 +1380,16 @@ function buildStatusSection(status2) {
|
|
|
1101
1380
|
" - Fix: provide your token via `--meta-token=...` CLI arg, `META_ACCESS_TOKEN` env var, or `.env` file"
|
|
1102
1381
|
);
|
|
1103
1382
|
}
|
|
1383
|
+
if (status2.google.configured) {
|
|
1384
|
+
lines.push("- **Google Ads API**: Configured");
|
|
1385
|
+
} else {
|
|
1386
|
+
lines.push(
|
|
1387
|
+
"- **Google Ads API**: Not configured \u2014 Google Ads tools will return errors"
|
|
1388
|
+
);
|
|
1389
|
+
lines.push(
|
|
1390
|
+
" - Fix: run `npx mobile-growth-mcp auth google` to set up credentials"
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1104
1393
|
return lines.join("\n");
|
|
1105
1394
|
}
|
|
1106
1395
|
function registerInstructionsResource(server2, status2) {
|
|
@@ -1180,12 +1469,316 @@ function resolveApiKey() {
|
|
|
1180
1469
|
function resolveMetaToken() {
|
|
1181
1470
|
return resolve("META_ACCESS_TOKEN", "meta-token");
|
|
1182
1471
|
}
|
|
1472
|
+
function resolveGoogleAdsConfig() {
|
|
1473
|
+
const devToken = resolve("GOOGLE_ADS_DEVELOPER_TOKEN", "google-dev-token");
|
|
1474
|
+
const clientId = resolve("GOOGLE_ADS_CLIENT_ID", "google-client-id");
|
|
1475
|
+
const clientSecret = resolve("GOOGLE_ADS_CLIENT_SECRET", "google-client-secret");
|
|
1476
|
+
const refreshToken = resolve("GOOGLE_ADS_REFRESH_TOKEN", "google-refresh-token");
|
|
1477
|
+
const loginCustomerId = resolve("GOOGLE_ADS_LOGIN_CUSTOMER_ID", "google-login-customer-id");
|
|
1478
|
+
const required = [
|
|
1479
|
+
{ name: "GOOGLE_ADS_DEVELOPER_TOKEN", result: devToken },
|
|
1480
|
+
{ name: "GOOGLE_ADS_CLIENT_ID", result: clientId },
|
|
1481
|
+
{ name: "GOOGLE_ADS_CLIENT_SECRET", result: clientSecret },
|
|
1482
|
+
{ name: "GOOGLE_ADS_REFRESH_TOKEN", result: refreshToken }
|
|
1483
|
+
];
|
|
1484
|
+
const missing = required.filter((r) => !r.result.value).map((r) => r.name);
|
|
1485
|
+
const sources = {};
|
|
1486
|
+
for (const r of required) {
|
|
1487
|
+
sources[r.name] = r.result.source;
|
|
1488
|
+
}
|
|
1489
|
+
sources["GOOGLE_ADS_LOGIN_CUSTOMER_ID"] = loginCustomerId.source;
|
|
1490
|
+
return {
|
|
1491
|
+
configured: missing.length === 0,
|
|
1492
|
+
missing,
|
|
1493
|
+
sources,
|
|
1494
|
+
developerToken: devToken.value,
|
|
1495
|
+
clientId: clientId.value,
|
|
1496
|
+
clientSecret: clientSecret.value,
|
|
1497
|
+
refreshToken: refreshToken.value,
|
|
1498
|
+
loginCustomerId: loginCustomerId.value
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// src/google/auth.ts
|
|
1503
|
+
import { createInterface } from "readline";
|
|
1504
|
+
import { createServer } from "http";
|
|
1505
|
+
import { exec } from "child_process";
|
|
1506
|
+
import { readFileSync as readFileSync2, appendFileSync, writeFileSync } from "fs";
|
|
1507
|
+
import { join as join2 } from "path";
|
|
1508
|
+
var OAUTH_PORT = 8549;
|
|
1509
|
+
var REDIRECT_URI = `http://localhost:${OAUTH_PORT}/callback`;
|
|
1510
|
+
var AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
1511
|
+
var TOKEN_URL2 = "https://oauth2.googleapis.com/token";
|
|
1512
|
+
var SCOPE = "https://www.googleapis.com/auth/adwords";
|
|
1513
|
+
async function maybeRunAuthCommand() {
|
|
1514
|
+
const args = process.argv.slice(2);
|
|
1515
|
+
if (args[0] !== "auth" || args[1] !== "google") return;
|
|
1516
|
+
await runGoogleAuthFlow();
|
|
1517
|
+
process.exit(0);
|
|
1518
|
+
}
|
|
1519
|
+
function prompt(rl, question) {
|
|
1520
|
+
return new Promise((resolve2) => {
|
|
1521
|
+
rl.question(question, (answer) => resolve2(answer.trim()));
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
function openBrowser(url) {
|
|
1525
|
+
const platform = process.platform;
|
|
1526
|
+
const cmd = platform === "darwin" ? `open "${url}"` : platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
|
|
1527
|
+
exec(cmd, (err) => {
|
|
1528
|
+
if (err) {
|
|
1529
|
+
console.error(` Could not open browser automatically.`);
|
|
1530
|
+
console.error(` Open this URL manually:
|
|
1531
|
+
${url}`);
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
function getDotEnvPath() {
|
|
1536
|
+
return join2(process.cwd(), ".env");
|
|
1537
|
+
}
|
|
1538
|
+
function readExistingEnv() {
|
|
1539
|
+
try {
|
|
1540
|
+
const content = readFileSync2(getDotEnvPath(), "utf-8");
|
|
1541
|
+
const vars = {};
|
|
1542
|
+
for (const line of content.split("\n")) {
|
|
1543
|
+
const trimmed = line.trim();
|
|
1544
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1545
|
+
const eq = trimmed.indexOf("=");
|
|
1546
|
+
if (eq === -1) continue;
|
|
1547
|
+
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
1548
|
+
}
|
|
1549
|
+
return vars;
|
|
1550
|
+
} catch {
|
|
1551
|
+
return {};
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
function saveToEnv(vars) {
|
|
1555
|
+
const envPath = getDotEnvPath();
|
|
1556
|
+
const existing = readExistingEnv();
|
|
1557
|
+
const overwritten = [];
|
|
1558
|
+
for (const key of Object.keys(vars)) {
|
|
1559
|
+
if (existing[key]) {
|
|
1560
|
+
overwritten.push(key);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (overwritten.length > 0) {
|
|
1564
|
+
console.log(`
|
|
1565
|
+
Overwriting existing keys: ${overwritten.join(", ")}`);
|
|
1566
|
+
let content;
|
|
1567
|
+
try {
|
|
1568
|
+
content = readFileSync2(envPath, "utf-8");
|
|
1569
|
+
} catch {
|
|
1570
|
+
content = "";
|
|
1571
|
+
}
|
|
1572
|
+
const lines = content.split("\n");
|
|
1573
|
+
const updated = /* @__PURE__ */ new Set();
|
|
1574
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1575
|
+
const trimmed = lines[i].trim();
|
|
1576
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1577
|
+
const eq = trimmed.indexOf("=");
|
|
1578
|
+
if (eq === -1) continue;
|
|
1579
|
+
const key = trimmed.slice(0, eq).trim();
|
|
1580
|
+
if (key in vars) {
|
|
1581
|
+
lines[i] = `${key}=${vars[key]}`;
|
|
1582
|
+
updated.add(key);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
1586
|
+
if (!updated.has(key)) {
|
|
1587
|
+
lines.push(`${key}=${value}`);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
writeFileSync(envPath, lines.join("\n"));
|
|
1591
|
+
} else {
|
|
1592
|
+
const block = Object.entries(vars).map(([k, v]) => `${k}=${v}`).join("\n");
|
|
1593
|
+
let prefix = "\n";
|
|
1594
|
+
try {
|
|
1595
|
+
const existing2 = readFileSync2(envPath, "utf-8");
|
|
1596
|
+
if (existing2.length > 0 && !existing2.endsWith("\n")) {
|
|
1597
|
+
prefix = "\n\n";
|
|
1598
|
+
} else if (existing2.endsWith("\n")) {
|
|
1599
|
+
prefix = "";
|
|
1600
|
+
}
|
|
1601
|
+
} catch {
|
|
1602
|
+
prefix = "";
|
|
1603
|
+
}
|
|
1604
|
+
appendFileSync(envPath, `${prefix}${block}
|
|
1605
|
+
`);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
async function waitForOAuthCallback(clientId, clientSecret) {
|
|
1609
|
+
return new Promise((resolve2, reject) => {
|
|
1610
|
+
const server2 = createServer(
|
|
1611
|
+
async (req, res) => {
|
|
1612
|
+
const url = new URL(req.url ?? "/", `http://localhost:${OAUTH_PORT}`);
|
|
1613
|
+
if (url.pathname !== "/callback") {
|
|
1614
|
+
res.writeHead(404);
|
|
1615
|
+
res.end("Not found");
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
const code = url.searchParams.get("code");
|
|
1619
|
+
const error = url.searchParams.get("error");
|
|
1620
|
+
if (error) {
|
|
1621
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1622
|
+
res.end(
|
|
1623
|
+
"<html><body><h2>Authorization failed</h2><p>You can close this tab.</p></body></html>"
|
|
1624
|
+
);
|
|
1625
|
+
server2.close();
|
|
1626
|
+
reject(new Error(`OAuth authorization denied: ${error}`));
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (!code) {
|
|
1630
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
1631
|
+
res.end(
|
|
1632
|
+
"<html><body><h2>Missing authorization code</h2></body></html>"
|
|
1633
|
+
);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
try {
|
|
1637
|
+
const tokenBody = new URLSearchParams({
|
|
1638
|
+
code,
|
|
1639
|
+
client_id: clientId,
|
|
1640
|
+
client_secret: clientSecret,
|
|
1641
|
+
redirect_uri: REDIRECT_URI,
|
|
1642
|
+
grant_type: "authorization_code"
|
|
1643
|
+
});
|
|
1644
|
+
const tokenRes = await fetch(TOKEN_URL2, {
|
|
1645
|
+
method: "POST",
|
|
1646
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1647
|
+
body: tokenBody.toString()
|
|
1648
|
+
});
|
|
1649
|
+
if (!tokenRes.ok) {
|
|
1650
|
+
const text = await tokenRes.text();
|
|
1651
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
|
|
1652
|
+
}
|
|
1653
|
+
const tokenData = await tokenRes.json();
|
|
1654
|
+
if (!tokenData.refresh_token) {
|
|
1655
|
+
throw new Error(
|
|
1656
|
+
"No refresh token received. Make sure prompt=consent is set and try again."
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1660
|
+
res.end(
|
|
1661
|
+
"<html><body><h2>Success!</h2><p>You can close this tab and return to the terminal.</p></body></html>"
|
|
1662
|
+
);
|
|
1663
|
+
server2.close();
|
|
1664
|
+
resolve2({ refreshToken: tokenData.refresh_token });
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
res.writeHead(500, { "Content-Type": "text/html" });
|
|
1667
|
+
res.end(
|
|
1668
|
+
"<html><body><h2>Token exchange failed</h2><p>Check the terminal for details.</p></body></html>"
|
|
1669
|
+
);
|
|
1670
|
+
server2.close();
|
|
1671
|
+
reject(err);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
);
|
|
1675
|
+
server2.listen(OAUTH_PORT, () => {
|
|
1676
|
+
});
|
|
1677
|
+
server2.on("error", (err) => {
|
|
1678
|
+
if (err.code === "EADDRINUSE") {
|
|
1679
|
+
reject(
|
|
1680
|
+
new Error(
|
|
1681
|
+
`Port ${OAUTH_PORT} is already in use. Close the process using it and try again.`
|
|
1682
|
+
)
|
|
1683
|
+
);
|
|
1684
|
+
} else {
|
|
1685
|
+
reject(err);
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
async function runGoogleAuthFlow() {
|
|
1691
|
+
console.log("\nGoogle Ads Setup");
|
|
1692
|
+
console.log("================\n");
|
|
1693
|
+
const rl = createInterface({
|
|
1694
|
+
input: process.stdin,
|
|
1695
|
+
output: process.stdout
|
|
1696
|
+
});
|
|
1697
|
+
try {
|
|
1698
|
+
console.log("Step 1: Developer token");
|
|
1699
|
+
console.log(" (Found in Google Ads \u2192 Tools \u2192 API Center)\n");
|
|
1700
|
+
const developerToken = await prompt(rl, " Developer token: ");
|
|
1701
|
+
if (!developerToken) {
|
|
1702
|
+
console.error("\n Developer token is required.");
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const loginCustomerId = await prompt(
|
|
1706
|
+
rl,
|
|
1707
|
+
" MCC account ID (optional, press Enter to skip): "
|
|
1708
|
+
);
|
|
1709
|
+
console.log("\nStep 2: OAuth credentials");
|
|
1710
|
+
console.log(
|
|
1711
|
+
" (Create in Google Cloud Console \u2192 APIs & Services \u2192 Credentials)\n"
|
|
1712
|
+
);
|
|
1713
|
+
console.log(` Redirect URI to register: ${REDIRECT_URI}
|
|
1714
|
+
`);
|
|
1715
|
+
const clientId = await prompt(rl, " Client ID: ");
|
|
1716
|
+
if (!clientId) {
|
|
1717
|
+
console.error("\n Client ID is required.");
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const clientSecret = await prompt(rl, " Client secret: ");
|
|
1721
|
+
if (!clientSecret) {
|
|
1722
|
+
console.error("\n Client secret is required.");
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
rl.close();
|
|
1726
|
+
console.log("\nStep 3: Authorization");
|
|
1727
|
+
const authParams = new URLSearchParams({
|
|
1728
|
+
client_id: clientId,
|
|
1729
|
+
redirect_uri: REDIRECT_URI,
|
|
1730
|
+
response_type: "code",
|
|
1731
|
+
scope: SCOPE,
|
|
1732
|
+
access_type: "offline",
|
|
1733
|
+
prompt: "consent"
|
|
1734
|
+
});
|
|
1735
|
+
const authUrl = `${AUTH_URL}?${authParams.toString()}`;
|
|
1736
|
+
console.log(" Opening browser...\n");
|
|
1737
|
+
openBrowser(authUrl);
|
|
1738
|
+
console.log(` If the browser didn't open, visit:
|
|
1739
|
+
${authUrl}
|
|
1740
|
+
`);
|
|
1741
|
+
const { refreshToken } = await waitForOAuthCallback(clientId, clientSecret);
|
|
1742
|
+
const envVars = {
|
|
1743
|
+
GOOGLE_ADS_DEVELOPER_TOKEN: developerToken,
|
|
1744
|
+
GOOGLE_ADS_CLIENT_ID: clientId,
|
|
1745
|
+
GOOGLE_ADS_CLIENT_SECRET: clientSecret,
|
|
1746
|
+
GOOGLE_ADS_REFRESH_TOKEN: refreshToken
|
|
1747
|
+
};
|
|
1748
|
+
if (loginCustomerId) {
|
|
1749
|
+
envVars.GOOGLE_ADS_LOGIN_CUSTOMER_ID = loginCustomerId;
|
|
1750
|
+
}
|
|
1751
|
+
saveToEnv(envVars);
|
|
1752
|
+
console.log(`
|
|
1753
|
+
\u2713 Saved to .env. Restart your MCP client to use Google Ads tools.`);
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
console.error(
|
|
1756
|
+
`
|
|
1757
|
+
Error: ${err instanceof Error ? err.message : String(err)}`
|
|
1758
|
+
);
|
|
1759
|
+
process.exit(1);
|
|
1760
|
+
} finally {
|
|
1761
|
+
rl.close();
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1183
1764
|
|
|
1184
1765
|
// src/index.ts
|
|
1766
|
+
await maybeRunAuthCommand();
|
|
1185
1767
|
var apiKeyResult = resolveApiKey();
|
|
1186
1768
|
var metaTokenResult = resolveMetaToken();
|
|
1769
|
+
var googleAdsResult = resolveGoogleAdsConfig();
|
|
1187
1770
|
if (apiKeyResult.value) process.env.API_KEY = apiKeyResult.value;
|
|
1188
1771
|
if (metaTokenResult.value) process.env.META_ACCESS_TOKEN = metaTokenResult.value;
|
|
1772
|
+
if (googleAdsResult.developerToken)
|
|
1773
|
+
process.env.GOOGLE_ADS_DEVELOPER_TOKEN = googleAdsResult.developerToken;
|
|
1774
|
+
if (googleAdsResult.clientId)
|
|
1775
|
+
process.env.GOOGLE_ADS_CLIENT_ID = googleAdsResult.clientId;
|
|
1776
|
+
if (googleAdsResult.clientSecret)
|
|
1777
|
+
process.env.GOOGLE_ADS_CLIENT_SECRET = googleAdsResult.clientSecret;
|
|
1778
|
+
if (googleAdsResult.refreshToken)
|
|
1779
|
+
process.env.GOOGLE_ADS_REFRESH_TOKEN = googleAdsResult.refreshToken;
|
|
1780
|
+
if (googleAdsResult.loginCustomerId)
|
|
1781
|
+
process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID = googleAdsResult.loginCustomerId;
|
|
1189
1782
|
var apiKey = apiKeyResult.value;
|
|
1190
1783
|
console.error(
|
|
1191
1784
|
apiKey ? `API key: ${apiKeyResult.source}` : "API key: not configured \u2014 KB tools will not be available"
|
|
@@ -1193,6 +1786,9 @@ console.error(
|
|
|
1193
1786
|
console.error(
|
|
1194
1787
|
metaTokenResult.value ? `Meta token: ${metaTokenResult.source}` : "Meta token: not configured \u2014 Meta tools will return errors when called"
|
|
1195
1788
|
);
|
|
1789
|
+
console.error(
|
|
1790
|
+
googleAdsResult.configured ? `Google Ads: configured` : `Google Ads: not configured (missing: ${googleAdsResult.missing.join(", ")}) \u2014 run \`npx mobile-growth-mcp auth google\` to set up`
|
|
1791
|
+
);
|
|
1196
1792
|
var server = new McpServer({
|
|
1197
1793
|
name: "mobile-growth-mcp",
|
|
1198
1794
|
version: "2.0.0"
|
|
@@ -1200,6 +1796,10 @@ var server = new McpServer({
|
|
|
1200
1796
|
var status = {
|
|
1201
1797
|
kb: { connected: false, toolCount: 0, promptCount: 0 },
|
|
1202
1798
|
meta: { tokenConfigured: !!metaTokenResult.value },
|
|
1799
|
+
google: {
|
|
1800
|
+
configured: googleAdsResult.configured,
|
|
1801
|
+
missing: googleAdsResult.missing
|
|
1802
|
+
},
|
|
1203
1803
|
apiKey: { source: apiKeyResult.source }
|
|
1204
1804
|
};
|
|
1205
1805
|
if (apiKey) {
|
|
@@ -1223,6 +1823,7 @@ registerGetMetaAdSets(server);
|
|
|
1223
1823
|
registerGetMetaAds(server);
|
|
1224
1824
|
registerGetMetaInsights(server);
|
|
1225
1825
|
registerGetMetaAdFatigue(server);
|
|
1826
|
+
registerGetGoogleAdsCampaigns(server);
|
|
1226
1827
|
registerConnectionStatus(server, status);
|
|
1227
1828
|
registerVocabularyResource(server);
|
|
1228
1829
|
registerInstructionsResource(server, status);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-growth-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "MCP server for mobile growth & UA knowledge base — campaign optimization, creative strategy, and subscription app insights",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,5 +33,13 @@
|
|
|
33
33
|
"meta-ads",
|
|
34
34
|
"subscription-apps"
|
|
35
35
|
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/kubachour/mobile-growth-mcp.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/kubachour/mobile-growth-mcp#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/kubachour/mobile-growth-mcp/issues"
|
|
43
|
+
},
|
|
36
44
|
"license": "MIT"
|
|
37
45
|
}
|