mcp-google-ads 1.0.16 → 1.1.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/dist/auth-cli.d.ts +2 -0
- package/dist/auth-cli.js +385 -0
- package/dist/build-info.json +5 -1
- package/dist/credentials.d.ts +38 -0
- package/dist/credentials.js +123 -0
- package/dist/embedded-secrets.d.ts +4 -0
- package/dist/embedded-secrets.js +13 -0
- package/dist/errors.js +97 -112
- package/dist/index.js +1630 -1577
- package/dist/platform.d.ts +8 -0
- package/dist/platform.js +59 -0
- package/dist/resilience.js +106 -116
- package/dist/tools.js +633 -605
- package/dist/validateRsa.d.ts +24 -0
- package/dist/validateRsa.js +78 -0
- package/package.json +13 -4
package/dist/auth-cli.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { GoogleAdsApi } from "google-ads-api";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import promptsImport from "prompts";
|
|
5
|
+
import { URL } from "url";
|
|
6
|
+
import { writeStoredCredentials, credentialsFilePath, CREDENTIALS_FILE_VERSION } from "./credentials.js";
|
|
7
|
+
import {
|
|
8
|
+
EMBEDDED_CLIENT_ID,
|
|
9
|
+
EMBEDDED_CLIENT_SECRET,
|
|
10
|
+
EMBEDDED_DEVELOPER_TOKEN
|
|
11
|
+
} from "./embedded-secrets.js";
|
|
12
|
+
import { classifyError, GoogleAdsAuthError } from "./errors.js";
|
|
13
|
+
import { findFreeLoopbackPort, openBrowser } from "./platform.js";
|
|
14
|
+
import { logger, withResilience } from "./resilience.js";
|
|
15
|
+
const prompts = promptsImport.default ?? promptsImport;
|
|
16
|
+
const OAUTH_SCOPE = "https://www.googleapis.com/auth/adwords";
|
|
17
|
+
const OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
18
|
+
const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const args = { help: false };
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === "--help" || a === "-h") args.help = true;
|
|
24
|
+
else if (a === "--customer-id" && argv[i + 1]) {
|
|
25
|
+
args.customerId = argv[++i].replace(/-/g, "");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return args;
|
|
29
|
+
}
|
|
30
|
+
function printHelp() {
|
|
31
|
+
process.stdout.write(
|
|
32
|
+
[
|
|
33
|
+
"mcp-google-ads-auth \u2014 authorize Claude to access your Google Ads account",
|
|
34
|
+
"",
|
|
35
|
+
"Usage:",
|
|
36
|
+
" npx mcp-google-ads-auth",
|
|
37
|
+
" npx mcp-google-ads-auth --customer-id 374-196-1572",
|
|
38
|
+
"",
|
|
39
|
+
"Options:",
|
|
40
|
+
" --customer-id <id> Skip the account picker and use this customer ID directly",
|
|
41
|
+
" -h, --help Show this help",
|
|
42
|
+
"",
|
|
43
|
+
`Credentials are written to: ${credentialsFilePath}`,
|
|
44
|
+
""
|
|
45
|
+
].join("\n")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
function buildAuthUrl(clientId, redirectUri, state) {
|
|
49
|
+
const params = new URLSearchParams({
|
|
50
|
+
client_id: clientId,
|
|
51
|
+
redirect_uri: redirectUri,
|
|
52
|
+
response_type: "code",
|
|
53
|
+
scope: OAUTH_SCOPE,
|
|
54
|
+
access_type: "offline",
|
|
55
|
+
prompt: "consent",
|
|
56
|
+
state
|
|
57
|
+
});
|
|
58
|
+
return `${OAUTH_AUTH_URL}?${params.toString()}`;
|
|
59
|
+
}
|
|
60
|
+
async function waitForAuthorizationCode(port, expectedState, authUrl) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const finish = (fn) => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
fn();
|
|
67
|
+
};
|
|
68
|
+
const server = http.createServer((req, res) => {
|
|
69
|
+
if (!req.url) {
|
|
70
|
+
res.writeHead(404).end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const parsed = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
74
|
+
const code = parsed.searchParams.get("code");
|
|
75
|
+
const state = parsed.searchParams.get("state");
|
|
76
|
+
const error = parsed.searchParams.get("error");
|
|
77
|
+
if (error) {
|
|
78
|
+
const body2 = renderAuthCompletePage(
|
|
79
|
+
"Authorization was denied",
|
|
80
|
+
`Google returned: ${escapeHtml(error)}. You can close this tab and re-run the command.`
|
|
81
|
+
);
|
|
82
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
83
|
+
res.end(body2);
|
|
84
|
+
finish(() => {
|
|
85
|
+
server.close();
|
|
86
|
+
reject(new GoogleAdsAuthError(`OAuth denied: ${error}`));
|
|
87
|
+
});
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!code) {
|
|
91
|
+
res.writeHead(204).end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (state !== expectedState) {
|
|
95
|
+
const body2 = renderAuthCompletePage(
|
|
96
|
+
"Security check failed",
|
|
97
|
+
"The state parameter did not match. This tab may have been tampered with. Please re-run the command."
|
|
98
|
+
);
|
|
99
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
100
|
+
res.end(body2);
|
|
101
|
+
finish(() => {
|
|
102
|
+
server.close();
|
|
103
|
+
reject(new GoogleAdsAuthError("OAuth state mismatch \u2014 possible CSRF"));
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const body = renderAuthCompletePage(
|
|
108
|
+
"Signed in successfully",
|
|
109
|
+
"You can close this tab and return to the terminal."
|
|
110
|
+
);
|
|
111
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
112
|
+
res.end(body);
|
|
113
|
+
finish(() => {
|
|
114
|
+
setTimeout(() => server.close(), 200);
|
|
115
|
+
resolve({ code, state });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
server.on("error", (err) => {
|
|
119
|
+
finish(() => reject(new Error(`Loopback server failed: ${err.message}`)));
|
|
120
|
+
});
|
|
121
|
+
server.listen(port, "127.0.0.1", () => {
|
|
122
|
+
process.stderr.write(`
|
|
123
|
+
Opening your browser to sign in with Google...
|
|
124
|
+
`);
|
|
125
|
+
process.stderr.write(`If it doesn't open automatically, visit:
|
|
126
|
+
${authUrl}
|
|
127
|
+
|
|
128
|
+
`);
|
|
129
|
+
openBrowser(authUrl).catch((err) => {
|
|
130
|
+
logger.warn({ err: err.message }, "openBrowser failed \u2014 user can paste URL manually");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
finish(() => {
|
|
135
|
+
server.close();
|
|
136
|
+
reject(new Error("Timed out waiting for OAuth callback (5 minutes). Re-run the command."));
|
|
137
|
+
});
|
|
138
|
+
}, 5 * 60 * 1e3);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function renderAuthCompletePage(title, body) {
|
|
142
|
+
return `<!DOCTYPE html>
|
|
143
|
+
<html lang="en">
|
|
144
|
+
<head>
|
|
145
|
+
<meta charset="utf-8">
|
|
146
|
+
<title>${escapeHtml(title)}</title>
|
|
147
|
+
<style>
|
|
148
|
+
body { font: 15px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
149
|
+
max-width: 480px; margin: 80px auto; padding: 0 24px; color: #222; }
|
|
150
|
+
h1 { font-size: 22px; margin-bottom: 12px; }
|
|
151
|
+
p { line-height: 1.5; }
|
|
152
|
+
</style>
|
|
153
|
+
</head>
|
|
154
|
+
<body>
|
|
155
|
+
<h1>${escapeHtml(title)}</h1>
|
|
156
|
+
<p>${escapeHtml(body)}</p>
|
|
157
|
+
</body>
|
|
158
|
+
</html>`;
|
|
159
|
+
}
|
|
160
|
+
function escapeHtml(s) {
|
|
161
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
162
|
+
}
|
|
163
|
+
async function exchangeCodeForTokens(code, clientId, clientSecret, redirectUri) {
|
|
164
|
+
return withResilience(async () => {
|
|
165
|
+
const body = new URLSearchParams({
|
|
166
|
+
code,
|
|
167
|
+
client_id: clientId,
|
|
168
|
+
client_secret: clientSecret,
|
|
169
|
+
redirect_uri: redirectUri,
|
|
170
|
+
grant_type: "authorization_code"
|
|
171
|
+
});
|
|
172
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
175
|
+
body: body.toString()
|
|
176
|
+
});
|
|
177
|
+
const json = await res.json();
|
|
178
|
+
if (!res.ok || json.error) {
|
|
179
|
+
const err = new Error(
|
|
180
|
+
`Token exchange failed: ${json.error_description || json.error || res.statusText}`
|
|
181
|
+
);
|
|
182
|
+
err.status = res.status;
|
|
183
|
+
err.code = res.status;
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
return json;
|
|
187
|
+
}, "oauth.exchangeCode");
|
|
188
|
+
}
|
|
189
|
+
async function enumerateAccounts(api, refreshToken) {
|
|
190
|
+
const listed = await withResilience(
|
|
191
|
+
() => api.listAccessibleCustomers(refreshToken),
|
|
192
|
+
"auth.listAccessibleCustomers"
|
|
193
|
+
);
|
|
194
|
+
const topLevelIds = (listed.resource_names || []).map(
|
|
195
|
+
(rn) => rn.replace("customers/", "")
|
|
196
|
+
);
|
|
197
|
+
if (topLevelIds.length === 0) {
|
|
198
|
+
throw new GoogleAdsAuthError(
|
|
199
|
+
"No Google Ads accounts accessible to this Google login. Make sure you signed in with an account that has access to at least one Google Ads account."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const accounts = [];
|
|
203
|
+
for (const id of topLevelIds) {
|
|
204
|
+
try {
|
|
205
|
+
const customer = api.Customer({ customer_id: id, refresh_token: refreshToken });
|
|
206
|
+
const rows = await withResilience(
|
|
207
|
+
() => customer.query("SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1"),
|
|
208
|
+
`auth.fetchCustomer[${id}]`
|
|
209
|
+
);
|
|
210
|
+
const row = rows[0]?.customer;
|
|
211
|
+
const name = row?.descriptive_name || `(unnamed ${id})`;
|
|
212
|
+
const isManager = Boolean(row?.manager);
|
|
213
|
+
accounts.push({ id, name, isManager });
|
|
214
|
+
if (isManager) {
|
|
215
|
+
const mccCustomer = api.Customer({
|
|
216
|
+
customer_id: id,
|
|
217
|
+
refresh_token: refreshToken,
|
|
218
|
+
login_customer_id: id
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
const children = await withResilience(
|
|
222
|
+
() => mccCustomer.query(
|
|
223
|
+
"SELECT customer_client.id, customer_client.descriptive_name, customer_client.manager FROM customer_client WHERE customer_client.manager = FALSE AND customer_client.status = 'ENABLED'"
|
|
224
|
+
),
|
|
225
|
+
`auth.enumerateChildren[${id}]`
|
|
226
|
+
);
|
|
227
|
+
for (const childRow of children) {
|
|
228
|
+
const cc = childRow.customer_client;
|
|
229
|
+
if (!cc?.id) continue;
|
|
230
|
+
const childId = String(cc.id);
|
|
231
|
+
if (childId === id) continue;
|
|
232
|
+
accounts.push({
|
|
233
|
+
id: childId,
|
|
234
|
+
name: cc.descriptive_name || `(unnamed ${childId})`,
|
|
235
|
+
isManager: false,
|
|
236
|
+
parentMccId: id,
|
|
237
|
+
parentMccName: name
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger.warn(
|
|
242
|
+
{ err: err.message, mccId: id },
|
|
243
|
+
"Failed to enumerate MCC children \u2014 MCC will still appear in picker"
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
const classified = classifyError(err);
|
|
249
|
+
if (classified instanceof GoogleAdsAuthError) throw classified;
|
|
250
|
+
logger.warn({ err: classified.message, customerId: id }, "Failed to fetch account info \u2014 skipping");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return accounts;
|
|
254
|
+
}
|
|
255
|
+
async function pickAccount(accounts, presetCustomerId) {
|
|
256
|
+
if (presetCustomerId) {
|
|
257
|
+
const match = accounts.find((a) => a.id === presetCustomerId);
|
|
258
|
+
if (!match) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`--customer-id ${presetCustomerId} was not found among ${accounts.length} accessible account(s). Remove the flag to pick interactively.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return match;
|
|
264
|
+
}
|
|
265
|
+
if (accounts.length === 1) {
|
|
266
|
+
process.stderr.write(
|
|
267
|
+
`
|
|
268
|
+
Only one account accessible: ${accounts[0].name} (${accounts[0].id}). Auto-selecting.
|
|
269
|
+
`
|
|
270
|
+
);
|
|
271
|
+
return accounts[0];
|
|
272
|
+
}
|
|
273
|
+
const sorted = [...accounts].sort((a, b) => {
|
|
274
|
+
const aKey = a.parentMccId ? `${a.parentMccId}:1:${a.name}` : `${a.id}:0:${a.name}`;
|
|
275
|
+
const bKey = b.parentMccId ? `${b.parentMccId}:1:${b.name}` : `${b.id}:0:${b.name}`;
|
|
276
|
+
return aKey.localeCompare(bKey);
|
|
277
|
+
});
|
|
278
|
+
const choices = sorted.map((acct) => {
|
|
279
|
+
const prefix = acct.parentMccId ? " \u21B3 " : acct.isManager ? "\u{1F4C1} " : "\u2022 ";
|
|
280
|
+
const mccSuffix = acct.parentMccId ? "" : acct.isManager ? " (MCC)" : "";
|
|
281
|
+
return {
|
|
282
|
+
title: `${prefix}${acct.name} \u2014 ${acct.id}${mccSuffix}`,
|
|
283
|
+
value: acct,
|
|
284
|
+
disabled: acct.isManager && accounts.some((a) => a.parentMccId === acct.id) ? false : false
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
const response = await prompts(
|
|
288
|
+
{
|
|
289
|
+
type: "select",
|
|
290
|
+
name: "account",
|
|
291
|
+
message: "Which Google Ads account should Claude use?",
|
|
292
|
+
choices,
|
|
293
|
+
initial: 0
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
onCancel: () => {
|
|
297
|
+
throw new Error("Cancelled by user");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
if (!response.account) {
|
|
302
|
+
throw new Error("No account selected");
|
|
303
|
+
}
|
|
304
|
+
return response.account;
|
|
305
|
+
}
|
|
306
|
+
async function run(argv = process.argv.slice(2)) {
|
|
307
|
+
const args = parseArgs(argv);
|
|
308
|
+
if (args.help) {
|
|
309
|
+
printHelp();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const clientId = process.env.GOOGLE_ADS_CLIENT_ID?.trim() || EMBEDDED_CLIENT_ID;
|
|
313
|
+
const clientSecret = process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() || EMBEDDED_CLIENT_SECRET;
|
|
314
|
+
const developerToken = process.env.GOOGLE_ADS_DEVELOPER_TOKEN?.trim() || EMBEDDED_DEVELOPER_TOKEN;
|
|
315
|
+
if (!clientId || !clientSecret || !developerToken) {
|
|
316
|
+
process.stderr.write(
|
|
317
|
+
"This build of mcp-google-ads was published without embedded OAuth credentials.\nSet GOOGLE_ADS_CLIENT_ID, GOOGLE_ADS_CLIENT_SECRET, and GOOGLE_ADS_DEVELOPER_TOKEN\nin your environment before running this command.\n"
|
|
318
|
+
);
|
|
319
|
+
process.exit(2);
|
|
320
|
+
}
|
|
321
|
+
const port = await findFreeLoopbackPort();
|
|
322
|
+
const redirectUri = `http://127.0.0.1:${port}`;
|
|
323
|
+
const state = randomState();
|
|
324
|
+
const authUrl = buildAuthUrl(clientId, redirectUri, state);
|
|
325
|
+
process.stderr.write("\n=== mcp-google-ads authentication ===\n");
|
|
326
|
+
const { code } = await waitForAuthorizationCode(port, state, authUrl);
|
|
327
|
+
process.stderr.write("Authorization code received. Exchanging for tokens...\n");
|
|
328
|
+
const tokens = await exchangeCodeForTokens(code, clientId, clientSecret, redirectUri);
|
|
329
|
+
if (!tokens.refresh_token) {
|
|
330
|
+
throw new GoogleAdsAuthError(
|
|
331
|
+
"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."
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
process.stderr.write("Tokens received. Fetching accessible Google Ads accounts...\n");
|
|
335
|
+
const api = new GoogleAdsApi({
|
|
336
|
+
client_id: clientId,
|
|
337
|
+
client_secret: clientSecret,
|
|
338
|
+
developer_token: developerToken
|
|
339
|
+
});
|
|
340
|
+
const accounts = await enumerateAccounts(api, tokens.refresh_token);
|
|
341
|
+
const chosen = await pickAccount(accounts, args.customerId);
|
|
342
|
+
const stored = {
|
|
343
|
+
version: CREDENTIALS_FILE_VERSION,
|
|
344
|
+
refresh_token: tokens.refresh_token,
|
|
345
|
+
customer_id: chosen.id,
|
|
346
|
+
customer_name: chosen.name,
|
|
347
|
+
mcc_customer_id: chosen.parentMccId ?? null,
|
|
348
|
+
obtained_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
349
|
+
scopes: [OAUTH_SCOPE]
|
|
350
|
+
};
|
|
351
|
+
writeStoredCredentials(stored);
|
|
352
|
+
process.stderr.write(
|
|
353
|
+
[
|
|
354
|
+
"",
|
|
355
|
+
"\u2705 Done.",
|
|
356
|
+
"",
|
|
357
|
+
` Account: ${chosen.name} (${chosen.id})`,
|
|
358
|
+
chosen.parentMccId ? ` Via MCC: ${chosen.parentMccName} (${chosen.parentMccId})` : ` (Direct access \u2014 no MCC)`,
|
|
359
|
+
` Saved to: ${credentialsFilePath}`,
|
|
360
|
+
"",
|
|
361
|
+
"Next step: fully quit Claude Desktop (Cmd+Q / File > Exit) and reopen it.",
|
|
362
|
+
'Then try: "List campaigns in Google Ads"',
|
|
363
|
+
""
|
|
364
|
+
].join("\n")
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
function randomState() {
|
|
368
|
+
const bytes = new Uint8Array(16);
|
|
369
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
370
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
371
|
+
}
|
|
372
|
+
const isMain = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/auth-cli.js") || process.argv[1]?.endsWith("\\auth-cli.js");
|
|
373
|
+
if (isMain) {
|
|
374
|
+
run().catch((err) => {
|
|
375
|
+
const classified = classifyError(err);
|
|
376
|
+
process.stderr.write(`
|
|
377
|
+
\u274C ${classified.message}
|
|
378
|
+
`);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
export {
|
|
383
|
+
run
|
|
384
|
+
};
|
|
385
|
+
//# sourceMappingURL=auth-cli.js.map
|
package/dist/build-info.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const CREDENTIALS_FILE_VERSION = 1;
|
|
2
|
+
export interface StoredCredentials {
|
|
3
|
+
version: number;
|
|
4
|
+
refresh_token: string;
|
|
5
|
+
customer_id: string;
|
|
6
|
+
customer_name?: string;
|
|
7
|
+
mcc_customer_id?: string | null;
|
|
8
|
+
obtained_at: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
}
|
|
11
|
+
export interface ResolvedCredentials {
|
|
12
|
+
client_id: string;
|
|
13
|
+
client_secret: string;
|
|
14
|
+
developer_token: string;
|
|
15
|
+
refresh_token: string;
|
|
16
|
+
customer_id: string;
|
|
17
|
+
mcc_customer_id: string;
|
|
18
|
+
source: "env" | "file" | "mixed";
|
|
19
|
+
}
|
|
20
|
+
export declare function readStoredCredentials(filePath?: string): StoredCredentials | null;
|
|
21
|
+
export declare function writeStoredCredentials(creds: StoredCredentials, filePath?: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve all credentials needed to make Google Ads API calls.
|
|
24
|
+
*
|
|
25
|
+
* Throws a descriptive Error if any required value is missing after
|
|
26
|
+
* walking the entire priority chain. The error message points users to
|
|
27
|
+
* the correct action (run the auth helper vs. set env vars).
|
|
28
|
+
*
|
|
29
|
+
* The optional `credsFilePath` parameter exists so tests can point at a
|
|
30
|
+
* tmpdir instead of reading the real ~/Library credentials file, and so
|
|
31
|
+
* the MCP server's multi-client mode can read a different location.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveCredentials(credsFilePath?: string): ResolvedCredentials;
|
|
34
|
+
export declare function validateResolvedCredentials(creds: ResolvedCredentials): {
|
|
35
|
+
valid: boolean;
|
|
36
|
+
issues: string[];
|
|
37
|
+
};
|
|
38
|
+
export { configDir, credentialsFilePath } from "./platform.js";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
EMBEDDED_CLIENT_ID,
|
|
5
|
+
EMBEDDED_CLIENT_SECRET,
|
|
6
|
+
EMBEDDED_DEVELOPER_TOKEN
|
|
7
|
+
} from "./embedded-secrets.js";
|
|
8
|
+
import { credentialsFilePath } from "./platform.js";
|
|
9
|
+
import { logger } from "./resilience.js";
|
|
10
|
+
const CREDENTIALS_FILE_VERSION = 1;
|
|
11
|
+
const envTrimmed = (key) => (process.env[key] || "").trim().replace(/^["']|["']$/g, "");
|
|
12
|
+
function readStoredCredentials(filePath = credentialsFilePath) {
|
|
13
|
+
if (!existsSync(filePath)) return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed.version !== CREDENTIALS_FILE_VERSION) {
|
|
18
|
+
logger.warn(
|
|
19
|
+
{ path: filePath, version: parsed.version, expected: CREDENTIALS_FILE_VERSION },
|
|
20
|
+
"Credentials file version mismatch \u2014 ignoring"
|
|
21
|
+
);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return parsed;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
logger.warn({ err, path: filePath }, "Failed to parse credentials file \u2014 ignoring");
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function writeStoredCredentials(creds, filePath = credentialsFilePath) {
|
|
31
|
+
const dir = path.dirname(filePath);
|
|
32
|
+
if (!existsSync(dir)) {
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
writeFileSync(filePath, JSON.stringify(creds, null, 2), { encoding: "utf-8" });
|
|
36
|
+
try {
|
|
37
|
+
chmodSync(filePath, 384);
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function resolveCredentials(credsFilePath = credentialsFilePath) {
|
|
42
|
+
const client_id = envTrimmed("GOOGLE_ADS_CLIENT_ID") || EMBEDDED_CLIENT_ID;
|
|
43
|
+
const client_secret = envTrimmed("GOOGLE_ADS_CLIENT_SECRET") || EMBEDDED_CLIENT_SECRET;
|
|
44
|
+
const developer_token = envTrimmed("GOOGLE_ADS_DEVELOPER_TOKEN") || EMBEDDED_DEVELOPER_TOKEN;
|
|
45
|
+
const stored = readStoredCredentials(credsFilePath);
|
|
46
|
+
const envRefresh = envTrimmed("GOOGLE_ADS_REFRESH_TOKEN");
|
|
47
|
+
const envCustomer = envTrimmed("GOOGLE_ADS_CUSTOMER_ID");
|
|
48
|
+
const envMcc = envTrimmed("GOOGLE_ADS_MCC_CUSTOMER_ID");
|
|
49
|
+
const refresh_token = envRefresh || stored?.refresh_token || "";
|
|
50
|
+
const customer_id = envCustomer || stored?.customer_id || "";
|
|
51
|
+
const mcc_customer_id = envMcc || stored?.mcc_customer_id || "";
|
|
52
|
+
const source = envRefresh && stored ? "mixed" : envRefresh ? "env" : stored ? "file" : "env";
|
|
53
|
+
const missing = [];
|
|
54
|
+
if (!client_id) missing.push("client_id");
|
|
55
|
+
if (!client_secret) missing.push("client_secret");
|
|
56
|
+
if (!developer_token) missing.push("developer_token");
|
|
57
|
+
if (!refresh_token) missing.push("refresh_token");
|
|
58
|
+
if (!customer_id) missing.push("customer_id");
|
|
59
|
+
if (missing.length > 0) {
|
|
60
|
+
throw new Error(buildMissingCredentialsMessage(missing, Boolean(stored)));
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
client_id,
|
|
64
|
+
client_secret,
|
|
65
|
+
developer_token,
|
|
66
|
+
refresh_token,
|
|
67
|
+
customer_id,
|
|
68
|
+
mcc_customer_id,
|
|
69
|
+
source
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function buildMissingCredentialsMessage(missing, hasFile) {
|
|
73
|
+
const runAuth = "npx mcp-google-ads-auth";
|
|
74
|
+
const lines = [
|
|
75
|
+
`Missing Google Ads credentials: ${missing.join(", ")}.`,
|
|
76
|
+
``,
|
|
77
|
+
`To get started, run:`,
|
|
78
|
+
` ${runAuth}`,
|
|
79
|
+
``,
|
|
80
|
+
`This will open your browser, walk you through Google sign-in, let you pick which`,
|
|
81
|
+
`Google Ads account to use, and save the result to:`,
|
|
82
|
+
` ${credentialsFilePath}`
|
|
83
|
+
];
|
|
84
|
+
if (hasFile) {
|
|
85
|
+
lines.push(
|
|
86
|
+
``,
|
|
87
|
+
`A credentials file exists at ${credentialsFilePath} but is missing required fields.`,
|
|
88
|
+
`Re-run the auth helper to refresh it.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
lines.push(
|
|
92
|
+
``,
|
|
93
|
+
`Advanced: you can bypass the auth helper by setting these env vars in your`,
|
|
94
|
+
`Claude Desktop config: GOOGLE_ADS_REFRESH_TOKEN, GOOGLE_ADS_CUSTOMER_ID.`
|
|
95
|
+
);
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
function validateResolvedCredentials(creds) {
|
|
99
|
+
const issues = [];
|
|
100
|
+
const check = (name, val, minLen = 10) => {
|
|
101
|
+
if (val.length < minLen) issues.push(`${name} too short (expected >=${minLen} chars, got ${val.length})`);
|
|
102
|
+
};
|
|
103
|
+
check("client_id", creds.client_id);
|
|
104
|
+
check("client_secret", creds.client_secret);
|
|
105
|
+
check("developer_token", creds.developer_token);
|
|
106
|
+
check("refresh_token", creds.refresh_token);
|
|
107
|
+
const custDigits = creds.customer_id.replace(/-/g, "");
|
|
108
|
+
if (!/^\d{10}$/.test(custDigits)) {
|
|
109
|
+
issues.push(`customer_id must be 10 digits (got "${creds.customer_id}")`);
|
|
110
|
+
}
|
|
111
|
+
return { valid: issues.length === 0, issues };
|
|
112
|
+
}
|
|
113
|
+
import { configDir as configDir2, credentialsFilePath as credentialsFilePath2 } from "./platform.js";
|
|
114
|
+
export {
|
|
115
|
+
CREDENTIALS_FILE_VERSION,
|
|
116
|
+
configDir2 as configDir,
|
|
117
|
+
credentialsFilePath2 as credentialsFilePath,
|
|
118
|
+
readStoredCredentials,
|
|
119
|
+
resolveCredentials,
|
|
120
|
+
validateResolvedCredentials,
|
|
121
|
+
writeStoredCredentials
|
|
122
|
+
};
|
|
123
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const EMBEDDED_CLIENT_ID = "557294086068-o7rb5neg65g28uf65j85q0h60cop40j9.apps.googleusercontent.com";
|
|
2
|
+
const EMBEDDED_CLIENT_SECRET = "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";
|
|
3
|
+
const EMBEDDED_DEVELOPER_TOKEN = "xQjWTuRLCJ_1UFpX1xLWlA";
|
|
4
|
+
function hasEmbeddedSecrets() {
|
|
5
|
+
return EMBEDDED_CLIENT_ID.length > 10 && EMBEDDED_CLIENT_SECRET.length > 10 && EMBEDDED_DEVELOPER_TOKEN.length > 10;
|
|
6
|
+
}
|
|
7
|
+
export {
|
|
8
|
+
EMBEDDED_CLIENT_ID,
|
|
9
|
+
EMBEDDED_CLIENT_SECRET,
|
|
10
|
+
EMBEDDED_DEVELOPER_TOKEN,
|
|
11
|
+
hasEmbeddedSecrets
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=embedded-secrets.js.map
|