mcp-google-ads 1.6.0 → 1.7.0

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/README.md CHANGED
@@ -32,13 +32,36 @@ You need:
32
32
 
33
33
  #### Getting a Refresh Token
34
34
 
35
- Use the Google OAuth playground or run:
35
+ Bring your own OAuth client (the `client_id` / `client_secret` from the step
36
+ above) and run the bundled helper. It runs Google's installed-app loopback flow
37
+ **with PKCE (S256)** and prints your refresh token. It reads nothing from your
38
+ home directory and needs no shared OAuth keyfile.
36
39
 
37
40
  ```bash
38
- pip install google-ads
39
- google-ads-auth
41
+ export GOOGLE_ADS_CLIENT_ID="YOUR_CLIENT_ID.apps.googleusercontent.com"
42
+ export GOOGLE_ADS_CLIENT_SECRET="YOUR_CLIENT_SECRET"
43
+ node get-refresh-token.cjs
40
44
  ```
41
45
 
46
+ Your browser opens for Google sign-in; approve as the Google account that owns
47
+ the Ads data. On success the helper prints one line to stdout:
48
+
49
+ ```
50
+ GOOGLE_ADS_REFRESH_TOKEN=1//0a...
51
+ ```
52
+
53
+ Set that value in your environment (or `config.json`, below). The OAuth scope
54
+ requested is read from `config.json` (`oauth.scope`), falling back to
55
+ `config.example.json`, so the helper and the running server always request the
56
+ same scope. This MCP requests only the minimum scope it needs:
57
+ `https://www.googleapis.com/auth/adwords`.
58
+
59
+ > Do not run this with stdout redirected to a shared log file — the refresh
60
+ > token is printed to stdout by design.
61
+
62
+ Note: `GOOGLE_ADS_DEVELOPER_TOKEN` is a separate Google Ads API credential, not
63
+ an OAuth scope — set it independently (see Environment Variables below).
64
+
42
65
  ### 2. Install
43
66
 
44
67
  ```bash
@@ -66,6 +89,9 @@ Edit `config.json` with your credentials:
66
89
 
67
90
  ```json
