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.
Files changed (2) hide show
  1. package/dist/index.js +610 -9
  2. 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 prompt of prompts) {
119
+ for (const prompt2 of prompts) {
120
120
  const zodShape = {};
121
- for (const arg of prompt.arguments) {
121
+ for (const arg of prompt2.arguments) {
122
122
  let field = z.string().describe(arg.description);
123
123
  if (!arg.required) {
124
124
  field = field.optional();
@@ -126,13 +126,13 @@ function registerFetchedPrompts(server2, apiKey2, prompts) {
126
126
  zodShape[arg.name] = field;
127
127
  }
128
128
  server2.prompt(
129
- prompt.name,
130
- prompt.description,
129
+ prompt2.name,
130
+ prompt2.description,
131
131
  zodShape,
132
132
  async (args) => {
133
133
  const messages = await getRemotePrompt(
134
134
  apiKey2,
135
- prompt.name,
135
+ prompt2.name,
136
136
  args
137
137
  );
138
138
  return {
@@ -160,7 +160,7 @@ function getMetaAccessToken() {
160
160
  const token = process.env.META_ACCESS_TOKEN;
161
161
  if (!token) {
162
162
  throw new Error(
163
- "Missing META_ACCESS_TOKEN environment variable. Provide a Meta Marketing API access token to use Meta tools."
163
+ 'Missing META_ACCESS_TOKEN. To fix, add it to your MCP config:\n \u2022 Claude Code / Cursor: add "META_ACCESS_TOKEN": "..." to the "env" block in .mcp.json\n \u2022 Claude Desktop: add "META_ACCESS_TOKEN": "..." to the "env" block in claude_desktop_config.json\n \u2022 CLI: add META_ACCESS_TOKEN=... to a .env file in your working directory\nThen restart your MCP client.'
164
164
  );
165
165
  }
166
166
  return token;
@@ -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
- "1. CLI argument: add `--meta-token=...` to the args array",
863
- '2. Environment variable: add `"META_ACCESS_TOKEN": "..."` to the env block',
864
- "3. `.env` file: add `META_ACCESS_TOKEN=...` to your `.env` file"
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.0.6",
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
  }