68
91
  {
92
+ "oauth": {
93
+ "scope": "https://www.googleapis.com/auth/adwords"
94
+ },
69
95
  "google_ads": {
70
96
  "developer_token": "YOUR_DEVELOPER_TOKEN",
71
97
  "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
@@ -1,4 +1,7 @@
1
1
  {
2
+ "oauth": {
3
+ "scope": "https://www.googleapis.com/auth/adwords"
4
+ },
2
5
  "google_ads": {
3
6
  "developer_token": "YOUR_DEVELOPER_TOKEN",
4
7
  "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
@@ -1,2 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ export declare const OAUTH_SCOPE: string;
3
+ export declare function buildAuthUrl(clientId: string, redirectUri: string, state: string, codeChallenge: string): string;
4
+ export declare function buildTokenExchangeBody(opts: {
5
+ code: string;
6
+ clientId: string;
7
+ clientSecret: string;
8
+ redirectUri: string;
9
+ codeVerifier: string;
10
+ }): string;
2
11
  export declare function run(argv?: string[]): Promise<void>;
package/dist/auth-cli.js CHANGED
@@ -13,8 +13,17 @@ import {
13
13
  import { classifyError, GoogleAdsAuthError } from "./errors.js";
14
14
  import { findFreeLoopbackPort, openBrowser } from "./platform.js";
15
15
  import { logger, withResilience } from "./resilience.js";
16
+ import { dirname, join } from "path";
17
+ import { loadOAuthScopeFromFile } from "./oauthScope.js";
18
+ import {
19
+ generateCodeVerifier,
20
+ computeCodeChallenge,
21
+ buildLoopbackRedirectUri
22
+ } from "./pkce.js";
16
23
  const prompts = promptsImport.default ?? promptsImport;
17
- const OAUTH_SCOPE = "https://www.googleapis.com/auth/adwords";
24
+ const OAUTH_SCOPE = loadOAuthScopeFromFile(
25
+ join(dirname(fileURLToPath(import.meta.url)), "..", "config.json")
26
+ );
18
27
  const OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
19
28
  const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
20
29
  function parseArgs(argv) {
@@ -46,7 +55,7 @@ function printHelp() {
46
55
  ].join("\n")
47
56
  );
48
57
  }
49
- function buildAuthUrl(clientId, redirectUri, state) {
58
+ function buildAuthUrl(clientId, redirectUri, state, codeChallenge) {
50
59
  const params = new URLSearchParams({
51
60
  client_id: clientId,
52
61
  redirect_uri: redirectUri,
@@ -54,7 +63,10 @@ function buildAuthUrl(clientId, redirectUri, state) {
54
63
  scope: OAUTH_SCOPE,
55
64
  access_type: "offline",
56
65
  prompt: "consent",
57
- state
66
+ state,
67
+ code_challenge: codeChallenge,
68
+ // PKCE (RFC 7636)
69
+ code_challenge_method: "S256"
58
70
  });
59
71
  return `${OAUTH_AUTH_URL}?${params.toString()}`;
60
72
  }
@@ -161,19 +173,25 @@ function renderAuthCompletePage(title, body) {
161
173
  function escapeHtml(s) {
162
174
  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
163
175
  }
164
- async function exchangeCodeForTokens(code, clientId, clientSecret, redirectUri) {
176
+ function buildTokenExchangeBody(opts) {
177
+ return new URLSearchParams({
178
+ code: opts.code,
179
+ client_id: opts.clientId,
180
+ client_secret: opts.clientSecret,
181
+ // confidential Desktop client — PKCE is additive
182
+ redirect_uri: opts.redirectUri,
183
+ grant_type: "authorization_code",
184
+ code_verifier: opts.codeVerifier
185
+ // PKCE proof — sent on exchange
186
+ }).toString();
187
+ }
188
+ async function exchangeCodeForTokens(code, clientId, clientSecret, redirectUri, codeVerifier) {
165
189
  return withResilience(async () => {
166
- const body = new URLSearchParams({
167
- code,
168
- client_id: clientId,
169
- client_secret: clientSecret,
170
- redirect_uri: redirectUri,
171
- grant_type: "authorization_code"
172
- });
190
+ const body = buildTokenExchangeBody({ code, clientId, clientSecret, redirectUri, codeVerifier });
173
191
  const res = await fetch(OAUTH_TOKEN_URL, {
174
192
  method: "POST",
175
193
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
176
- body: body.toString()
194
+ body
177
195
  });
178
196
  const json = await res.json();
179
197
  if (!res.ok || json.error) {
@@ -348,13 +366,15 @@ async function run(argv = process.argv.slice(2)) {
348
366
  process.exit(2);
349
367
  }
350
368
  const port = await findFreeLoopbackPort();
351
- const redirectUri = `http://127.0.0.1:${port}`;
369
+ const redirectUri = buildLoopbackRedirectUri(port);
352
370
  const state = randomState();
353
- const authUrl = buildAuthUrl(clientId, redirectUri, state);
371
+ const codeVerifier = generateCodeVerifier();
372
+ const codeChallenge = computeCodeChallenge(codeVerifier);
373
+ const authUrl = buildAuthUrl(clientId, redirectUri, state, codeChallenge);
354
374
  process.stderr.write("\n=== mcp-google-ads authentication ===\n");
355
375
  const { code } = await waitForAuthorizationCode(port, state, authUrl);
356
376
  process.stderr.write("Authorization code received. Exchanging for tokens...\n");
357
- const tokens = await exchangeCodeForTokens(code, clientId, clientSecret, redirectUri);
377
+ const tokens = await exchangeCodeForTokens(code, clientId, clientSecret, redirectUri, codeVerifier);
358
378
  if (!tokens.refresh_token) {
359
379
  throw new GoogleAdsAuthError(
360
380
  "Google did not return a refresh token. This can happen if you previously granted consent to this app \u2014 revoke access at https://myaccount.google.com/permissions and try again."
@@ -419,6 +439,9 @@ if (isMain) {
419
439
  });
420
440
  }
421
441
  export {
442
+ OAUTH_SCOPE,
443
+ buildAuthUrl,
444
+ buildTokenExchangeBody,
422
445
  run
423
446
  };
424
447
  //# sourceMappingURL=auth-cli.js.map
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "7d2b185",
3
- "builtAt": "2026-05-27T23:45:13.595Z",
2
+ "sha": "95e7b7c",
3
+ "builtAt": "2026-07-09T13:47:57.357Z",
4
4
  "embeddedSecrets": true
5
5
  }