@aligulzar729/google-ads-mcp 0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +391 -0
  3. package/dist/index.js +4494 -0
  4. package/package.json +73 -0
package/dist/index.js ADDED
@@ -0,0 +1,4494 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/lib/gaxiosNativeFetch.ts
4
+ import { Gaxios } from "gaxios";
5
+ var original = Gaxios.prototype.request;
6
+ Gaxios.prototype.request = function patchedRequest(opts) {
7
+ if (this.defaults && this.defaults.fetchImplementation == null) {
8
+ this.defaults.fetchImplementation = globalThis.fetch;
9
+ }
10
+ return original.call(this, opts);
11
+ };
12
+
13
+ // src/index.ts
14
+ import { createRequire as createRequire3 } from "module";
15
+
16
+ // src/env.ts
17
+ import { z } from "zod";
18
+ var base = z.object({
19
+ GOOGLE_ADS_DEVELOPER_TOKEN: z.string().min(1, "required (the business developer token)"),
20
+ GOOGLE_OAUTH_CLIENT_ID: z.string().min(1, "required"),
21
+ GOOGLE_OAUTH_CLIENT_SECRET: z.string().min(1, "required"),
22
+ LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
23
+ // Display name on OAuth consent + error pages; override when white-labeling.
24
+ APP_NAME: z.string().min(1).default("Google Ads MCP")
25
+ });
26
+ var httpExtra = z.object({
27
+ PORT: z.coerce.number().int().positive().default(3001),
28
+ // 32-byte AES key (base64) that encrypts users' Google refresh tokens at rest.
29
+ TOKEN_ENC_KEY: z.string().refine((s) => Buffer.from(s, "base64").length === 32, "required in --http mode: 32 bytes base64 (generate with: openssl rand -base64 32)"),
30
+ AUDIT_DB_PATH: z.string().default("./data/audit.db"),
31
+ // Public HTTPS base URL of this service; builds the OAuth redirect URI - register that exact URI in the Google OAuth client.
32
+ PUBLIC_BASE_URL: z.string().url("required in --http mode, e.g. https://acme.example.com"),
33
+ // Allowlisted redirect targets (origin, host, or *.wildcard); blocks the broker from registering/authorizing anything else, which stops phished /authorize redirects.
34
+ ALLOWED_REDIRECT_ORIGINS: z.string().min(1, 'required in --http mode, e.g. "*.example.com" or "https://apple.example.com"').transform((s, ctx) => {
35
+ const out = [];
36
+ const hostPat = /^(\*\.)?[a-z0-9.-]+$/;
37
+ for (const raw of s.split(",").map((x) => x.trim().toLowerCase()).filter(Boolean)) {
38
+ if (raw.includes("://")) {
39
+ try {
40
+ out.push(new URL(raw).origin);
41
+ } catch {
42
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `not a valid origin: ${raw}` });
43
+ }
44
+ } else if (hostPat.test(raw)) {
45
+ const labels = (raw.startsWith("*.") ? raw.slice(2) : raw).split(".");
46
+ const malformed = raw.startsWith(".") || raw.endsWith(".") || labels.some((l) => !l);
47
+ const tooBroad = labels.length < 2 && raw !== "localhost";
48
+ if (malformed || tooBroad) {
49
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `not a usable host/wildcard (use "apple.example.com" or "*.example.com"): ${raw}` });
50
+ } else {
51
+ out.push(raw);
52
+ }
53
+ } else {
54
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: `not a valid origin or host pattern: ${raw}` });
55
+ }
56
+ }
57
+ if (!out.length) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "at least one entry is required" });
58
+ return out;
59
+ })
60
+ });
61
+ var stdioExtra = z.object({
62
+ GOOGLE_ADS_REFRESH_TOKEN: z.string().default(""),
63
+ GOOGLE_ADS_CUSTOMER_ID: z.string().default(""),
64
+ GOOGLE_ADS_LOGIN_CUSTOMER_ID: z.string().default("")
65
+ });
66
+ function fail(prefix, err) {
67
+ const lines = err.issues.map((i) => ` - ${i.path.join(".") || "env"}: ${i.message}`);
68
+ process.stderr.write(`${prefix}
69
+ ${lines.join("\n")}
70
+ `);
71
+ process.exit(1);
72
+ }
73
+ function loadConfig(mode, raw = process.env) {
74
+ const b = base.safeParse(raw);
75
+ if (!b.success) fail("Invalid environment:", b.error);
76
+ const common = {
77
+ mode,
78
+ logLevel: b.data.LOG_LEVEL,
79
+ appName: b.data.APP_NAME,
80
+ developerToken: b.data.GOOGLE_ADS_DEVELOPER_TOKEN,
81
+ oauthClientId: b.data.GOOGLE_OAUTH_CLIENT_ID,
82
+ oauthClientSecret: b.data.GOOGLE_OAUTH_CLIENT_SECRET
83
+ };
84
+ if (mode === "http") {
85
+ const h = httpExtra.safeParse(raw);
86
+ if (!h.success) fail("Invalid environment for --http mode:", h.error);
87
+ return {
88
+ ...common,
89
+ port: h.data.PORT,
90
+ tokenEncKey: h.data.TOKEN_ENC_KEY,
91
+ auditDbPath: h.data.AUDIT_DB_PATH,
92
+ publicBaseUrl: h.data.PUBLIC_BASE_URL.replace(/\/$/, ""),
93
+ allowedRedirectOrigins: h.data.ALLOWED_REDIRECT_ORIGINS,
94
+ refreshToken: "",
95
+ customerId: "",
96
+ loginCustomerId: ""
97
+ };
98
+ }
99
+ const s = stdioExtra.safeParse(raw);
100
+ if (!s.success) fail("Invalid environment for --stdio mode:", s.error);
101
+ return {
102
+ ...common,
103
+ port: 0,
104
+ tokenEncKey: "",
105
+ auditDbPath: "",
106
+ publicBaseUrl: "",
107
+ allowedRedirectOrigins: [],
108
+ refreshToken: s.data.GOOGLE_ADS_REFRESH_TOKEN,
109
+ customerId: s.data.GOOGLE_ADS_CUSTOMER_ID,
110
+ loginCustomerId: s.data.GOOGLE_ADS_LOGIN_CUSTOMER_ID
111
+ };
112
+ }
113
+ var CONFIG_ENV_KEYS = [
114
+ ...Object.keys(base.shape),
115
+ ...Object.keys(httpExtra.shape),
116
+ ...Object.keys(stdioExtra.shape),
117
+ "REQUIRE_USER_CONFIRMATION"
118
+ ];
119
+ function parseConfigFlags(argv) {
120
+ const known = new Set(CONFIG_ENV_KEYS);
121
+ const modeFlags = /* @__PURE__ */ new Set(["--http", "--stdio", "--help", "-h", "--version", "-v"]);
122
+ const env = {};
123
+ const unknown = [];
124
+ for (const arg of argv) {
125
+ if (!arg.startsWith("--") || modeFlags.has(arg)) continue;
126
+ let body = arg.slice(2);
127
+ let value = "true";
128
+ const eq = body.indexOf("=");
129
+ if (eq >= 0) {
130
+ value = body.slice(eq + 1);
131
+ body = body.slice(0, eq);
132
+ } else if (body.startsWith("no-")) {
133
+ body = body.slice(3);
134
+ value = "false";
135
+ }
136
+ const key = body.replace(/-/g, "_").toUpperCase();
137
+ if (known.has(key)) env[key] = value;
138
+ else unknown.push(body);
139
+ }
140
+ return { env, unknown };
141
+ }
142
+
143
+ // src/logger.ts
144
+ var ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
145
+ var Logger = class {
146
+ constructor(threshold = "info") {
147
+ this.threshold = threshold;
148
+ }
149
+ threshold;
150
+ write(level, source, message, meta) {
151
+ if (ORDER[level] < ORDER[this.threshold]) return;
152
+ const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), level, source, message, ...meta ?? {} };
153
+ let line;
154
+ try {
155
+ line = JSON.stringify(entry) + "\n";
156
+ } catch {
157
+ line = JSON.stringify({ ts: entry.ts, level, source, message }) + "\n";
158
+ }
159
+ try {
160
+ process.stderr.write(line);
161
+ } catch {
162
+ }
163
+ }
164
+ debug(source, message, meta) {
165
+ this.write("debug", source, message, meta);
166
+ }
167
+ info(source, message, meta) {
168
+ this.write("info", source, message, meta);
169
+ }
170
+ warn(source, message, meta) {
171
+ this.write("warn", source, message, meta);
172
+ }
173
+ error(source, message, meta) {
174
+ this.write("error", source, message, meta);
175
+ }
176
+ /** Stream a structured activity entry (also persisted by the Audit store). */
177
+ activity(entry) {
178
+ this.write(entry.status === "error" ? "warn" : "info", entry.server, entry.summary, {
179
+ category: entry.category,
180
+ customerId: entry.customerId,
181
+ status: entry.status,
182
+ errorMessage: entry.errorMessage,
183
+ // Full raw error (stack/decoded GoogleAdsFailure); stays in the local JSONL stream, never returned to the client. Undefined on success.
184
+ errorDetail: entry.errorDetail
185
+ });
186
+ }
187
+ };
188
+
189
+ // src/ads/api.ts
190
+ import { GoogleAdsApi } from "google-ads-api";
191
+
192
+ // src/lib/retry.ts
193
+ var TRANSIENT_RE = /\b(RESOURCE_EXHAUSTED|INTERNAL|UNAVAILABLE|DEADLINE_EXCEEDED)\b/;
194
+ var TRANSIENT_CODES = /* @__PURE__ */ new Set([4, 8, 13, 14]);
195
+ function isRecord(v) {
196
+ return typeof v === "object" && v !== null;
197
+ }
198
+ function getPath(obj, keys) {
199
+ let cur = obj;
200
+ for (const k of keys) {
201
+ if (!isRecord(cur)) return void 0;
202
+ cur = cur[k];
203
+ }
204
+ return cur;
205
+ }
206
+ function failureErrorCodes(err) {
207
+ const errs = isRecord(err) ? err.errors : void 0;
208
+ if (!Array.isArray(errs)) return [];
209
+ const out = [];
210
+ for (const e of errs) {
211
+ const code = isRecord(e) ? e.error_code : void 0;
212
+ if (isRecord(code)) out.push(...Object.keys(code));
213
+ }
214
+ return out;
215
+ }
216
+ function toNum(v) {
217
+ if (v == null) return 0;
218
+ if (typeof v === "number") return Number.isFinite(v) ? v : 0;
219
+ if (typeof v === "string") {
220
+ const n4 = Number(v);
221
+ return Number.isFinite(n4) ? n4 : 0;
222
+ }
223
+ if (isRecord(v) && typeof v.toNumber === "function") {
224
+ const n4 = v.toNumber();
225
+ return Number.isFinite(n4) ? n4 : 0;
226
+ }
227
+ const n3 = Number(v);
228
+ return Number.isFinite(n3) ? n3 : 0;
229
+ }
230
+ function isTransient(err) {
231
+ if (!isRecord(err)) return false;
232
+ if (typeof err.code === "number" && TRANSIENT_CODES.has(err.code)) return true;
233
+ return TRANSIENT_RE.test(String(err.message ?? err));
234
+ }
235
+ function isQuotaExhausted(err) {
236
+ if (!isRecord(err)) return false;
237
+ if (err.code === 8) return true;
238
+ if (failureErrorCodes(err).includes("quota_error")) return true;
239
+ return /RESOURCE_EXHAUSTED/.test(String(err.message ?? err));
240
+ }
241
+ function parseRetryDelayMs(err) {
242
+ const errs = isRecord(err) ? err.errors : void 0;
243
+ if (!Array.isArray(errs)) return void 0;
244
+ for (const e of errs) {
245
+ const d = getPath(e, ["details", "quota_error_details", "retry_delay"]);
246
+ if (isRecord(d)) {
247
+ const ms = toNum(d.seconds) * 1e3 + Math.floor(toNum(d.nanos) / 1e6);
248
+ if (ms > 0) return ms;
249
+ }
250
+ }
251
+ return void 0;
252
+ }
253
+ var REAUTH_RE = /\b(invalid_grant|unauthorized_client|UNAUTHENTICATED)\b/;
254
+ function isReauthRequired(err) {
255
+ if (!isRecord(err)) return false;
256
+ if (err.code === 16) return true;
257
+ if (failureErrorCodes(err).includes("authentication_error")) return true;
258
+ return REAUTH_RE.test(String(err.message ?? err));
259
+ }
260
+ function retryDelayMs(err, attempt, opts = {}) {
261
+ const { baseDelayMs = 1e3, maxDelayMs = 6e4 } = opts;
262
+ if (isQuotaExhausted(err)) {
263
+ const rd = parseRetryDelayMs(err);
264
+ if (rd !== void 0) return Math.min(maxDelayMs, rd);
265
+ }
266
+ return Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
267
+ }
268
+ async function withRetry(fn, opts = {}) {
269
+ const { retries = 4 } = opts;
270
+ let attempt = 0;
271
+ while (true) {
272
+ try {
273
+ return await fn();
274
+ } catch (err) {
275
+ if (attempt >= retries || !(isTransient(err) || isQuotaExhausted(err))) throw err;
276
+ const base2 = retryDelayMs(err, attempt, opts);
277
+ const jitter = Math.random() * base2 * 0.25;
278
+ await new Promise((r) => setTimeout(r, base2 + jitter));
279
+ attempt++;
280
+ }
281
+ }
282
+ }
283
+
284
+ // src/ads/api.ts
285
+ var AdsService = class {
286
+ api;
287
+ constructor(opts) {
288
+ this.api = new GoogleAdsApi({
289
+ developer_token: opts.developerToken,
290
+ client_id: opts.clientId,
291
+ client_secret: opts.clientSecret
292
+ });
293
+ }
294
+ /** Per-call Customer: cheap to construct, and the SDK caches access tokens. */
295
+ customer(creds) {
296
+ return this.api.Customer({
297
+ customer_id: creds.customerId,
298
+ refresh_token: creds.refreshToken,
299
+ login_customer_id: creds.loginCustomerId
300
+ });
301
+ }
302
+ /** Run a GAQL query (1 op, regardless of rows). Retried on transient errors. */
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
+ async query(creds, gaql) {
305
+ return withRetry(() => this.customer(creds).query(gaql));
306
+ }
307
+ /** Fetches one bounded page plus its true total row count in a single Search call, so a capped report can say "top N of TOTAL" without a second round-trip. Uses the private `search` (via cast, like `searchFields`) since `query` buffers every page and `queryStream` can't carry a count. */
308
+ async searchPage(creds, gaql, pageSize) {
309
+ const customer = this.customer(creds);
310
+ return withRetry(async () => {
311
+ const raw = await customer.search(gaql, { search_settings: { return_total_results_count: true } });
312
+ return interpretSearchPage(raw, pageSize);
313
+ });
314
+ }
315
+ /** The account's reporting timezone (e.g. "Asia/Tokyo"), cached. Used to align report date windows. */
316
+ tzCache = /* @__PURE__ */ new Map();
317
+ async timeZone(creds) {
318
+ const cached = this.tzCache.get(creds.customerId);
319
+ if (cached) return cached;
320
+ try {
321
+ const rows = await this.query(creds, "SELECT customer.time_zone FROM customer LIMIT 1");
322
+ const tz = rows[0]?.customer?.time_zone;
323
+ if (tz) this.tzCache.set(creds.customerId, tz);
324
+ return tz;
325
+ } catch {
326
+ return void 0;
327
+ }
328
+ }
329
+ /** The account's reporting currency (e.g. "JPY", "USD"), cached. Used to label cost in reports. */
330
+ currencyCache = /* @__PURE__ */ new Map();
331
+ async currencyCode(creds) {
332
+ const cached = this.currencyCache.get(creds.customerId);
333
+ if (cached) return cached;
334
+ try {
335
+ const rows = await this.query(creds, "SELECT customer.currency_code FROM customer LIMIT 1");
336
+ const code = rows[0]?.customer?.currency_code;
337
+ if (code) this.currencyCache.set(creds.customerId, code);
338
+ return code;
339
+ } catch {
340
+ return void 0;
341
+ }
342
+ }
343
+ /** Accounts this refresh token can reach, as bare 10-digit ids; ignores login-customer-id, returning only directly-accessible accounts (the valid account-picker values). */
344
+ async listAccessibleCustomers(refreshToken) {
345
+ const res = await withRetry(() => this.api.listAccessibleCustomers(refreshToken));
346
+ return res.resource_names.map((rn) => rn.split("/")[1] ?? "").filter(Boolean);
347
+ }
348
+ /** Account-picker label (manager flag + display name); best-effort, returns {} if unqueryable so `list` never fails over one account. */
349
+ async accountMeta(refreshToken, customerId) {
350
+ try {
351
+ const rows = await this.query({ refreshToken, customerId }, "SELECT customer.manager, customer.descriptive_name FROM customer LIMIT 1");
352
+ const c = rows[0]?.customer;
353
+ return { manager: c?.manager, name: c?.descriptive_name };
354
+ } catch {
355
+ return {};
356
+ }
357
+ }
358
+ /** Field metadata via GoogleAdsFieldService, powering the report `metadata` action so the model discovers valid fields instead of guessing; the service is private on the Opteo client, reached via cast. */
359
+ async searchFields(creds, where) {
360
+ const customer = this.customer(creds);
361
+ const svc = customer.googleAdsFields;
362
+ const query = `SELECT name, category, data_type, selectable, filterable, sortable, metrics, segments, selectable_with WHERE ${where}`;
363
+ const res = await withRetry(() => svc.searchGoogleAdsFields({ query }));
364
+ const rows = Array.isArray(res) ? res[0] : res.results ?? res.response;
365
+ return rows ?? [];
366
+ }
367
+ };
368
+ function interpretSearchPage(raw, cap) {
369
+ const all = raw.response ?? [];
370
+ const rows = all.slice(0, cap);
371
+ const reported = raw.totalResultsCount != null ? Number(raw.totalResultsCount) : void 0;
372
+ const total = reported ?? (raw.nextPageToken ? void 0 : all.length);
373
+ const truncated = Boolean(raw.nextPageToken) || all.length > rows.length || total != null && total > rows.length;
374
+ return { rows, total, truncated };
375
+ }
376
+
377
+ // src/store/db.ts
378
+ import { createRequire } from "module";
379
+ import fs from "fs";
380
+ import path from "path";
381
+ var requireCjs = createRequire(import.meta.url);
382
+ function openDb(file) {
383
+ const sqlite = loadSqlite();
384
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
385
+ const db = new sqlite.DatabaseSync(file);
386
+ db.exec("PRAGMA journal_mode = WAL");
387
+ migrate(db);
388
+ return db;
389
+ }
390
+ function loadSqlite() {
391
+ silenceSqliteExperimentalWarning();
392
+ try {
393
+ return requireCjs("node:sqlite");
394
+ } catch (err) {
395
+ throw new Error(
396
+ `HTTP mode needs Node's built-in SQLite ("node:sqlite"), available in Node >= 22.5 (some 22.x builds require the --experimental-sqlite flag). This runtime does not provide it; upgrade Node to run HTTP mode. stdio mode does not use it. Cause: ` + (err instanceof Error ? err.message : String(err))
397
+ );
398
+ }
399
+ }
400
+ var warningSilenced = false;
401
+ function silenceSqliteExperimentalWarning() {
402
+ if (warningSilenced) return;
403
+ warningSilenced = true;
404
+ const original2 = process.emitWarning.bind(process);
405
+ process.emitWarning = ((warning, ...rest) => {
406
+ const message = typeof warning === "string" ? warning : warning?.message;
407
+ if (/SQLite is an experimental feature/.test(String(message ?? ""))) return;
408
+ original2(warning, ...rest);
409
+ });
410
+ }
411
+ function migrate(db) {
412
+ db.exec(`
413
+ -- Append-only audit trail; mutations must leave a durable record.
414
+ CREATE TABLE IF NOT EXISTS audit (
415
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
416
+ ts INTEGER NOT NULL,
417
+ category TEXT NOT NULL,
418
+ summary TEXT NOT NULL,
419
+ customer_id TEXT,
420
+ status TEXT NOT NULL,
421
+ error_message TEXT
422
+ );
423
+ CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts);
424
+
425
+ -- OAuth broker: DCR-registered MCP clients. PKCE-only, no client secret.
426
+ CREATE TABLE IF NOT EXISTS oauth_clients (
427
+ client_id TEXT PRIMARY KEY,
428
+ client_name TEXT,
429
+ redirect_uris TEXT NOT NULL, -- JSON array; exact-match validated
430
+ created_ts INTEGER NOT NULL
431
+ );
432
+
433
+ -- One session per (user, client); holds the AES-256-GCM-encrypted Google refresh token.
434
+ CREATE TABLE IF NOT EXISTS oauth_sessions (
435
+ id TEXT PRIMARY KEY,
436
+ client_id TEXT NOT NULL,
437
+ google_refresh_enc TEXT NOT NULL, -- iv.tag.ciphertext (base64url)
438
+ scope TEXT NOT NULL,
439
+ customer_id TEXT, -- active account
440
+ accessible_customers TEXT, -- JSON array
441
+ created_ts INTEGER NOT NULL,
442
+ last_used_ts INTEGER NOT NULL
443
+ );
444
+
445
+ -- Auth codes and our tokens are stored as SHA-256 hashes only.
446
+ CREATE TABLE IF NOT EXISTS oauth_codes (
447
+ code_hash TEXT PRIMARY KEY,
448
+ client_id TEXT NOT NULL,
449
+ redirect_uri TEXT NOT NULL,
450
+ pkce_challenge TEXT NOT NULL,
451
+ session_id TEXT NOT NULL,
452
+ expires_ts INTEGER NOT NULL
453
+ );
454
+
455
+ CREATE TABLE IF NOT EXISTS oauth_tokens (
456
+ token_hash TEXT PRIMARY KEY,
457
+ session_id TEXT NOT NULL,
458
+ kind TEXT NOT NULL, -- 'access' | 'refresh'
459
+ expires_ts INTEGER NOT NULL
460
+ );
461
+ CREATE INDEX IF NOT EXISTS idx_oauth_tokens_session ON oauth_tokens(session_id);
462
+ CREATE INDEX IF NOT EXISTS idx_oauth_tokens_expires ON oauth_tokens(expires_ts);
463
+ CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_ts);
464
+ CREATE INDEX IF NOT EXISTS idx_oauth_sessions_created ON oauth_sessions(created_ts);
465
+ `);
466
+ }
467
+
468
+ // src/store/audit.ts
469
+ var Audit = class {
470
+ constructor(db, logger) {
471
+ this.db = db;
472
+ this.logger = logger;
473
+ }
474
+ db;
475
+ logger;
476
+ /** True when a durable, queryable audit store exists (i.e. --http mode). */
477
+ get hasStore() {
478
+ return this.db !== null;
479
+ }
480
+ record(entry) {
481
+ this.logger.activity(entry);
482
+ if (!this.db) return;
483
+ try {
484
+ this.db.prepare("INSERT INTO audit (ts, category, summary, customer_id, status, error_message) VALUES (?, ?, ?, ?, ?, ?)").run(entry.timestamp, entry.category, entry.summary, entry.customerId ?? null, entry.status, entry.errorMessage ?? null);
485
+ } catch (e) {
486
+ this.logger.warn("audit", "failed to persist audit row", { error: e instanceof Error ? e.message : String(e) });
487
+ }
488
+ }
489
+ /** Read recent audit rows for one customer, newest first. Empty if no store. */
490
+ query(q) {
491
+ if (!this.db) return [];
492
+ const where = ["customer_id = ?"];
493
+ const params = [q.customerId];
494
+ if (q.category) {
495
+ where.push("category = ?");
496
+ params.push(q.category);
497
+ }
498
+ if (q.status) {
499
+ where.push("status = ?");
500
+ params.push(q.status);
501
+ }
502
+ if (q.sinceTs !== void 0) {
503
+ where.push("ts >= ?");
504
+ params.push(q.sinceTs);
505
+ }
506
+ params.push(q.limit);
507
+ return this.db.prepare(
508
+ `SELECT ts, category, summary, customer_id, status, error_message
509
+ FROM audit WHERE ${where.join(" AND ")}
510
+ ORDER BY ts DESC, id DESC LIMIT ?`
511
+ ).all(...params);
512
+ }
513
+ };
514
+
515
+ // src/oauth/crypto.ts
516
+ import crypto from "crypto";
517
+ var ALG = "aes-256-gcm";
518
+ var IV_BYTES = 12;
519
+ function loadEncKey(b64) {
520
+ const key = Buffer.from(b64, "base64");
521
+ if (key.length !== 32) {
522
+ throw new Error(`TOKEN_ENC_KEY must decode to 32 bytes (got ${key.length}). Generate one with: openssl rand -base64 32`);
523
+ }
524
+ return key;
525
+ }
526
+ function encryptSecret(plaintext, key) {
527
+ const iv = crypto.randomBytes(IV_BYTES);
528
+ const cipher = crypto.createCipheriv(ALG, key, iv);
529
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
530
+ const tag = cipher.getAuthTag();
531
+ return `${iv.toString("base64url")}.${tag.toString("base64url")}.${ct.toString("base64url")}`;
532
+ }
533
+ function decryptSecret(blob, key) {
534
+ const parts = blob.split(".");
535
+ if (parts.length !== 3) throw new Error("malformed ciphertext");
536
+ const [ivB, tagB, ctB] = parts;
537
+ const decipher = crypto.createDecipheriv(ALG, key, Buffer.from(ivB, "base64url"));
538
+ decipher.setAuthTag(Buffer.from(tagB, "base64url"));
539
+ return Buffer.concat([decipher.update(Buffer.from(ctB, "base64url")), decipher.final()]).toString("utf8");
540
+ }
541
+ function randomToken(bytes = 32) {
542
+ return crypto.randomBytes(bytes).toString("base64url");
543
+ }
544
+ function sha256Hex(value) {
545
+ return crypto.createHash("sha256").update(value).digest("hex");
546
+ }
547
+ function verifyPkceS256(verifier, challenge) {
548
+ if (!verifier || !challenge) return false;
549
+ const computed = crypto.createHash("sha256").update(verifier).digest("base64url");
550
+ if (computed.length !== challenge.length) return false;
551
+ return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(challenge));
552
+ }
553
+
554
+ // src/oauth/store.ts
555
+ var TXN_TTL_MS = 10 * 6e4;
556
+ var CODE_TTL_MS = 6e4;
557
+ var ACCESS_TTL_MS = 60 * 6e4;
558
+ var REFRESH_TTL_MS = 180 * 24 * 60 * 6e4;
559
+ var MAX_TXNS = 5e3;
560
+ var SESSION_GC_GRACE_MS = 60 * 6e4;
561
+ var OAuthStore = class {
562
+ constructor(db, encKey, now = () => Date.now()) {
563
+ this.db = db;
564
+ this.encKey = encKey;
565
+ this.now = now;
566
+ }
567
+ db;
568
+ encKey;
569
+ now;
570
+ txns = /* @__PURE__ */ new Map();
571
+ // Clients (Dynamic Client Registration)
572
+ registerClient(redirectUris, clientName) {
573
+ const clientId = `mcp_${randomToken(16)}`;
574
+ this.db.prepare("INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_ts) VALUES (?, ?, ?, ?)").run(clientId, clientName ?? null, JSON.stringify(redirectUris), this.now());
575
+ return { clientId, clientName, redirectUris };
576
+ }
577
+ getClient(clientId) {
578
+ const row = this.db.prepare("SELECT client_id, client_name, redirect_uris FROM oauth_clients WHERE client_id = ?").get(clientId);
579
+ if (!row) return null;
580
+ return { clientId: row.client_id, clientName: row.client_name ?? void 0, redirectUris: JSON.parse(row.redirect_uris) };
581
+ }
582
+ // Pending authorize transactions (in-memory, single-use)
583
+ putTxn(txn) {
584
+ if (this.txns.size >= MAX_TXNS) {
585
+ const cutoff = this.now() - TXN_TTL_MS;
586
+ for (const [k, v] of this.txns) if (v.createdMs < cutoff) this.txns.delete(k);
587
+ if (this.txns.size >= MAX_TXNS) {
588
+ const oldest = this.txns.keys().next().value;
589
+ if (oldest !== void 0) this.txns.delete(oldest);
590
+ }
591
+ }
592
+ const state = randomToken(16);
593
+ this.txns.set(state, { ...txn, createdMs: this.now() });
594
+ return state;
595
+ }
596
+ /** Read-and-delete (single use); returns null if missing or expired. */
597
+ takeTxn(state) {
598
+ const txn = this.txns.get(state);
599
+ if (!txn) return null;
600
+ this.txns.delete(state);
601
+ if (this.now() - txn.createdMs > TXN_TTL_MS) return null;
602
+ return txn;
603
+ }
604
+ // Sessions (hold the encrypted Google refresh token)
605
+ createSession(args) {
606
+ const id = randomToken(16);
607
+ const ts = this.now();
608
+ this.db.prepare(
609
+ "INSERT INTO oauth_sessions (id, client_id, google_refresh_enc, scope, customer_id, accessible_customers, created_ts, last_used_ts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
610
+ ).run(id, args.clientId, encryptSecret(args.googleRefreshToken, this.encKey), args.scope, args.customerId, JSON.stringify(args.accessibleCustomers), ts, ts);
611
+ return id;
612
+ }
613
+ getSession(id) {
614
+ const row = this.db.prepare("SELECT id, client_id, scope, customer_id, accessible_customers FROM oauth_sessions WHERE id = ?").get(id);
615
+ if (!row) return null;
616
+ return {
617
+ id: row.id,
618
+ clientId: row.client_id,
619
+ scope: row.scope,
620
+ customerId: row.customer_id,
621
+ accessibleCustomers: row.accessible_customers ? JSON.parse(row.accessible_customers) : []
622
+ };
623
+ }
624
+ /** Decrypts and returns the Google refresh token for use with the Ads client. */
625
+ getGoogleRefreshToken(sessionId) {
626
+ const row = this.db.prepare("SELECT google_refresh_enc FROM oauth_sessions WHERE id = ?").get(sessionId);
627
+ return row ? decryptSecret(row.google_refresh_enc, this.encKey) : null;
628
+ }
629
+ setActiveCustomer(sessionId, customerId) {
630
+ this.db.prepare("UPDATE oauth_sessions SET customer_id = ?, last_used_ts = ? WHERE id = ?").run(customerId, this.now(), sessionId);
631
+ }
632
+ touchSession(id) {
633
+ this.db.prepare("UPDATE oauth_sessions SET last_used_ts = ? WHERE id = ?").run(this.now(), id);
634
+ }
635
+ deleteSession(id) {
636
+ this.db.prepare("DELETE FROM oauth_sessions WHERE id = ?").run(id);
637
+ this.db.prepare("DELETE FROM oauth_tokens WHERE session_id = ?").run(id);
638
+ this.db.prepare("DELETE FROM oauth_codes WHERE session_id = ?").run(id);
639
+ }
640
+ // Authorization codes (hashed, single-use, short TTL)
641
+ issueCode(info) {
642
+ const code = randomToken(32);
643
+ this.db.prepare("INSERT INTO oauth_codes (code_hash, client_id, redirect_uri, pkce_challenge, session_id, expires_ts) VALUES (?, ?, ?, ?, ?, ?)").run(sha256Hex(code), info.clientId, info.redirectUri, info.pkceChallenge, info.sessionId, this.now() + CODE_TTL_MS);
644
+ return code;
645
+ }
646
+ /** Read-and-delete (single use); returns null if unknown or expired. */
647
+ takeCode(code) {
648
+ const hash = sha256Hex(code);
649
+ const row = this.db.prepare("SELECT client_id, redirect_uri, pkce_challenge, session_id, expires_ts FROM oauth_codes WHERE code_hash = ?").get(hash);
650
+ if (!row) return null;
651
+ this.db.prepare("DELETE FROM oauth_codes WHERE code_hash = ?").run(hash);
652
+ if (this.now() > row.expires_ts) return null;
653
+ return { clientId: row.client_id, redirectUri: row.redirect_uri, pkceChallenge: row.pkce_challenge, sessionId: row.session_id };
654
+ }
655
+ // Our issued tokens (hashed)
656
+ issueToken(sessionId, kind, ttlMs) {
657
+ const token = randomToken(32);
658
+ this.db.prepare("INSERT INTO oauth_tokens (token_hash, session_id, kind, expires_ts) VALUES (?, ?, ?, ?)").run(sha256Hex(token), sessionId, kind, this.now() + ttlMs);
659
+ return token;
660
+ }
661
+ /** Returns the session id if the token is valid, of the right kind, and unexpired. */
662
+ resolveToken(token, kind) {
663
+ const row = this.db.prepare("SELECT session_id, kind, expires_ts FROM oauth_tokens WHERE token_hash = ?").get(sha256Hex(token));
664
+ if (!row || row.kind !== kind || this.now() > row.expires_ts) return null;
665
+ return row.session_id;
666
+ }
667
+ /** Slides a refresh token's expiry forward on each successful refresh, so only true idle time (never active use) can expire it. */
668
+ touchRefreshToken(token, ttlMs) {
669
+ const now = this.now();
670
+ this.db.prepare("UPDATE oauth_tokens SET expires_ts = ? WHERE token_hash = ? AND kind = 'refresh' AND expires_ts > ?").run(now + ttlMs, sha256Hex(token), now);
671
+ }
672
+ revokeToken(token) {
673
+ this.db.prepare("DELETE FROM oauth_tokens WHERE token_hash = ?").run(sha256Hex(token));
674
+ }
675
+ revokeSessionTokens(sessionId) {
676
+ this.db.prepare("DELETE FROM oauth_tokens WHERE session_id = ?").run(sessionId);
677
+ }
678
+ /** Periodic cleanup of expired codes/tokens and stale in-memory transactions. */
679
+ sweepExpired() {
680
+ const now = this.now();
681
+ this.db.prepare("DELETE FROM oauth_codes WHERE expires_ts < ?").run(now);
682
+ this.db.prepare("DELETE FROM oauth_tokens WHERE expires_ts < ?").run(now);
683
+ this.db.prepare(
684
+ `DELETE FROM oauth_sessions
685
+ WHERE created_ts < ?
686
+ AND id NOT IN (SELECT session_id FROM oauth_tokens WHERE expires_ts > ?)`
687
+ ).run(now - SESSION_GC_GRACE_MS, now);
688
+ this.db.prepare("DELETE FROM oauth_tokens WHERE session_id NOT IN (SELECT id FROM oauth_sessions)").run();
689
+ this.db.prepare("DELETE FROM oauth_codes WHERE session_id NOT IN (SELECT id FROM oauth_sessions)").run();
690
+ const cutoff = now - TXN_TTL_MS;
691
+ for (const [k, v] of this.txns) if (v.createdMs < cutoff) this.txns.delete(k);
692
+ }
693
+ };
694
+
695
+ // src/lib/pagecache.ts
696
+ import { randomUUID } from "crypto";
697
+ var PageCache = class {
698
+ entries = /* @__PURE__ */ new Map();
699
+ TTL = 5 * 60 * 1e3;
700
+ MAX_ENTRIES = 100;
701
+ /** Park a fully-rendered row set; returns a cursor id. */
702
+ create(scope, title, rows, startOffset, customerId) {
703
+ this.sweep();
704
+ const id = randomUUID().slice(0, 12);
705
+ this.entries.set(id, { scope, title, rows, offset: startOffset, total: rows.length, createdAt: Date.now(), customerId: customerId ?? scope });
706
+ return id;
707
+ }
708
+ /** Fetch an entry, enforcing scope + TTL. Touches it (LRU-by-access). */
709
+ get(id, scope) {
710
+ const e = this.entries.get(id);
711
+ if (!e) return null;
712
+ if (e.scope !== scope) return null;
713
+ if (Date.now() - e.createdAt > this.TTL) {
714
+ this.entries.delete(id);
715
+ return null;
716
+ }
717
+ e.createdAt = Date.now();
718
+ return e;
719
+ }
720
+ /** Advance the cursor; drop the entry once exhausted. */
721
+ advance(id, newOffset) {
722
+ const e = this.entries.get(id);
723
+ if (!e) return;
724
+ if (newOffset >= e.total) this.entries.delete(id);
725
+ else e.offset = newOffset;
726
+ }
727
+ sweep() {
728
+ const now = Date.now();
729
+ for (const [id, e] of this.entries) if (now - e.createdAt > this.TTL) this.entries.delete(id);
730
+ while (this.entries.size >= this.MAX_ENTRIES) {
731
+ let oldestId = null;
732
+ let oldestAt = Infinity;
733
+ for (const [id, e] of this.entries) if (e.createdAt < oldestAt) oldestAt = e.createdAt, oldestId = id;
734
+ if (oldestId) this.entries.delete(oldestId);
735
+ else break;
736
+ }
737
+ }
738
+ };
739
+
740
+ // src/app.ts
741
+ import { Hono } from "hono";
742
+ import { bodyLimit } from "hono/body-limit";
743
+ import { serve } from "@hono/node-server";
744
+ import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
745
+
746
+ // src/lib/rateLimit.ts
747
+ var RateLimiter = class {
748
+ constructor(limit, windowMs) {
749
+ this.limit = limit;
750
+ this.windowMs = windowMs;
751
+ }
752
+ limit;
753
+ windowMs;
754
+ hits = /* @__PURE__ */ new Map();
755
+ /** Returns true if the key is still under the limit (and records this hit). */
756
+ allow(key) {
757
+ const now = Date.now();
758
+ const cutoff = now - this.windowMs;
759
+ let times = this.hits.get(key);
760
+ if (!times) {
761
+ times = [];
762
+ this.hits.set(key, times);
763
+ }
764
+ let i = 0;
765
+ while (i < times.length && times[i] < cutoff) i++;
766
+ if (i > 0) times.splice(0, i);
767
+ if (times.length >= this.limit) return false;
768
+ times.push(now);
769
+ if (this.hits.size > 1e4) {
770
+ for (const [k, t] of this.hits) {
771
+ if (!t.length || t[t.length - 1] < cutoff) this.hits.delete(k);
772
+ }
773
+ }
774
+ return true;
775
+ }
776
+ };
777
+
778
+ // src/lib/startup.ts
779
+ import fs2 from "fs";
780
+ import path2 from "path";
781
+ function runStartupChecks(config, logger) {
782
+ const base2 = config.publicBaseUrl;
783
+ logger.info("startup", `OAuth issuer: ${base2} (MCP endpoint: ${base2}/mcp)`);
784
+ logger.info("startup", `Register this redirect URI on the Google OAuth client: ${base2}/auth/callback`);
785
+ const isHttps = base2.startsWith("https://");
786
+ const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/.test(base2);
787
+ if (!isHttps && !isLocal) {
788
+ logger.warn("startup", `PUBLIC_BASE_URL is not HTTPS (${base2}). Google rejects non-HTTPS redirect URIs and the consent cookie is Secure-only, so the connect flow will fail.`);
789
+ }
790
+ checkPersistence(config, logger);
791
+ void probePublicEndpoints(base2, logger);
792
+ }
793
+ function checkPersistence(config, logger) {
794
+ try {
795
+ const dir = path2.dirname(path2.resolve(config.auditDbPath));
796
+ const mounts = fs2.readFileSync("/proc/self/mountinfo", "utf8").split("\n").map((l) => l.split(" ")[4]).filter((m) => Boolean(m) && m !== "/");
797
+ const onMount = mounts.some((m) => dir === m || dir.startsWith(m.endsWith("/") ? m : `${m}/`));
798
+ if (!onMount) {
799
+ logger.warn(
800
+ "startup",
801
+ `DB dir ${dir} is NOT on a mounted volume. The audit log AND every user's stored Google authorization will be LOST on container recreate. Mount a volume there.`
802
+ );
803
+ }
804
+ } catch {
805
+ }
806
+ }
807
+ async function probePublicEndpoints(base2, logger) {
808
+ const url = `${base2}/.well-known/oauth-protected-resource`;
809
+ const ctrl = new AbortController();
810
+ const timer = setTimeout(() => ctrl.abort(), 5e3);
811
+ timer.unref?.();
812
+ try {
813
+ const res = await fetch(url, { signal: ctrl.signal, redirect: "manual" });
814
+ const text = await res.text().catch(() => "");
815
+ if (res.ok && text.includes("authorization_servers")) {
816
+ logger.info("startup", "Public OAuth endpoints reachable. The reverse proxy is forwarding this subdomain to the container.");
817
+ } else {
818
+ logger.warn(
819
+ "startup",
820
+ `${url} did not return this server's metadata (HTTP ${res.status}). Check the reverse proxy forwards this subdomain to the container, plus DNS/TLS for ${base2}. (Best-effort; ignore if connect works for users.)`
821
+ );
822
+ }
823
+ } catch (e) {
824
+ logger.warn(
825
+ "startup",
826
+ `Could not reach ${url} from inside the container (${e instanceof Error ? e.message : String(e)}). Verify the reverse proxy and DNS/TLS for ${base2}. (Best-effort.)`
827
+ );
828
+ } finally {
829
+ clearTimeout(timer);
830
+ }
831
+ }
832
+
833
+ // src/mcp/router.ts
834
+ import { randomUUID as randomUUID2 } from "crypto";
835
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
836
+ function writeJson(res, status, obj) {
837
+ if (res.headersSent) return;
838
+ res.writeHead(status, { "Content-Type": "application/json" });
839
+ res.end(JSON.stringify(obj));
840
+ }
841
+ var McpSessionManager = class {
842
+ constructor(makeServer, logger) {
843
+ this.makeServer = makeServer;
844
+ this.logger = logger;
845
+ this.sweepTimer = setInterval(() => this.sweep(), 5 * 60 * 1e3);
846
+ this.sweepTimer.unref?.();
847
+ }
848
+ makeServer;
849
+ logger;
850
+ sessions = /* @__PURE__ */ new Map();
851
+ sweepTimer;
852
+ MAX_SESSIONS = 200;
853
+ IDLE_TTL = 12 * 60 * 60 * 1e3;
854
+ ABS_MAX = 48 * 60 * 60 * 1e3;
855
+ KEEPALIVE = 60 * 1e3;
856
+ safeClose(fn) {
857
+ try {
858
+ const r = fn();
859
+ if (r && typeof r.then === "function") r.catch(() => {
860
+ });
861
+ } catch {
862
+ }
863
+ }
864
+ destroy(sid) {
865
+ const s = this.sessions.get(sid);
866
+ if (!s) return;
867
+ this.sessions.delete(sid);
868
+ if (s.keepaliveTimer) clearInterval(s.keepaliveTimer);
869
+ this.safeClose(() => s.transport.close?.());
870
+ this.safeClose(() => s.server.close());
871
+ }
872
+ startKeepalive(session) {
873
+ session.keepaliveTimer = setInterval(() => {
874
+ session.server.server.notification({ method: "notifications/message", params: { level: "debug", data: { type: "keepalive", ts: Date.now() } } }).catch(() => {
875
+ });
876
+ }, this.KEEPALIVE);
877
+ session.keepaliveTimer.unref?.();
878
+ }
879
+ sweep() {
880
+ const now = Date.now();
881
+ for (const [sid, s] of this.sessions) {
882
+ if (s.inFlight > 0) continue;
883
+ if (now - s.lastActivity > this.IDLE_TTL || now - s.createdAt > this.ABS_MAX) this.destroy(sid);
884
+ }
885
+ }
886
+ async handlePost(incoming, outgoing, body, creds, oauthSessionId) {
887
+ const sessionId = incoming.headers["mcp-session-id"];
888
+ const existing = sessionId ? this.sessions.get(sessionId) : void 0;
889
+ if (existing) {
890
+ existing.lastActivity = Date.now();
891
+ existing.inFlight++;
892
+ try {
893
+ await existing.runWithAuth(creds, oauthSessionId, () => existing.transport.handleRequest(incoming, outgoing, body));
894
+ } finally {
895
+ existing.inFlight--;
896
+ existing.lastActivity = Date.now();
897
+ }
898
+ return;
899
+ }
900
+ if (sessionId) {
901
+ writeJson(outgoing, 400, { jsonrpc: "2.0", error: { code: -32e3, message: "Unknown session; please re-initialize." }, id: null });
902
+ return;
903
+ }
904
+ if (this.sessions.size >= this.MAX_SESSIONS) {
905
+ const stale = [...this.sessions.entries()].filter(([, s]) => s.inFlight === 0).sort((a, b) => a[1].lastActivity - b[1].lastActivity);
906
+ const evict = Math.min(stale.length, Math.ceil(this.sessions.size * 0.2));
907
+ for (let i = 0; i < evict; i++) this.destroy(stale[i][0]);
908
+ if (this.sessions.size >= this.MAX_SESSIONS) {
909
+ writeJson(outgoing, 503, { error: "Too many active sessions. Try again later." });
910
+ return;
911
+ }
912
+ }
913
+ const { server, runWithAuth } = this.makeServer();
914
+ const transport = new StreamableHTTPServerTransport({
915
+ sessionIdGenerator: () => randomUUID2(),
916
+ onsessioninitialized: (sid) => {
917
+ const session = { transport, server, runWithAuth, lastActivity: Date.now(), createdAt: Date.now(), inFlight: 1, keepaliveTimer: null };
918
+ this.sessions.set(sid, session);
919
+ this.startKeepalive(session);
920
+ }
921
+ });
922
+ transport.onclose = () => {
923
+ if (transport.sessionId) this.destroy(transport.sessionId);
924
+ };
925
+ transport.onerror = (err) => {
926
+ this.logger.error("mcp", "transport error", { error: err.message });
927
+ if (transport.sessionId) this.destroy(transport.sessionId);
928
+ };
929
+ try {
930
+ await runWithAuth(creds, oauthSessionId, async () => {
931
+ await server.connect(transport);
932
+ await transport.handleRequest(incoming, outgoing, body);
933
+ });
934
+ } catch (err) {
935
+ if (transport.sessionId) {
936
+ this.destroy(transport.sessionId);
937
+ } else {
938
+ this.safeClose(() => transport.close?.());
939
+ this.safeClose(() => server.close());
940
+ }
941
+ throw err;
942
+ } finally {
943
+ const s = transport.sessionId ? this.sessions.get(transport.sessionId) : void 0;
944
+ if (s) {
945
+ s.inFlight--;
946
+ s.lastActivity = Date.now();
947
+ }
948
+ }
949
+ }
950
+ async handleGet(incoming, outgoing) {
951
+ const sessionId = incoming.headers["mcp-session-id"];
952
+ const session = sessionId ? this.sessions.get(sessionId) : void 0;
953
+ if (!session) {
954
+ writeJson(outgoing, 400, { error: "Invalid or missing session id." });
955
+ return;
956
+ }
957
+ session.lastActivity = Date.now();
958
+ session.inFlight++;
959
+ try {
960
+ await session.transport.handleRequest(incoming, outgoing);
961
+ } finally {
962
+ session.inFlight--;
963
+ session.lastActivity = Date.now();
964
+ }
965
+ }
966
+ async handleDelete(incoming, outgoing) {
967
+ const sessionId = incoming.headers["mcp-session-id"];
968
+ const session = sessionId ? this.sessions.get(sessionId) : void 0;
969
+ if (!session || !sessionId) {
970
+ writeJson(outgoing, 400, { error: "Invalid or missing session id." });
971
+ return;
972
+ }
973
+ session.inFlight++;
974
+ try {
975
+ await session.transport.handleRequest(incoming, outgoing);
976
+ } finally {
977
+ session.inFlight--;
978
+ }
979
+ this.destroy(sessionId);
980
+ }
981
+ close() {
982
+ clearInterval(this.sweepTimer);
983
+ for (const sid of [...this.sessions.keys()]) this.destroy(sid);
984
+ }
985
+ };
986
+
987
+ // src/mcp/server.ts
988
+ import { createRequire as createRequire2 } from "module";
989
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
990
+
991
+ // src/types.ts
992
+ function formatErrorDetail(e) {
993
+ if (e instanceof Error) return e.stack || `${e.name}: ${e.message}`;
994
+ if (typeof e === "string") return e;
995
+ try {
996
+ return JSON.stringify(e, null, 2);
997
+ } catch {
998
+ return String(e);
999
+ }
1000
+ }
1001
+
1002
+ // src/lib/adsError.ts
1003
+ var MAX = 500;
1004
+ function isRecord2(v) {
1005
+ return typeof v === "object" && v !== null;
1006
+ }
1007
+ function firstLine(s) {
1008
+ return s.split("\n")[0].trim();
1009
+ }
1010
+ function fieldPath(err) {
1011
+ const elems = isRecord2(err.location) ? err.location.field_path_elements : void 0;
1012
+ if (!Array.isArray(elems)) return void 0;
1013
+ const parts = elems.map((el) => isRecord2(el) && typeof el.field_name === "string" ? el.field_name : "").filter(Boolean);
1014
+ return parts.length ? parts.join(".") : void 0;
1015
+ }
1016
+ function gaErrorLine(err) {
1017
+ const msg = typeof err.message === "string" ? err.message.trim() : "";
1018
+ const category = isRecord2(err.error_code) ? Object.keys(err.error_code)[0] : void 0;
1019
+ const fp = fieldPath(err);
1020
+ let line = category ? `${msg} [${category}]` : msg;
1021
+ if (fp) line += ` (field: ${fp})`;
1022
+ return line.trim();
1023
+ }
1024
+ function describeAdsError(e) {
1025
+ if (e == null) return "Unknown error";
1026
+ if (typeof e === "string") return e.slice(0, MAX);
1027
+ if (isRecord2(e) && Array.isArray(e.errors)) {
1028
+ const joined = e.errors.map(gaErrorLine).filter(Boolean).join("; ");
1029
+ if (joined) {
1030
+ const reqId = typeof e.request_id === "string" ? e.request_id : void 0;
1031
+ return (reqId ? `${joined} (request_id: ${reqId})` : joined).slice(0, MAX);
1032
+ }
1033
+ }
1034
+ if (e instanceof Error) {
1035
+ const details = e.details;
1036
+ const msg = typeof details === "string" && details ? details : e.message;
1037
+ return firstLine(msg).slice(0, MAX) || "Unknown error";
1038
+ }
1039
+ if (isRecord2(e)) {
1040
+ if (typeof e.message === "string" && e.message) return firstLine(e.message).slice(0, MAX);
1041
+ if (typeof e.details === "string" && e.details) return firstLine(e.details).slice(0, MAX);
1042
+ return formatErrorDetail(e).slice(0, MAX);
1043
+ }
1044
+ return String(e).slice(0, MAX);
1045
+ }
1046
+ function actionableHint(text) {
1047
+ const t = text.toLowerCase();
1048
+ if (t.includes("manager account") || t.includes("requested_metrics_for_manager") || t.includes("metrics cannot be requested")) {
1049
+ return "That is a manager (MCC) account; metrics live on client accounts. Switch with google_ads_account (action switch), then retry.";
1050
+ }
1051
+ if (t.includes("not allowed with project") || t.includes("developer_token_prohibited")) {
1052
+ return "The developer token is tied to a different Google Cloud project than this OAuth client (GOOGLE_OAUTH_CLIENT_ID). With Basic/Test access a token only works with OAuth credentials from its own GCP project: either use an OAuth client created in that project, or link this project to the token in the Google Ads API Center. Standard access removes the restriction.";
1053
+ }
1054
+ if (t.includes("developer token") || t.includes("developer_token")) {
1055
+ return "The developer token is not approved for this account. Apply for Basic access in the Google Ads API Center.";
1056
+ }
1057
+ if (t.includes("user_permission_denied") || t.includes("doesn't have permission") || t.includes("permission to access")) {
1058
+ return "This login cannot access that account. Verify access, or set the manager id as login_customer_id.";
1059
+ }
1060
+ if (t.includes("customer_not_enabled") || t.includes("not enabled")) {
1061
+ return "The account is not active/enabled in Google Ads.";
1062
+ }
1063
+ if (t.includes("campaign_budget") && (t.includes("not allowed for the given context") || t.includes("context_error"))) {
1064
+ return "This account cannot hold campaigns/budgets, it is almost certainly a manager (MCC) account. Campaigns and budgets exist only on CLIENT accounts. Run account list to find a [client] account, switch to it (google_ads_account switch), then retry. This is NOT a missing-budget problem; create makes the budget for you.";
1065
+ }
1066
+ if (t.includes("mutates are not allowed") || t.includes("mutate_not_allowed")) {
1067
+ return "The Google Ads API refuses to create this resource. For a VIDEO campaign this is expected: Google sunset Video Action campaigns (migrated to Demand Gen), so new video campaigns cannot be created via the API. Create a DEMAND_GEN campaign instead (it serves on YouTube, Discover, and Gmail). Do not retry the video create.";
1068
+ }
1069
+ if (t.includes("cannot be mutated for removed campaign")) {
1070
+ return "This belongs to a REMOVED (deleted) campaign, so Google will not let it be updated or deleted independently: it was already removed together with its campaign, and removal is permanent. There is nothing to delete: it is already gone. Report it as removed rather than retrying.";
1071
+ }
1072
+ if (t.includes("duplicate asset")) {
1073
+ return "Two assets have identical content, e.g. the same image URL used for more than one slot (a common mistake: reusing one URL for both square_images and logos). Use a DISTINCT image for each slot.";
1074
+ }
1075
+ if (t.includes("brand guidelines")) {
1076
+ return "P-MAX with Brand Guidelines requires a business name + square logo linked as CampaignAssets. The create already sets brand_guidelines_enabled=false to avoid this, if it still appears, the account enforces brand guidelines: add a business name and square logo in the Google Ads UI, or turn Brand Guidelines off for the account, then retry.";
1077
+ }
1078
+ if (t.includes("unrecognized field") || t.includes("unknown field") || t.includes("field is not") || t.includes("invalid field")) {
1079
+ return "That field name does not exist for this resource. Call the report metadata action (action: metadata, resource: <name>) to list valid selectable/filterable/sortable fields, then retry with exact names.";
1080
+ }
1081
+ return void 0;
1082
+ }
1083
+
1084
+ // src/tools/define.ts
1085
+ var TOOL_TIMEOUT_MS = 6e4;
1086
+ var MAX_OUTPUT_CHARS = 5e4;
1087
+ var SET_CREDS_HTTP = "Set your Google Ads credentials (refresh token and customer ID) in your MCP client settings for this server, then try again.";
1088
+ var SET_CREDS_STDIO = "Set GOOGLE_ADS_REFRESH_TOKEN and GOOGLE_ADS_CUSTOMER_ID in the environment.";
1089
+ var NotConnectedError = class extends Error {
1090
+ constructor(mode) {
1091
+ super("not_connected");
1092
+ this.mode = mode;
1093
+ this.name = "NotConnectedError";
1094
+ }
1095
+ mode;
1096
+ };
1097
+ function errorResult(message) {
1098
+ return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true };
1099
+ }
1100
+ function textResult(text) {
1101
+ const clipped = text.length > MAX_OUTPUT_CHARS ? `${text.slice(0, MAX_OUTPUT_CHARS)}
1102
+ ... (truncated)` : text;
1103
+ return { content: [{ type: "text", text: clipped }] };
1104
+ }
1105
+ function sanitizeError(e) {
1106
+ return describeAdsError(e);
1107
+ }
1108
+ function friendlyMessage(e) {
1109
+ if (isQuotaExhausted(e)) {
1110
+ return "Google Ads rate/quota limit reached. Retry later; if it persists, the developer token likely needs Standard Access.";
1111
+ }
1112
+ const message = describeAdsError(e);
1113
+ const hint = actionableHint(message);
1114
+ return hint ? `${message}
1115
+ \u2192 ${hint}` : message;
1116
+ }
1117
+ function defineTool(def) {
1118
+ const original2 = def.execute;
1119
+ def.execute = async (params, ctx) => {
1120
+ const parsed = def.parameters.safeParse(params);
1121
+ if (!parsed.success) {
1122
+ const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "root"}: ${i.message}`).join("\n");
1123
+ throw new Error(`Invalid parameters for '${def.name}':
1124
+ ${issues}
1125
+
1126
+ Rewrite the input to match the schema.`);
1127
+ }
1128
+ let timer;
1129
+ const timeoutMsg = def.annotations?.destructiveHint ? `Tool '${def.name}' timed out after ${TOOL_TIMEOUT_MS / 1e3}s. \u26A0\uFE0F The change MAY have been applied, check the account before retrying (do not blindly retry).` : `Tool '${def.name}' timed out after ${TOOL_TIMEOUT_MS / 1e3}s.`;
1130
+ return Promise.race([
1131
+ original2(parsed.data, ctx).finally(() => clearTimeout(timer)),
1132
+ new Promise((_, reject) => {
1133
+ timer = setTimeout(() => reject(new Error(timeoutMsg)), TOOL_TIMEOUT_MS);
1134
+ })
1135
+ ]);
1136
+ };
1137
+ return def;
1138
+ }
1139
+ function wrapForMcp(tool, ctx) {
1140
+ return async (params) => {
1141
+ try {
1142
+ const result = await tool.execute(params, ctx);
1143
+ ctx.audit.record({
1144
+ timestamp: Date.now(),
1145
+ server: "mcp",
1146
+ category: result.category ?? tool.logCategory,
1147
+ summary: result.auditSummary ?? tool.name,
1148
+ customerId: result.customerId,
1149
+ status: "success"
1150
+ });
1151
+ return textResult(result.output);
1152
+ } catch (e) {
1153
+ if (e instanceof NotConnectedError) {
1154
+ return textResult(
1155
+ e.mode === "http" ? `You haven't set your Google Ads credentials yet.
1156
+
1157
+ ${SET_CREDS_HTTP}` : `No Google Ads credentials configured.
1158
+
1159
+ ${SET_CREDS_STDIO}`
1160
+ );
1161
+ }
1162
+ if (isReauthRequired(e)) {
1163
+ if (ctx.mode === "http" && ctx.oauthSessionId) {
1164
+ ctx.oauthStore?.deleteSession(ctx.oauthSessionId);
1165
+ }
1166
+ ctx.audit.record({
1167
+ timestamp: Date.now(),
1168
+ server: "mcp",
1169
+ category: "auth",
1170
+ summary: `${tool.name}: refresh token rejected, session cleared`,
1171
+ customerId: ctx.credentials?.customerId,
1172
+ status: "error",
1173
+ errorMessage: "invalid_grant / unauthorized_client / UNAUTHENTICATED"
1174
+ });
1175
+ return textResult(
1176
+ ctx.mode === "http" ? "\u26A0\uFE0F Your Google Ads authorization is no longer valid and has been reset. Click Connect to re-authorize." : "\u26A0\uFE0F The configured GOOGLE_ADS_REFRESH_TOKEN was rejected (expired/revoked). Update it."
1177
+ );
1178
+ }
1179
+ ctx.audit.record({
1180
+ timestamp: Date.now(),
1181
+ server: "mcp",
1182
+ category: "error",
1183
+ summary: `${tool.name} failed`,
1184
+ customerId: ctx.credentials?.customerId,
1185
+ status: "error",
1186
+ errorMessage: sanitizeError(e),
1187
+ errorDetail: formatErrorDetail(e)
1188
+ });
1189
+ return errorResult(friendlyMessage(e));
1190
+ }
1191
+ };
1192
+ }
1193
+
1194
+ // src/tools/orchestrator.ts
1195
+ import { z as z2 } from "zod";
1196
+ function injectAction(output, action) {
1197
+ if (!action || !output.includes("Example: {") || output.includes('"action"')) return output;
1198
+ return output.replace("Example: {", `Example: {"action": "${action}", `);
1199
+ }
1200
+ function buildDescription(spec) {
1201
+ return [
1202
+ `Purpose: ${spec.purpose}`,
1203
+ "Actions:",
1204
+ ...spec.handlers.map((h) => `- ${h.action}: ${h.summary}`),
1205
+ `Limitations: ${spec.limitations}`,
1206
+ "Examples:",
1207
+ ...spec.examples.map((e) => ` ${e}`)
1208
+ ].join("\n");
1209
+ }
1210
+ function defineOrchestrator(spec) {
1211
+ const { name, handlers } = spec;
1212
+ const byAction = new Map(handlers.map((h) => [h.action, h]));
1213
+ const actions = handlers.map((h) => h.action);
1214
+ const shape = {
1215
+ action: z2.enum(actions).describe(`The operation to perform. One of: ${actions.join(", ")}.`)
1216
+ };
1217
+ for (const h of handlers) {
1218
+ const fields = h.schema.shape;
1219
+ for (const [key, field] of Object.entries(fields)) {
1220
+ if (!shape[key]) shape[key] = field.optional();
1221
+ }
1222
+ }
1223
+ return defineTool({
1224
+ name,
1225
+ description: buildDescription(spec),
1226
+ // readOnlyHint only when EVERY action is read-only (orchestrators are usually mixed).
1227
+ annotations: handlers.every((h) => h.readOnly) ? { readOnlyHint: true } : {},
1228
+ logCategory: "report",
1229
+ // default only; the per-call category comes from result.category
1230
+ parameters: z2.object(shape),
1231
+ async execute(params, ctx) {
1232
+ const action = typeof params.action === "string" ? params.action : "";
1233
+ const handler = byAction.get(action);
1234
+ if (!handler) {
1235
+ throw new Error(`Unknown action "${action}" for ${name}. Valid actions: ${actions.join(", ")}.`);
1236
+ }
1237
+ const parsed = handler.schema.safeParse(params);
1238
+ if (!parsed.success) {
1239
+ const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "param"}: ${i.message}`).join("\n");
1240
+ throw new Error(`Invalid parameters for ${name} action "${action}":
1241
+ ${issues}`);
1242
+ }
1243
+ const result = await handler.execute(parsed.data, ctx);
1244
+ const output = injectAction(result.output, action);
1245
+ return { ...result, output, category: result.category ?? handler.category };
1246
+ }
1247
+ });
1248
+ }
1249
+
1250
+ // src/tools/handlers/account/status.ts
1251
+ import { z as z3 } from "zod";
1252
+
1253
+ // src/tools/connection.ts
1254
+ function requireConnection(ctx) {
1255
+ if (!ctx.credentials) throw new NotConnectedError(ctx.mode);
1256
+ return ctx.credentials;
1257
+ }
1258
+ function formatCid(id) {
1259
+ return /^\d{10}$/.test(id) ? `${id.slice(0, 3)}-${id.slice(3, 6)}-${id.slice(6)}` : id;
1260
+ }
1261
+ var SETTINGS_HINT = "Set your Google Ads refresh token and customer ID in your MCP client settings for this server.";
1262
+
1263
+ // src/tools/handlers/account/status.ts
1264
+ var schema = z3.object({
1265
+ check_live: z3.boolean().default(true).describe("Confirm the token works with a live API call (1 op).")
1266
+ });
1267
+ var statusHandler = {
1268
+ action: "status",
1269
+ summary: "Check whether Google Ads credentials are present and the refresh token still works. check_live (default true) makes one live API call.",
1270
+ readOnly: true,
1271
+ category: "auth",
1272
+ schema,
1273
+ async execute(params, ctx) {
1274
+ const creds = ctx.credentials;
1275
+ if (!creds) {
1276
+ return {
1277
+ output: ctx.mode === "http" ? `Not connected. ${SETTINGS_HINT}` : "Not connected. Set GOOGLE_ADS_REFRESH_TOKEN and GOOGLE_ADS_CUSTOMER_ID.",
1278
+ auditSummary: "Connection status: not connected"
1279
+ };
1280
+ }
1281
+ if (!params.check_live) {
1282
+ return { output: `Connected to account ${formatCid(creds.customerId)} (token not live-checked).`, auditSummary: "Connection status: connected (cached)", customerId: creds.customerId };
1283
+ }
1284
+ try {
1285
+ const rows = await ctx.ads.query(creds, "SELECT customer.descriptive_name, customer.id FROM customer LIMIT 1");
1286
+ const name = rows[0]?.customer?.descriptive_name?.trim();
1287
+ const label = name ? `${name} (${formatCid(creds.customerId)})` : formatCid(creds.customerId);
1288
+ return { output: `\u2705 Connected and token valid, account ${label}.`, auditSummary: "Connection status: healthy", customerId: creds.customerId };
1289
+ } catch (e) {
1290
+ if (isReauthRequired(e)) {
1291
+ return {
1292
+ output: ctx.mode === "http" ? `\u26A0\uFE0F Refresh token rejected (expired/revoked). Update it in your MCP client settings for this server.` : "\u26A0\uFE0F GOOGLE_ADS_REFRESH_TOKEN was rejected, update it.",
1293
+ auditSummary: "Connection status: token rejected",
1294
+ customerId: creds.customerId
1295
+ };
1296
+ }
1297
+ throw e;
1298
+ }
1299
+ }
1300
+ };
1301
+
1302
+ // src/tools/handlers/account/list.ts
1303
+ import { z as z4 } from "zod";
1304
+ var schema2 = z4.object({});
1305
+ var listHandler = {
1306
+ action: "list",
1307
+ summary: "List the active account plus other accounts this login can access (use it to get a customer_id before switch).",
1308
+ readOnly: true,
1309
+ category: "report",
1310
+ schema: schema2,
1311
+ async execute(_params, ctx) {
1312
+ const creds = requireConnection(ctx);
1313
+ const ids = await ctx.ads.listAccessibleCustomers(creds.refreshToken);
1314
+ const LABEL_CAP = 40;
1315
+ const metas = ids.length <= LABEL_CAP ? await Promise.all(ids.map((id) => ctx.ads.accountMeta(creds.refreshToken, id))) : [];
1316
+ const lines = ids.map((id, i) => {
1317
+ const m = metas[i];
1318
+ const name = m?.name ? ` ${m.name}` : "";
1319
+ const type = m?.manager === true ? " [manager, no campaigns/metrics]" : m?.manager === false ? " [client]" : "";
1320
+ return `${id === creds.customerId ? "\u2192 " : " "}${formatCid(id)}${name}${type}`;
1321
+ });
1322
+ const hint = ids.length <= LABEL_CAP ? "\n\nCreate campaigns/run metrics on a [client] account; switch to one first." : "";
1323
+ return {
1324
+ output: [`Active account: ${formatCid(creds.customerId)}`, "", ids.length ? "Accessible accounts:" : "No other accessible accounts found.", ...lines].join("\n") + hint,
1325
+ auditSummary: "Listed accessible accounts",
1326
+ customerId: creds.customerId
1327
+ };
1328
+ }
1329
+ };
1330
+
1331
+ // src/tools/handlers/account/switch.ts
1332
+ import { z as z5 } from "zod";
1333
+ var schema3 = z5.object({
1334
+ customer_id: z5.string().describe("The 10-digit account ID to make active (dashes optional).")
1335
+ });
1336
+ var switchHandler = {
1337
+ action: "switch",
1338
+ summary: "Set which Google Ads account (customer_id) the tools act on, for a login that can access several. Persists on the session.",
1339
+ readOnly: false,
1340
+ category: "auth",
1341
+ schema: schema3,
1342
+ async execute(params, ctx) {
1343
+ const creds = requireConnection(ctx);
1344
+ const cid = params.customer_id.replace(/-/g, "");
1345
+ if (!ctx.oauthStore || !ctx.oauthSessionId) {
1346
+ return { output: "Switching accounts is only available on the hosted server.", customerId: creds.customerId };
1347
+ }
1348
+ const session = ctx.oauthStore.getSession(ctx.oauthSessionId);
1349
+ if (!session) return { output: "Your session was not found. Please reconnect." };
1350
+ if (!session.accessibleCustomers.includes(cid)) {
1351
+ const list = session.accessibleCustomers.map(formatCid).join(", ") || "none found";
1352
+ return { output: `Account ${formatCid(cid)} isn't one your authorization can access. Accessible accounts: ${list}.`, customerId: creds.customerId };
1353
+ }
1354
+ ctx.oauthStore.setActiveCustomer(ctx.oauthSessionId, cid);
1355
+ return { output: `\u2705 Active account set to ${formatCid(cid)}. It applies to your next requests.`, auditSummary: `Set active account to ${cid}`, customerId: cid };
1356
+ }
1357
+ };
1358
+
1359
+ // src/tools/handlers/account/audit.ts
1360
+ import { z as z6 } from "zod";
1361
+ var schema4 = z6.object({
1362
+ limit: z6.number().int().min(1).max(200).default(50).describe("Max entries to return (newest first). Default 50."),
1363
+ category: z6.enum(["report", "mutate", "auth", "error"]).optional().describe("Filter by action category."),
1364
+ status: z6.enum(["success", "error"]).optional().describe("Filter by outcome."),
1365
+ since_days: z6.number().int().min(1).max(365).optional().describe("Only actions from the last N days.")
1366
+ });
1367
+ var auditHandler = {
1368
+ action: "audit",
1369
+ summary: "Show the recent audit trail (when / what / outcome) of actions on the connected account. Filter by category, status, or since_days.",
1370
+ readOnly: true,
1371
+ category: "report",
1372
+ schema: schema4,
1373
+ async execute(params, ctx) {
1374
+ const creds = requireConnection(ctx);
1375
+ if (!ctx.audit.hasStore) {
1376
+ return {
1377
+ output: "The audit trail is only available on the hosted server (--http mode); this session has no durable audit store.",
1378
+ auditSummary: "Audit log: unavailable (no store)",
1379
+ customerId: creds.customerId
1380
+ };
1381
+ }
1382
+ const sinceTs = params.since_days ? Date.now() - params.since_days * 864e5 : void 0;
1383
+ const rows = ctx.audit.query({
1384
+ customerId: creds.customerId,
1385
+ limit: params.limit,
1386
+ category: params.category,
1387
+ status: params.status,
1388
+ sinceTs
1389
+ });
1390
+ if (!rows.length) {
1391
+ return {
1392
+ output: `No audited actions found for ${formatCid(creds.customerId)} in the selected range.`,
1393
+ auditSummary: "Audit log: 0 entries",
1394
+ customerId: creds.customerId
1395
+ };
1396
+ }
1397
+ const lines = rows.map((r) => {
1398
+ const when = new Date(r.ts).toISOString().replace("T", " ").slice(0, 19);
1399
+ const mark = r.status === "error" ? "\u2717" : "\u2713";
1400
+ const err = r.error_message ? `: ${r.error_message}` : "";
1401
+ return `${when} ${mark} [${r.category}] ${r.summary}${err}`;
1402
+ });
1403
+ return {
1404
+ output: [`${rows.length} most recent audited action(s) for ${formatCid(creds.customerId)} (newest first):`, "", ...lines].join("\n"),
1405
+ auditSummary: `Read audit log (${rows.length} entries)`,
1406
+ customerId: creds.customerId
1407
+ };
1408
+ }
1409
+ };
1410
+
1411
+ // src/tools/orchestrators/account.ts
1412
+ var accountTool = defineOrchestrator({
1413
+ name: "google_ads_account",
1414
+ purpose: "Connection and account management. status checks the connection and token. list shows the accounts you can access. switch changes the active account. audit reads the audit trail.",
1415
+ limitations: "switch and audit only work on the hosted (http) server, not stdio. Customer ids are 10 digits with no dashes. A manager (MCC) account cannot be queried for metrics, so switch to a client account first if you need reporting.",
1416
+ examples: [
1417
+ '{"action":"status"}',
1418
+ '{"action":"list"}',
1419
+ '{"action":"switch","customer_id":"1234567890"}',
1420
+ '{"action":"audit","limit":20,"status":"error"}'
1421
+ ],
1422
+ handlers: [statusHandler, listHandler, switchHandler, auditHandler]
1423
+ });
1424
+
1425
+ // src/tools/handlers/report/performance.ts
1426
+ import { z as z7 } from "zod";
1427
+ import { enums } from "google-ads-api";
1428
+
1429
+ // src/tools/format.ts
1430
+ var money = (micros) => (Number(micros) / 1e6).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
1431
+ var formatUnits = (units) => Number(units).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
1432
+ var int = (n3) => Math.round(Number(n3)).toLocaleString("en-US");
1433
+ var pct = (frac) => `${(Number(frac) * 100).toFixed(2)}%`;
1434
+ function enumLabel(enumObj, value) {
1435
+ if (typeof value === "string") return value;
1436
+ if (value == null) return "-";
1437
+ for (const k of Object.keys(enumObj)) if (enumObj[k] === value) return k;
1438
+ return String(value);
1439
+ }
1440
+
1441
+ // src/lib/paginate.ts
1442
+ var PAGE_BUDGET_CHARS = 4e4;
1443
+ function paginate(args) {
1444
+ const { ctx, cursor, customerId } = args;
1445
+ const cache = ctx.pageCache;
1446
+ const scope = ctx.credentials?.customerId ?? "";
1447
+ let lines;
1448
+ let offset;
1449
+ let title;
1450
+ let effectiveCustomerId = customerId;
1451
+ if (cursor) {
1452
+ const entry = cache.get(cursor, scope);
1453
+ if (!entry) {
1454
+ return {
1455
+ output: "That report has expired (pages are cached ~5 minutes). Re-run the report to start again.",
1456
+ auditSummary: `${args.summary} (cursor expired)`,
1457
+ customerId
1458
+ };
1459
+ }
1460
+ lines = entry.rows;
1461
+ offset = entry.offset;
1462
+ title = entry.title;
1463
+ effectiveCustomerId = entry.customerId ?? customerId;
1464
+ } else {
1465
+ lines = args.rows;
1466
+ offset = 0;
1467
+ title = args.title;
1468
+ }
1469
+ const total = lines.length;
1470
+ let used = title.length;
1471
+ let i = offset;
1472
+ for (; i < total; i++) {
1473
+ const ln = lines[i];
1474
+ if (i > offset && used + ln.length + 1 > PAGE_BUDGET_CHARS) break;
1475
+ used += ln.length + 1;
1476
+ }
1477
+ const shownTo = i;
1478
+ const pageLines = lines.slice(offset, shownTo);
1479
+ const hasMore = shownTo < total;
1480
+ let nextCursor = null;
1481
+ if (cursor) {
1482
+ cache.advance(cursor, shownTo);
1483
+ nextCursor = hasMore ? cursor : null;
1484
+ } else if (hasMore) {
1485
+ nextCursor = cache.create(scope, title, lines, shownTo, effectiveCustomerId);
1486
+ }
1487
+ const range = total === 0 ? "0 of 0" : `${offset + 1}-${shownTo} of ${total}`;
1488
+ const footer = hasMore ? `
1489
+ ... showing ${range}. ${total - shownTo} more row(s). To continue, call this tool again with cursor:"${nextCursor}". Ask the user first if they want the rest.` : offset > 0 ? `
1490
+ (showing ${range}, end of report)` : "";
1491
+ return {
1492
+ output: [title, "", ...pageLines, footer].join("\n"),
1493
+ auditSummary: cursor ? `${args.summary} (page from row ${offset + 1})` : args.summary,
1494
+ customerId: effectiveCustomerId
1495
+ };
1496
+ }
1497
+
1498
+ // src/ads/dates.ts
1499
+ var DATE_RANGES = [
1500
+ "TODAY",
1501
+ "YESTERDAY",
1502
+ "LAST_7_DAYS",
1503
+ "LAST_14_DAYS",
1504
+ "LAST_30_DAYS",
1505
+ "THIS_MONTH",
1506
+ "LAST_MONTH"
1507
+ ];
1508
+ function fmt(d) {
1509
+ return d.toISOString().slice(0, 10);
1510
+ }
1511
+ function accountToday(tz) {
1512
+ if (!tz) {
1513
+ const n3 = /* @__PURE__ */ new Date();
1514
+ return new Date(Date.UTC(n3.getUTCFullYear(), n3.getUTCMonth(), n3.getUTCDate()));
1515
+ }
1516
+ const parts = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(/* @__PURE__ */ new Date());
1517
+ const get = (t) => Number(parts.find((p2) => p2.type === t).value);
1518
+ return new Date(Date.UTC(get("year"), get("month") - 1, get("day")));
1519
+ }
1520
+ function addDays(d, n3) {
1521
+ const x = new Date(d);
1522
+ x.setUTCDate(x.getUTCDate() + n3);
1523
+ return x;
1524
+ }
1525
+ function explicitWindow(range, tz) {
1526
+ const today = accountToday(tz);
1527
+ switch (range) {
1528
+ case "TODAY":
1529
+ return { start: fmt(today), end: fmt(today) };
1530
+ case "YESTERDAY": {
1531
+ const y = addDays(today, -1);
1532
+ return { start: fmt(y), end: fmt(y) };
1533
+ }
1534
+ case "LAST_7_DAYS":
1535
+ return { start: fmt(addDays(today, -7)), end: fmt(addDays(today, -1)) };
1536
+ case "LAST_14_DAYS":
1537
+ return { start: fmt(addDays(today, -14)), end: fmt(addDays(today, -1)) };
1538
+ case "LAST_30_DAYS":
1539
+ return { start: fmt(addDays(today, -30)), end: fmt(addDays(today, -1)) };
1540
+ case "THIS_MONTH":
1541
+ return { start: fmt(new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1))), end: fmt(today) };
1542
+ case "LAST_MONTH":
1543
+ return {
1544
+ start: fmt(new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1))),
1545
+ end: fmt(new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 0)))
1546
+ };
1547
+ }
1548
+ }
1549
+ function previousWindow(w) {
1550
+ const start = /* @__PURE__ */ new Date(`${w.start}T00:00:00Z`);
1551
+ const end = /* @__PURE__ */ new Date(`${w.end}T00:00:00Z`);
1552
+ const lenDays = Math.round((end.getTime() - start.getTime()) / 864e5) + 1;
1553
+ const prevEnd = addDays(start, -1);
1554
+ const prevStart = addDays(prevEnd, -(lenDays - 1));
1555
+ return { start: fmt(prevStart), end: fmt(prevEnd) };
1556
+ }
1557
+
1558
+ // src/tools/handlers/report/performance.ts
1559
+ var CHANNELS = ["SEARCH", "PERFORMANCE_MAX", "DISPLAY", "SHOPPING", "VIDEO", "DEMAND_GEN"];
1560
+ var METRICS = "metrics.impressions, metrics.clicks, metrics.ctr, metrics.average_cpc, metrics.cost_micros, metrics.conversions, metrics.conversions_value, metrics.conversions_from_interactions_rate";
1561
+ var n = (v) => Number(v ?? 0);
1562
+ var roas = (value, cost) => cost > 0 ? (value / cost).toFixed(2) : "-";
1563
+ function sum(rows) {
1564
+ return rows.reduce(
1565
+ (a, r) => ({
1566
+ impressions: a.impressions + n(r.metrics.impressions),
1567
+ clicks: a.clicks + n(r.metrics.clicks),
1568
+ cost: a.cost + n(r.metrics.cost_micros) / 1e6,
1569
+ conversions: a.conversions + n(r.metrics.conversions),
1570
+ value: a.value + n(r.metrics.conversions_value)
1571
+ }),
1572
+ { impressions: 0, clicks: 0, cost: 0, conversions: 0, value: 0 }
1573
+ );
1574
+ }
1575
+ function delta(cur, prev) {
1576
+ if (prev === 0) return cur === 0 ? "\xB10%" : "n/a";
1577
+ const d = (cur - prev) / prev * 100;
1578
+ return `${d >= 0 ? "+" : ""}${d.toFixed(1)}%`;
1579
+ }
1580
+ function campaignLine(r, currency) {
1581
+ const m = r.metrics;
1582
+ const cost = n(m.cost_micros) / 1e6;
1583
+ const cur = currency ? `${currency} ` : "";
1584
+ const ctr = m.ctr != null ? pct(n(m.ctr)) : "-";
1585
+ return `\u2022 ${r.campaign.name} (${enumLabel(enums.AdvertisingChannelType, r.campaign.advertising_channel_type)}) id ${r.campaign.id}, Impr ${int(n(m.impressions))} | Clicks ${int(n(m.clicks))} | CTR ${ctr} | Cost ${cur}${formatUnits(cost)} | Conv ${n(m.conversions).toFixed(1)} | CvR ${m.conversions_from_interactions_rate != null ? pct(n(m.conversions_from_interactions_rate)) : "-"} | Value ${formatUnits(n(m.conversions_value))} | ROAS ${roas(n(m.conversions_value), cost)}`;
1586
+ }
1587
+ function performanceTitle(range, customerId, rows, truncated, cap, total, currency) {
1588
+ const t = sum(rows);
1589
+ const cur = currency ? `${currency} ` : "";
1590
+ const totals = rows.length ? ` \xB7 Totals: Impr ${int(t.impressions)} | Cost ${cur}${formatUnits(t.cost)} | Conv ${t.conversions.toFixed(1)} | ROAS ${roas(t.value, t.cost)}` : "";
1591
+ const note = truncated ? `
1592
+ (top ${cap}${total != null ? ` of ${int(total)}` : ""} campaigns by cost shown; refine filters or export for the full set)` : "";
1593
+ const acct = `account ${formatCid(customerId)}${currency ? ` \xB7 ${currency}` : ""}`;
1594
+ return `Campaign performance, ${range} (${acct})${totals}${note}`;
1595
+ }
1596
+ function renderCompare(customerId, cur, prev, curRows, prevRows, currency) {
1597
+ const c = sum(curRows);
1598
+ const p2 = sum(prevRows);
1599
+ const line = (label, cv, pv, fmt2) => `${label.padEnd(13)} ${fmt2(cv).padStart(14)} ${fmt2(pv).padStart(14)} ${delta(cv, pv)}`;
1600
+ return [
1601
+ `Campaign performance, comparison (account ${formatCid(customerId)}${currency ? ` \xB7 ${currency}` : ""})`,
1602
+ `Current : ${cur.start} \u2192 ${cur.end}`,
1603
+ `Previous: ${prev.start} \u2192 ${prev.end}`,
1604
+ "",
1605
+ `${"Metric".padEnd(13)} ${"Current".padStart(14)} ${"Previous".padStart(14)} \u0394`,
1606
+ line("Impressions", c.impressions, p2.impressions, int),
1607
+ line("Clicks", c.clicks, p2.clicks, int),
1608
+ line("Cost", c.cost, p2.cost, formatUnits),
1609
+ line("Conversions", c.conversions, p2.conversions, (x) => x.toFixed(1)),
1610
+ line("Conv. value", c.value, p2.value, formatUnits),
1611
+ `${"ROAS".padEnd(13)} ${roas(c.value, c.cost).padStart(14)} ${roas(p2.value, p2.cost).padStart(14)}`
1612
+ ].join("\n");
1613
+ }
1614
+ var schema5 = z7.object({
1615
+ date_range: z7.enum(DATE_RANGES).default("LAST_30_DAYS"),
1616
+ channel_type: z7.enum(CHANNELS).optional().describe("Filter to one campaign type, e.g. PERFORMANCE_MAX"),
1617
+ compare_previous: z7.boolean().default(false).describe("Compare with the preceding equal-length period"),
1618
+ limit: z7.number().int().min(1).max(2e3).default(200).describe("Max campaigns to fetch this run (paginated for display)"),
1619
+ cursor: z7.string().optional().describe("Continuation token from a previous page (served from cache, no new Google call)")
1620
+ });
1621
+ var performanceHandler = {
1622
+ action: "campaign_performance",
1623
+ summary: "Campaign performance (impressions, clicks, CTR, cost, conversions, value, computed ROAS). Pick a date_range; optional channel_type filter and compare_previous; paginated via cursor.",
1624
+ readOnly: true,
1625
+ category: "report",
1626
+ schema: schema5,
1627
+ async execute(params, ctx) {
1628
+ if (params.cursor) {
1629
+ return paginate({ ctx, cursor: params.cursor, title: "", rows: [], summary: "Report: campaign performance (page)" });
1630
+ }
1631
+ const creds = await requireConnection(ctx);
1632
+ const channelFilter = params.channel_type ? ` AND campaign.advertising_channel_type = ${params.channel_type}` : "";
1633
+ const select = `SELECT campaign.id, campaign.name, campaign.advertising_channel_type, ${METRICS} FROM campaign`;
1634
+ const currency = await ctx.ads.currencyCode(creds);
1635
+ if (params.compare_previous) {
1636
+ const tz = await ctx.ads.timeZone(creds);
1637
+ const cur = explicitWindow(params.date_range, tz);
1638
+ const prev = previousWindow(cur);
1639
+ const order = " ORDER BY metrics.cost_micros DESC";
1640
+ const cap = 1e3;
1641
+ const [curRes, prevRes] = await Promise.all([
1642
+ ctx.ads.searchPage(creds, `${select} WHERE segments.date BETWEEN '${cur.start}' AND '${cur.end}'${channelFilter}${order}`, cap),
1643
+ ctx.ads.searchPage(creds, `${select} WHERE segments.date BETWEEN '${prev.start}' AND '${prev.end}'${channelFilter}${order}`, cap)
1644
+ ]);
1645
+ const maxTotal = Math.max(curRes.total ?? 0, prevRes.total ?? 0);
1646
+ const truncNote = curRes.truncated || prevRes.truncated ? `
1647
+ (\u26A0\uFE0F totals cover the top ${cap}${maxTotal ? ` of ${int(maxTotal)}` : ""} campaigns by cost per period; narrow with channel_type for exact totals on a larger account)` : "";
1648
+ return {
1649
+ output: renderCompare(creds.customerId, cur, prev, curRes.rows, prevRes.rows, currency) + truncNote,
1650
+ auditSummary: `Report: compare ${params.date_range}${params.channel_type ? ` (${params.channel_type})` : ""}`,
1651
+ customerId: creds.customerId
1652
+ };
1653
+ }
1654
+ const gaql = `${select} WHERE segments.date DURING ${params.date_range}${channelFilter} ORDER BY metrics.cost_micros DESC`;
1655
+ const { rows, total, truncated } = await ctx.ads.searchPage(creds, gaql, params.limit);
1656
+ if (!rows.length) {
1657
+ return {
1658
+ output: `Campaign performance, ${params.date_range} (account ${formatCid(creds.customerId)})
1659
+
1660
+ No campaigns with data in this period.`,
1661
+ auditSummary: `Report: campaign performance (${params.date_range})`,
1662
+ customerId: creds.customerId
1663
+ };
1664
+ }
1665
+ return paginate({
1666
+ ctx,
1667
+ title: performanceTitle(params.date_range, creds.customerId, rows, truncated, params.limit, total, currency),
1668
+ rows: rows.map((r) => campaignLine(r, currency)),
1669
+ summary: `Report: campaign performance (${params.date_range}${params.channel_type ? `, ${params.channel_type}` : ""})`,
1670
+ customerId: creds.customerId
1671
+ });
1672
+ }
1673
+ };
1674
+
1675
+ // src/tools/handlers/report/impressionShare.ts
1676
+ import { z as z8 } from "zod";
1677
+ var FIELDS = "campaign.id, campaign.name, metrics.search_impression_share, metrics.search_top_impression_share, metrics.search_absolute_top_impression_share, metrics.search_budget_lost_impression_share, metrics.search_rank_lost_impression_share";
1678
+ var p = (v) => v != null ? pct(Number(v)) : "-";
1679
+ function isLine(r) {
1680
+ const m = r.metrics;
1681
+ return `\u2022 ${r.campaign.name} id ${r.campaign.id}, IS ${p(m.search_impression_share)} | top ${p(m.search_top_impression_share)} | abs-top ${p(m.search_absolute_top_impression_share)} | lost(budget) ${p(m.search_budget_lost_impression_share)} | lost(rank) ${p(m.search_rank_lost_impression_share)}`;
1682
+ }
1683
+ var schema6 = z8.object({
1684
+ date_range: z8.enum(DATE_RANGES).default("LAST_30_DAYS"),
1685
+ limit: z8.number().int().min(1).max(2e3).default(200),
1686
+ cursor: z8.string().optional()
1687
+ });
1688
+ var impressionShareHandler = {
1689
+ action: "impression_share",
1690
+ summary: "Search impression-share report (the Average-Position replacement): impression share, top and absolute-top IS, and share lost to budget vs rank, per campaign. Search campaigns only. Pick a date_range; paginated via cursor.",
1691
+ readOnly: true,
1692
+ category: "report",
1693
+ schema: schema6,
1694
+ async execute(params, ctx) {
1695
+ if (params.cursor) return paginate({ ctx, cursor: params.cursor, title: "", rows: [], summary: "Impression share (page)" });
1696
+ const creds = await requireConnection(ctx);
1697
+ const gaql = `SELECT ${FIELDS} FROM campaign WHERE campaign.advertising_channel_type = SEARCH AND segments.date DURING ${params.date_range} ORDER BY metrics.search_impression_share DESC`;
1698
+ const { rows, total, truncated } = await ctx.ads.searchPage(creds, gaql, params.limit);
1699
+ if (!rows.length) {
1700
+ return { output: `No Search campaigns with impression-share data in ${params.date_range}.`, auditSummary: `Report: impression share (${params.date_range})`, customerId: creds.customerId };
1701
+ }
1702
+ return paginate({
1703
+ ctx,
1704
+ title: `Search impression share, ${params.date_range} (account ${formatCid(creds.customerId)})${truncated ? ` - top ${params.limit}${total != null ? ` of ${int(total)}` : ""}` : ""}`,
1705
+ rows: rows.map(isLine),
1706
+ summary: `Report: impression share (${params.date_range})`,
1707
+ customerId: creds.customerId
1708
+ });
1709
+ }
1710
+ };
1711
+
1712
+ // src/tools/handlers/report/assetGroup.ts
1713
+ import { z as z10 } from "zod";
1714
+ import { enums as enums2 } from "google-ads-api";
1715
+
1716
+ // src/tools/shared.ts
1717
+ import { z as z9 } from "zod";
1718
+ var ID = z9.string().regex(/^\d+$/, "must be a numeric Google Ads id (digits only)");
1719
+ function youtubeVideoId(input) {
1720
+ const s = input.trim();
1721
+ if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
1722
+ const m = s.match(/(?:v=|\/embed\/|youtu\.be\/|\/v\/|\/shorts\/)([A-Za-z0-9_-]{11})/);
1723
+ if (m?.[1]) return m[1];
1724
+ throw new Error(`"${input}" is not a YouTube video id or URL. Pass the 11-character video id or a youtube.com/watch?v=... / youtu.be/... link.`);
1725
+ }
1726
+ var confirmFields = {
1727
+ confirmed: z9.boolean().default(false).describe(
1728
+ 'Apply the write. Leave false (or omit) on the first call to get a validate-only preview. Set true ONLY after the user typed "yes" or "ok" in chat in reply to that preview. Never set true on the first call; never invent approval.'
1729
+ ),
1730
+ user_confirmation: z9.enum(["yes", "ok"]).optional().describe(
1731
+ `Required when confirmed is true. Pass exactly "yes" or "ok" \u2014 the user's affirmative reply in chat. Do not set this until they type it. Not a free-form string; only those two values.`
1732
+ )
1733
+ };
1734
+ function userConfirmationRequired() {
1735
+ return !/^(false|0|no|off)$/i.test((process.env.REQUIRE_USER_CONFIRMATION ?? "").trim());
1736
+ }
1737
+ function stripConfirm(example) {
1738
+ const out = {};
1739
+ for (const [k, v] of Object.entries(example)) {
1740
+ if (k === "confirmed" || k === "user_confirmation") continue;
1741
+ if (v !== void 0) out[k] = v;
1742
+ }
1743
+ return out;
1744
+ }
1745
+ function preview(action, applyExample) {
1746
+ const intent = stripConfirm(applyExample);
1747
+ const example = { ...intent, confirmed: true, user_confirmation: "yes" };
1748
+ return [
1749
+ `\u26A0\uFE0F Validated, NOT applied yet. This will ${action}.`,
1750
+ "",
1751
+ 'Show these exact values to the user as a short question and ask them to type "yes" or "ok" to proceed (anything else = cancel).',
1752
+ "Do NOT call this tool again with confirmed:true until they type yes or ok in chat.",
1753
+ 'After they reply yes/ok, call again with confirmed:true and user_confirmation set to "yes" or "ok" (match what they typed).',
1754
+ `Example: ${JSON.stringify(example)}`
1755
+ ].join("\n");
1756
+ }
1757
+ function previewRemove(what, applyExample) {
1758
+ const intent = stripConfirm(applyExample);
1759
+ const example = { ...intent, confirmed: true, user_confirmation: "yes" };
1760
+ return [
1761
+ `\u26A0\uFE0F DELETE: this PERMANENTLY REMOVES ${what}. It cannot be undone.`,
1762
+ "",
1763
+ "NOT applied yet (validated only). Tell the user EXACTLY what will be deleted.",
1764
+ 'Ask them to type "yes" or "ok" to delete it (anything else = cancel).',
1765
+ "Do NOT call again with confirmed:true until they type yes or ok in chat.",
1766
+ 'After they reply yes/ok, call again with confirmed:true and user_confirmation set to "yes" or "ok".',
1767
+ `Example: ${JSON.stringify(example)}`
1768
+ ].join("\n");
1769
+ }
1770
+ function assertUserConfirmed(params) {
1771
+ if (!params.confirmed) {
1772
+ throw new Error("Internal error: assertUserConfirmed called without confirmed:true.");
1773
+ }
1774
+ if (!userConfirmationRequired()) return;
1775
+ if (params.user_confirmation !== "yes" && params.user_confirmation !== "ok") {
1776
+ throw new Error(
1777
+ `confirmed:true requires user_confirmation "yes" or "ok" from the user's chat reply. Call once without confirmed to preview, show the values, ask them to type yes or ok, then re-call with confirmed:true and user_confirmation:"yes" (or "ok").`
1778
+ );
1779
+ }
1780
+ }
1781
+ function assertUserConfirmedIfClaimed(params) {
1782
+ if (params.confirmed) assertUserConfirmed(params);
1783
+ }
1784
+ function createdId(result, key) {
1785
+ const responses = result?.mutate_operation_responses ?? result?.results ?? [];
1786
+ for (const r of responses) {
1787
+ const rn = r?.[key]?.resource_name;
1788
+ if (rn) return String(rn).split("/").pop() ?? null;
1789
+ }
1790
+ return null;
1791
+ }
1792
+
1793
+ // src/tools/handlers/report/assetGroup.ts
1794
+ var n2 = (v) => Number(v ?? 0);
1795
+ var roas2 = (value, cost) => cost > 0 ? (value / cost).toFixed(2) : "-";
1796
+ function agLine(r) {
1797
+ const ag = r.asset_group;
1798
+ const m = r.metrics;
1799
+ const cost = n2(m.cost_micros) / 1e6;
1800
+ return `\u2022 ${ag.name} (${enumLabel(enums2.AssetGroupStatus, ag.status)}) id ${ag.id} [campaign ${r.campaign?.id}], Impr ${int(n2(m.impressions))} | Clicks ${int(n2(m.clicks))} | Cost ${formatUnits(cost)} | Conv ${n2(m.conversions).toFixed(1)} | Value ${formatUnits(n2(m.conversions_value))} | ROAS ${roas2(n2(m.conversions_value), cost)}`;
1801
+ }
1802
+ var schema7 = z10.object({
1803
+ date_range: z10.enum(DATE_RANGES).default("LAST_30_DAYS"),
1804
+ campaign_id: ID.optional().describe("Limit to one Performance Max campaign"),
1805
+ limit: z10.number().int().min(1).max(2e3).default(200),
1806
+ cursor: z10.string().optional()
1807
+ });
1808
+ var assetGroupHandler = {
1809
+ action: "asset_group",
1810
+ summary: "Performance Max asset-group performance: impressions, clicks, cost, conversions, value, and computed ROAS per asset group. Optionally scope to one campaign_id. Pick a date_range; paginated via cursor.",
1811
+ readOnly: true,
1812
+ category: "report",
1813
+ schema: schema7,
1814
+ async execute(params, ctx) {
1815
+ if (params.cursor) return paginate({ ctx, cursor: params.cursor, title: "", rows: [], summary: "Asset-group performance (page)" });
1816
+ const creds = await requireConnection(ctx);
1817
+ const campaignFilter = params.campaign_id ? ` AND campaign.id = ${params.campaign_id}` : "";
1818
+ const gaql = `SELECT asset_group.id, asset_group.name, asset_group.status, campaign.id, campaign.name, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions, metrics.conversions_value FROM asset_group WHERE segments.date DURING ${params.date_range}${campaignFilter} ORDER BY metrics.cost_micros DESC`;
1819
+ const { rows, total, truncated } = await ctx.ads.searchPage(creds, gaql, params.limit);
1820
+ if (!rows.length) {
1821
+ return { output: `No asset-group data in ${params.date_range}${params.campaign_id ? ` for campaign ${params.campaign_id}` : ""}.`, auditSummary: `Report: asset groups (${params.date_range})`, customerId: creds.customerId };
1822
+ }
1823
+ return paginate({
1824
+ ctx,
1825
+ title: `P-MAX asset-group performance, ${params.date_range} (account ${formatCid(creds.customerId)})${truncated ? ` - top ${params.limit}${total != null ? ` of ${int(total)}` : ""}` : ""}`,
1826
+ rows: rows.map(agLine),
1827
+ summary: `Report: asset groups (${params.date_range})`,
1828
+ customerId: creds.customerId
1829
+ });
1830
+ }
1831
+ };
1832
+
1833
+ // src/tools/handlers/report/gaql.ts
1834
+ import { z as z11 } from "zod";
1835
+ var RESOURCE = /^[a-z][a-z0-9_]*$/;
1836
+ var FIELD = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
1837
+ var BLOCKED_RESOURCES = /* @__PURE__ */ new Set([
1838
+ "customer_user_access",
1839
+ "customer_user_access_invitation",
1840
+ "change_event",
1841
+ "change_status",
1842
+ "billing_setup",
1843
+ "payments_account",
1844
+ "account_budget",
1845
+ "account_budget_proposal"
1846
+ ]);
1847
+ function buildGaql(p2) {
1848
+ if (!RESOURCE.test(p2.resource)) {
1849
+ throw new Error(`Invalid resource "${p2.resource}". Use a single resource name like campaign or ad_group (call metadata to discover resources).`);
1850
+ }
1851
+ if (BLOCKED_RESOURCES.has(p2.resource)) {
1852
+ throw new Error(`Resource "${p2.resource}" is not allowed: it exposes user emails, change history, or billing data. This tool reports performance only, query campaign, ad_group, keyword_view, search_term_view, and similar.`);
1853
+ }
1854
+ if (!p2.fields?.length) {
1855
+ throw new Error("Provide at least one field in `fields` (call metadata to discover valid fields).");
1856
+ }
1857
+ for (const f of p2.fields) {
1858
+ if (!FIELD.test(f)) {
1859
+ throw new Error(`Invalid field "${f}". Use a fully-qualified field like campaign.name or metrics.clicks (call metadata to discover valid fields).`);
1860
+ }
1861
+ }
1862
+ const parts = [`SELECT ${p2.fields.join(", ")} FROM ${p2.resource}`];
1863
+ const conds = (p2.conditions ?? []).map((c) => c.trim()).filter(Boolean);
1864
+ if (conds.length) {
1865
+ for (const c of conds) {
1866
+ const first = c.split(/\s+/)[0] ?? "";
1867
+ if (!FIELD.test(first)) {
1868
+ throw new Error(`Invalid condition "${c}". Each condition must start with a fully-qualified field, e.g. "metrics.clicks > 100" or "segments.date DURING LAST_30_DAYS".`);
1869
+ }
1870
+ if (/[;`]/.test(c) || /\b(select|from)\b/i.test(c)) {
1871
+ throw new Error(`Disallowed token in condition "${c}".`);
1872
+ }
1873
+ }
1874
+ parts.push(`WHERE ${conds.join(" AND ")}`);
1875
+ }
1876
+ if (p2.orderings?.length) {
1877
+ const ords = [];
1878
+ for (const o of p2.orderings) {
1879
+ const m = o.trim().match(/^([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+)(?:\s+(ASC|DESC))?$/i);
1880
+ if (!m) {
1881
+ throw new Error(`Invalid ordering "${o}". Use a field with optional ASC/DESC, e.g. "metrics.cost_micros DESC".`);
1882
+ }
1883
+ ords.push(m[2] ? `${m[1]} ${m[2].toUpperCase()}` : m[1]);
1884
+ }
1885
+ parts.push(`ORDER BY ${ords.join(", ")}`);
1886
+ }
1887
+ if (p2.limit != null) parts.push(`LIMIT ${p2.limit}`);
1888
+ return parts.join(" ");
1889
+ }
1890
+ function humanizeMicros(value) {
1891
+ if (Array.isArray(value)) return value.map(humanizeMicros);
1892
+ if (value && typeof value === "object") {
1893
+ const out = {};
1894
+ for (const [k, v] of Object.entries(value)) {
1895
+ if (k.endsWith("_micros") && v != null && v !== "" && !Number.isNaN(Number(v))) {
1896
+ out[k.slice(0, -"_micros".length)] = Math.round(Number(v) / 1e4) / 100;
1897
+ } else {
1898
+ out[k] = humanizeMicros(v);
1899
+ }
1900
+ }
1901
+ return out;
1902
+ }
1903
+ return value;
1904
+ }
1905
+ var schema8 = z11.object({
1906
+ resource: z11.string().describe("Reporting resource to query, e.g. campaign, ad_group, keyword_view, search_term_view. Performance resources only, user-access, change-history, and billing resources are blocked."),
1907
+ fields: z11.array(z11.string()).min(1).describe('Fully-qualified fields to select, e.g. ["campaign.name","metrics.clicks","segments.date"]. Call metadata first.'),
1908
+ conditions: z11.array(z11.string()).optional().describe('WHERE clauses joined by AND, e.g. ["segments.date DURING LAST_30_DAYS","metrics.clicks > 100"].'),
1909
+ orderings: z11.array(z11.string()).optional().describe('ORDER BY entries, e.g. ["metrics.cost_micros DESC"].'),
1910
+ limit: z11.number().int().min(1).max(2e3).optional().describe("Max rows to fetch (default 200)."),
1911
+ cursor: z11.string().optional().describe("Continuation token from a previous page (served from cache, no new Google call).")
1912
+ });
1913
+ var gaqlHandler = {
1914
+ action: "gaql",
1915
+ summary: "Run a CUSTOM report by building a GAQL query from structured pieces (resource, fields, conditions, orderings, limit). Use for anything the curated reports do not cover (daily/weekly trends via segments.date, keyword / search-term / asset-group views). Call metadata first to learn valid fields.",
1916
+ readOnly: true,
1917
+ category: "report",
1918
+ schema: schema8,
1919
+ async execute(params, ctx) {
1920
+ if (params.cursor) return paginate({ ctx, cursor: params.cursor, title: "", rows: [], summary: "Report: custom GAQL (page)" });
1921
+ const creds = await requireConnection(ctx);
1922
+ const limit = params.limit ?? 200;
1923
+ const gaql = buildGaql({ ...params, limit: void 0 });
1924
+ const { rows, total, truncated } = await ctx.ads.searchPage(creds, gaql, limit);
1925
+ if (!rows.length) {
1926
+ return { output: `No rows for:
1927
+ ${gaql}`, auditSummary: `Report: custom GAQL (0 rows), ${gaql}`, customerId: creds.customerId };
1928
+ }
1929
+ const hasMicros = params.fields.some((f) => f.endsWith("_micros"));
1930
+ const currency = hasMicros ? await ctx.ads.currencyCode(creds) : void 0;
1931
+ const acct = `account ${formatCid(creds.customerId)}${currency ? ` \xB7 ${currency}` : ""}`;
1932
+ const cap = truncated ? `, top ${limit}${total != null ? ` of ${int(total)}` : ""}` : "";
1933
+ const moneyNote = hasMicros ? currency ? ` (amounts in ${currency}, converted from micros)` : " (amounts converted from micros)" : "";
1934
+ return paginate({
1935
+ ctx,
1936
+ title: `Custom report (${acct})${cap}${moneyNote}
1937
+ ${gaql}`,
1938
+ rows: rows.map((r) => JSON.stringify(humanizeMicros(r))),
1939
+ summary: `Report: custom GAQL, ${gaql}`,
1940
+ customerId: creds.customerId
1941
+ });
1942
+ }
1943
+ };
1944
+
1945
+ // src/tools/handlers/report/metadata.ts
1946
+ import { z as z12 } from "zod";
1947
+ var RESOURCE2 = /^[a-z][a-z0-9_]*$/;
1948
+ var schema9 = z12.object({
1949
+ resource: z12.string().describe("Resource to introspect, e.g. campaign, ad_group, keyword_view, search_term_view.")
1950
+ });
1951
+ var metadataHandler = {
1952
+ action: "metadata",
1953
+ summary: "Discover the valid fields for a resource before writing a gaql query: selectable / filterable / sortable fields plus compatible metrics and segments. Do not guess field names, look them up here.",
1954
+ readOnly: true,
1955
+ category: "report",
1956
+ schema: schema9,
1957
+ async execute(params, ctx) {
1958
+ const creds = await requireConnection(ctx);
1959
+ const resource = params.resource.trim().toLowerCase();
1960
+ if (!RESOURCE2.test(resource)) {
1961
+ throw new Error(`Invalid resource "${params.resource}". Use a single resource name like campaign or ad_group.`);
1962
+ }
1963
+ const [attrs, nodeRows] = await Promise.all([
1964
+ ctx.ads.searchFields(creds, `name LIKE '${resource}.%'`),
1965
+ ctx.ads.searchFields(creds, `name = '${resource}'`)
1966
+ ]);
1967
+ if (!attrs.length && !nodeRows.length) {
1968
+ return { output: `No field metadata found for resource "${resource}". Check the resource name.`, auditSummary: `Metadata: ${resource} (none)`, customerId: creds.customerId };
1969
+ }
1970
+ const names = (pred) => attrs.filter(pred).map((f) => f.name).sort();
1971
+ const node = nodeRows[0];
1972
+ const block = (label, items) => `${label} (${items.length}):
1973
+ ${items.length ? items.join(", ") : "(none)"}`;
1974
+ return {
1975
+ output: [
1976
+ `Fields for "${resource}" (use these exact names in a gaql query):`,
1977
+ block("Selectable", names((f) => Boolean(f.selectable))),
1978
+ block("Filterable", names((f) => Boolean(f.filterable))),
1979
+ block("Sortable", names((f) => Boolean(f.sortable))),
1980
+ block("Compatible metrics", (node?.metrics ?? []).slice().sort()),
1981
+ block("Compatible segments", (node?.segments ?? []).slice().sort())
1982
+ ].join("\n\n"),
1983
+ auditSummary: `Metadata: ${resource}`,
1984
+ customerId: creds.customerId
1985
+ };
1986
+ }
1987
+ };
1988
+
1989
+ // src/tools/orchestrators/report.ts
1990
+ var reportTool = defineOrchestrator({
1991
+ name: "google_ads_report",
1992
+ purpose: "Read Google Ads performance. Prefer a curated action: campaign_performance for any channel, impression_share for Search visibility (the Average Position replacement), or asset_group for Performance Max. For anything else, daily or weekly trends, keyword or search-term views, custom field combinations, use gaql after calling metadata first.",
1993
+ limitations: "Read-only. ROAS is computed as conversion value divided by cost. Average Position does not exist; use action impression_share instead. impression_share only applies to Search campaigns; asset_group only applies to Performance Max. Curated reports already format values, but in raw gaql results, money fields are in micros (divide by 1,000,000) and rates (metrics.ctr, conversion rate, impression share) are ratios from 0 to 1. For gaql, do not guess field names: call metadata first to get the exact selectable, filterable, and sortable fields plus compatible metrics and segments, then use those names verbatim.",
1994
+ examples: [
1995
+ '{"action":"campaign_performance","date_range":"LAST_30_DAYS"}',
1996
+ '{"action":"campaign_performance","date_range":"LAST_MONTH","compare_previous":true,"channel_type":"PERFORMANCE_MAX"}',
1997
+ '{"action":"impression_share","date_range":"LAST_30_DAYS"}',
1998
+ '{"action":"asset_group","campaign_id":"1234567890","date_range":"LAST_30_DAYS"}',
1999
+ '{"action":"metadata","resource":"keyword_view"}',
2000
+ '{"action":"gaql","resource":"keyword_view","fields":["ad_group_criterion.keyword.text","metrics.clicks","segments.date"],"conditions":["segments.date DURING LAST_7_DAYS"]}'
2001
+ ],
2002
+ handlers: [performanceHandler, impressionShareHandler, assetGroupHandler, gaqlHandler, metadataHandler]
2003
+ });
2004
+
2005
+ // src/tools/handlers/campaign/create.ts
2006
+ import { z as z13 } from "zod";
2007
+ import { enums as enums3, toMicros as toMicros2, ResourceNames } from "google-ads-api";
2008
+
2009
+ // src/tools/handlers/campaign/helpers.ts
2010
+ import { toMicros } from "google-ads-api";
2011
+ var BIDDING = ["MANUAL_CPC", "MAXIMIZE_CONVERSIONS", "MAXIMIZE_CONVERSION_VALUE", "TARGET_CPA", "TARGET_ROAS"];
2012
+ function biddingFields(strategy, targetCpa, targetRoas, forUpdate = false) {
2013
+ switch (strategy) {
2014
+ case "MANUAL_CPC":
2015
+ return { manual_cpc: { enhanced_cpc_enabled: false } };
2016
+ case "MAXIMIZE_CONVERSIONS":
2017
+ return { maximize_conversions: targetCpa != null ? { target_cpa_micros: toMicros(targetCpa) } : forUpdate ? { target_cpa_micros: 0 } : {} };
2018
+ case "MAXIMIZE_CONVERSION_VALUE":
2019
+ return { maximize_conversion_value: targetRoas != null ? { target_roas: targetRoas } : forUpdate ? { target_roas: 0 } : {} };
2020
+ case "TARGET_CPA":
2021
+ return { target_cpa: { target_cpa_micros: toMicros(targetCpa ?? 0) } };
2022
+ case "TARGET_ROAS":
2023
+ return { target_roas: { target_roas: targetRoas ?? 0 } };
2024
+ }
2025
+ }
2026
+ var LANG_IDS = {
2027
+ english: "1000",
2028
+ en: "1000",
2029
+ german: "1001",
2030
+ french: "1002",
2031
+ spanish: "1003",
2032
+ italian: "1004",
2033
+ japanese: "1005",
2034
+ ja: "1005",
2035
+ dutch: "1010",
2036
+ portuguese: "1014",
2037
+ swedish: "1015",
2038
+ korean: "1012",
2039
+ ko: "1012",
2040
+ chinese: "1017",
2041
+ "chinese (simplified)": "1017",
2042
+ "chinese (traditional)": "1018",
2043
+ arabic: "1019",
2044
+ russian: "1031"
2045
+ };
2046
+ function geoId(s) {
2047
+ if (/^\d+$/.test(s)) return s;
2048
+ throw new Error(`Location "${s}" must be a numeric geo target constant id. Resolve the name to an id with google_ads_lookup (action location) first, then pass the id.`);
2049
+ }
2050
+ function langId(s) {
2051
+ if (/^\d+$/.test(s)) return s;
2052
+ const id = LANG_IDS[s.toLowerCase()];
2053
+ if (!id) throw new Error(`Unknown language "${s}". Resolve it with google_ads_lookup (action language), or pass a numeric language constant id.`);
2054
+ return id;
2055
+ }
2056
+
2057
+ // src/tools/handlers/campaign/create.ts
2058
+ var schema10 = z13.object({
2059
+ name: z13.string().min(1).max(255),
2060
+ channel_type: z13.enum(["SEARCH", "PERFORMANCE_MAX", "DISPLAY", "DEMAND_GEN", "VIDEO", "APP", "SHOPPING"]),
2061
+ daily_budget: z13.number().positive().describe("Daily amount in account currency (e.g. 50 = 50.00/day)"),
2062
+ bidding: z13.enum(BIDDING).default("MAXIMIZE_CONVERSIONS"),
2063
+ target_cpa: z13.number().positive().optional().describe("Target CPA in account currency (TARGET_CPA / MAXIMIZE_CONVERSIONS)"),
2064
+ target_roas: z13.number().positive().optional().describe("Target ROAS as a ratio, e.g. 4 = 400% (TARGET_ROAS / MAXIMIZE_CONVERSION_VALUE)"),
2065
+ app_id: z13.string().optional().describe("APP only: the app id (Android package name like com.example.app, or the numeric iOS App Store id)."),
2066
+ app_store: z13.enum(["GOOGLE_APP_STORE", "APPLE_APP_STORE"]).optional().describe("APP only: which store the app is in."),
2067
+ merchant_id: z13.string().regex(/^\d+$/).optional().describe("SHOPPING only: the linked Merchant Center account id; products come from its feed."),
2068
+ brand_guidelines_enabled: z13.boolean().optional().describe("PERFORMANCE_MAX only. ON requires a business name + square logo (added in the UI); OFF creates without brand assets. Ask the user, do not assume."),
2069
+ contains_eu_political_advertising: z13.boolean().default(false).describe("Google-required EU political-ads disclosure. Almost always false; set true only if this campaign runs EU political advertising."),
2070
+ ...confirmFields
2071
+ });
2072
+ var createHandler = {
2073
+ action: "create",
2074
+ summary: "Create a campaign plus its daily budget, atomically. channel_type: SEARCH, DISPLAY, DEMAND_GEN, VIDEO, PERFORMANCE_MAX, APP (needs app_id + app_store, TARGET_CPA bidding), or SHOPPING (needs merchant_id from a linked Merchant Center). Created PAUSED. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2075
+ readOnly: false,
2076
+ category: "mutate",
2077
+ schema: schema10,
2078
+ async execute(params, ctx) {
2079
+ const creds = await requireConnection(ctx);
2080
+ if (params.channel_type === "PERFORMANCE_MAX" && params.brand_guidelines_enabled === void 0) {
2081
+ return {
2082
+ output: `Before I create the P-MAX campaign "${params.name}", one choice I will not assume:
2083
+
2084
+ Brand Guidelines, ON or OFF?
2085
+ \u2022 OFF: I create it now; no brand assets needed.
2086
+ \u2022 ON: Google requires a business name + a square logo linked to the campaign. Those have to be added in the Google Ads UI, so the API create fails until they exist.
2087
+
2088
+ Ask the user, then call create again with brand_guidelines_enabled set to true or false.`,
2089
+ auditSummary: `Wizard: brand-guidelines choice for "${params.name}"`,
2090
+ customerId: creds.customerId
2091
+ };
2092
+ }
2093
+ if (params.channel_type === "PERFORMANCE_MAX" && params.bidding !== "MAXIMIZE_CONVERSIONS" && params.bidding !== "MAXIMIZE_CONVERSION_VALUE") {
2094
+ throw new Error("Performance Max requires MAXIMIZE_CONVERSIONS or MAXIMIZE_CONVERSION_VALUE bidding.");
2095
+ }
2096
+ if (params.channel_type === "DEMAND_GEN" && params.bidding === "MANUAL_CPC") {
2097
+ throw new Error("Demand Gen requires automated bidding (not MANUAL_CPC): use MAXIMIZE_CONVERSIONS, TARGET_CPA, MAXIMIZE_CONVERSION_VALUE, or TARGET_ROAS.");
2098
+ }
2099
+ if (params.channel_type === "VIDEO" && params.bidding !== "MAXIMIZE_CONVERSIONS" && params.bidding !== "TARGET_CPA") {
2100
+ throw new Error("A Video Action campaign requires automated bidding (MAXIMIZE_CONVERSIONS or TARGET_CPA), not MANUAL_CPC.");
2101
+ }
2102
+ if (params.channel_type === "APP") {
2103
+ if (!params.app_id) throw new Error("app_id is required for an App campaign (the Android package name or numeric iOS App Store id).");
2104
+ if (!params.app_store) throw new Error("app_store is required for an App campaign (GOOGLE_APP_STORE or APPLE_APP_STORE).");
2105
+ if (params.bidding !== "TARGET_CPA") throw new Error("An App campaign requires TARGET_CPA bidding (target_cpa = your target cost per install).");
2106
+ }
2107
+ if (params.channel_type === "SHOPPING" && !params.merchant_id) {
2108
+ throw new Error("merchant_id is required for a Shopping campaign: its products come from a linked Merchant Center account. Link Merchant Center to this Google Ads account first, then pass its id.");
2109
+ }
2110
+ if (params.bidding === "TARGET_CPA" && params.target_cpa == null) throw new Error("target_cpa is required for TARGET_CPA bidding.");
2111
+ if (params.bidding === "TARGET_ROAS" && params.target_roas == null) throw new Error("target_roas is required for TARGET_ROAS bidding.");
2112
+ const channelTypeOf = {
2113
+ SEARCH: enums3.AdvertisingChannelType.SEARCH,
2114
+ DISPLAY: enums3.AdvertisingChannelType.DISPLAY,
2115
+ DEMAND_GEN: enums3.AdvertisingChannelType.DEMAND_GEN,
2116
+ VIDEO: enums3.AdvertisingChannelType.VIDEO,
2117
+ PERFORMANCE_MAX: enums3.AdvertisingChannelType.PERFORMANCE_MAX,
2118
+ SHOPPING: enums3.AdvertisingChannelType.SHOPPING,
2119
+ APP: enums3.AdvertisingChannelType.MULTI_CHANNEL
2120
+ };
2121
+ const budgetRN = ResourceNames.campaignBudget(creds.customerId, "-1");
2122
+ const campaign = {
2123
+ name: params.name,
2124
+ advertising_channel_type: channelTypeOf[params.channel_type],
2125
+ status: enums3.CampaignStatus.PAUSED,
2126
+ // never auto-launch spend
2127
+ campaign_budget: budgetRN,
2128
+ // Google requires this disclosure on every create (omitting it is a field_error).
2129
+ contains_eu_political_advertising: params.contains_eu_political_advertising ? enums3.EuPoliticalAdvertisingStatus.CONTAINS_EU_POLITICAL_ADVERTISING : enums3.EuPoliticalAdvertisingStatus.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING,
2130
+ ...biddingFields(params.bidding, params.target_cpa, params.target_roas)
2131
+ };
2132
+ if (params.channel_type === "SEARCH") {
2133
+ campaign.network_settings = { target_google_search: true, target_search_network: true };
2134
+ } else if (params.channel_type === "DISPLAY") {
2135
+ campaign.network_settings = { target_content_network: true };
2136
+ } else if (params.channel_type === "VIDEO") {
2137
+ campaign.advertising_channel_sub_type = enums3.AdvertisingChannelSubType.VIDEO_ACTION;
2138
+ } else if (params.channel_type === "APP") {
2139
+ campaign.advertising_channel_sub_type = enums3.AdvertisingChannelSubType.APP_CAMPAIGN;
2140
+ campaign.app_campaign_setting = {
2141
+ app_id: params.app_id,
2142
+ app_store: enums3.AppCampaignAppStore[params.app_store],
2143
+ bidding_strategy_goal_type: enums3.AppCampaignBiddingStrategyGoalType.OPTIMIZE_INSTALLS_TARGET_INSTALL_COST
2144
+ };
2145
+ } else if (params.channel_type === "SHOPPING") {
2146
+ campaign.shopping_setting = { merchant_id: Number(params.merchant_id) };
2147
+ } else if (params.channel_type === "PERFORMANCE_MAX") {
2148
+ campaign.brand_guidelines_enabled = params.brand_guidelines_enabled;
2149
+ }
2150
+ const operations = [
2151
+ {
2152
+ entity: "campaign_budget",
2153
+ operation: "create",
2154
+ resource: { resource_name: budgetRN, name: `${params.name} Budget`, delivery_method: enums3.BudgetDeliveryMethod.STANDARD, amount_micros: toMicros2(params.daily_budget), explicitly_shared: false }
2155
+ },
2156
+ { entity: "campaign", operation: "create", resource: campaign }
2157
+ ];
2158
+ const customer = ctx.ads.customer(creds);
2159
+ if (!params.confirmed) {
2160
+ await customer.mutateResources(operations, { validate_only: true });
2161
+ const euDecl = params.contains_eu_political_advertising ? "this campaign CONTAINS EU political advertising" : "this campaign does NOT contain EU political advertising";
2162
+ return {
2163
+ output: preview(
2164
+ `create a ${params.channel_type} campaign "${params.name}" with a ${params.daily_budget}/day budget and ${params.bidding} bidding (created PAUSED).
2165
+
2166
+ \u2696\uFE0F LEGAL DECLARATION submitted to Google on the user's behalf: ${euDecl} (contains_eu_political_advertising=${params.contains_eu_political_advertising}). Show ALL of these values to the user and get their explicit OK (change any they did not ask for). If the campaign runs EU political ads, set contains_eu_political_advertising=true before confirming.`,
2167
+ { name: params.name, channel_type: params.channel_type, daily_budget: params.daily_budget, bidding: params.bidding, contains_eu_political_advertising: params.contains_eu_political_advertising }
2168
+ ),
2169
+ auditSummary: `Preview: create ${params.channel_type} campaign "${params.name}"`,
2170
+ customerId: creds.customerId
2171
+ };
2172
+ }
2173
+ assertUserConfirmed(params);
2174
+ const result = await customer.mutateResources(operations);
2175
+ const id = createdId(result, "campaign_result");
2176
+ const nextByChannel = {
2177
+ PERFORMANCE_MAX: "\nNext: add an asset group before enabling, a P-MAX campaign needs assets to serve.",
2178
+ DISPLAY: "\nNext: add an ad group, then a responsive display ad (add_ad ad_type RESPONSIVE_DISPLAY_AD) or an HTML5 ad, then enable the campaign.",
2179
+ DEMAND_GEN: "\nNext: add an ad group, then a Demand Gen ad (add_ad ad_type DEMAND_GEN_MULTI_ASSET), then enable the campaign.",
2180
+ VIDEO: "\nNext: add an ad group, then a responsive video ad (add_ad ad_type VIDEO_RESPONSIVE with a YouTube video), then enable the campaign.",
2181
+ APP: "\nNext: App campaigns auto-create an ad group, find it with ad_group list_ads (or a report), add an app ad (add_ad ad_type APP), then enable the campaign.",
2182
+ SHOPPING: "\nNext: add an ad group, then a product ad (add_ad ad_type SHOPPING_PRODUCT). Products serve from the linked Merchant Center feed, make sure Merchant Center is linked.",
2183
+ SEARCH: "\nNext: add an ad group and a responsive search ad, then enable the campaign."
2184
+ };
2185
+ const next = nextByChannel[params.channel_type] ?? nextByChannel.SEARCH;
2186
+ return {
2187
+ output: `\u2705 Created ${params.channel_type} campaign "${params.name}"${id ? ` (id ${id})` : ""}, PAUSED, ${params.daily_budget}/day, ${params.bidding} bidding.` + next,
2188
+ auditSummary: `Created ${params.channel_type} campaign "${params.name}"${id ? ` (${id})` : ""}`,
2189
+ customerId: creds.customerId
2190
+ };
2191
+ }
2192
+ };
2193
+
2194
+ // src/tools/handlers/campaign/update.ts
2195
+ import { z as z14 } from "zod";
2196
+ var DATE = /^\d{4}-\d{2}-\d{2}$/;
2197
+ var schema11 = z14.object({
2198
+ campaign_id: ID,
2199
+ name: z14.string().min(1).max(255).optional(),
2200
+ start_date: z14.string().regex(DATE, "must be YYYY-MM-DD").optional(),
2201
+ end_date: z14.string().regex(DATE, "must be YYYY-MM-DD").optional(),
2202
+ search_network: z14.boolean().optional().describe("Search campaigns only: include Google search partners"),
2203
+ content_network: z14.boolean().optional().describe("Search campaigns only: include the Display Network (search with display expansion)"),
2204
+ ...confirmFields
2205
+ });
2206
+ var updateHandler = {
2207
+ action: "update",
2208
+ summary: "Edit a campaign in place: rename, change start/end dates (YYYY-MM-DD), or toggle search-partner / display-network settings (Search campaigns only). Requires campaign_id and at least one field. In-place update, not delete-and-recreate. For pause/resume use action status, for budget use budget, for bidding use bidding. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2209
+ readOnly: false,
2210
+ category: "mutate",
2211
+ schema: schema11,
2212
+ async execute(params, ctx) {
2213
+ const hasNetwork = params.search_network != null || params.content_network != null;
2214
+ if (params.name == null && params.start_date == null && params.end_date == null && !hasNetwork) {
2215
+ throw new Error("Provide at least one of name, start_date, end_date, search_network, content_network.");
2216
+ }
2217
+ const creds = await requireConnection(ctx);
2218
+ const customer = ctx.ads.customer(creds);
2219
+ const resource = { resource_name: `customers/${creds.customerId}/campaigns/${params.campaign_id}` };
2220
+ if (params.name != null) resource.name = params.name;
2221
+ if (params.start_date != null) resource.start_date = params.start_date;
2222
+ if (params.end_date != null) resource.end_date = params.end_date;
2223
+ if (hasNetwork) {
2224
+ const ns = {};
2225
+ if (params.search_network != null) ns.target_search_network = params.search_network;
2226
+ if (params.content_network != null) ns.target_content_network = params.content_network;
2227
+ resource.network_settings = ns;
2228
+ }
2229
+ const changes = [
2230
+ params.name != null ? `name\u2192${params.name}` : "",
2231
+ params.start_date != null ? `start\u2192${params.start_date}` : "",
2232
+ params.end_date != null ? `end\u2192${params.end_date}` : "",
2233
+ params.search_network != null ? `search_network\u2192${params.search_network}` : "",
2234
+ params.content_network != null ? `content_network\u2192${params.content_network}` : ""
2235
+ ].filter(Boolean).join(", ");
2236
+ const ops = [{ entity: "campaign", operation: "update", resource }];
2237
+ if (!params.confirmed) {
2238
+ await customer.mutateResources(ops, { validate_only: true });
2239
+ return {
2240
+ output: preview(`update campaign ${params.campaign_id} (${changes})`, {
2241
+ campaign_id: params.campaign_id,
2242
+ ...params.name != null ? { name: params.name } : {},
2243
+ ...params.start_date != null ? { start_date: params.start_date } : {},
2244
+ ...params.end_date != null ? { end_date: params.end_date } : {},
2245
+ ...params.search_network != null ? { search_network: params.search_network } : {},
2246
+ ...params.content_network != null ? { content_network: params.content_network } : {},
2247
+ confirmed: true
2248
+ }),
2249
+ auditSummary: `Preview: update campaign ${params.campaign_id} (${changes})`,
2250
+ customerId: creds.customerId
2251
+ };
2252
+ }
2253
+ assertUserConfirmed(params);
2254
+ await customer.mutateResources(ops);
2255
+ return {
2256
+ output: `\u2705 Updated campaign ${params.campaign_id} (${changes}).`,
2257
+ auditSummary: `Updated campaign ${params.campaign_id} (${changes})`,
2258
+ customerId: creds.customerId
2259
+ };
2260
+ }
2261
+ };
2262
+
2263
+ // src/tools/handlers/campaign/status.ts
2264
+ import { z as z15 } from "zod";
2265
+ import { enums as enums4 } from "google-ads-api";
2266
+ var schema12 = z15.object({
2267
+ campaign_id: ID,
2268
+ status: z15.enum(["ENABLED", "PAUSED", "REMOVED"]),
2269
+ ...confirmFields
2270
+ });
2271
+ var statusHandler2 = {
2272
+ action: "status",
2273
+ summary: "Pause, resume (ENABLED), or delete (REMOVED) a campaign. Requires campaign_id + status (ENABLED|PAUSED|REMOVED). REMOVED is permanent and cannot be undone. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2274
+ readOnly: false,
2275
+ category: "mutate",
2276
+ schema: schema12,
2277
+ async execute(params, ctx) {
2278
+ const creds = await requireConnection(ctx);
2279
+ const customer = ctx.ads.customer(creds);
2280
+ const rn = `customers/${creds.customerId}/campaigns/${params.campaign_id}`;
2281
+ const isRemove = params.status === "REMOVED";
2282
+ const identity = { campaign_id: params.campaign_id, status: params.status };
2283
+ const updateOp = [{ resource_name: rn, status: enums4.CampaignStatus[params.status] }];
2284
+ if (!params.confirmed && (!isRemove || userConfirmationRequired())) {
2285
+ if (isRemove) await customer.campaigns.remove([rn], { validate_only: true });
2286
+ else await customer.campaigns.update(updateOp, { validate_only: true });
2287
+ const output = isRemove ? previewRemove(`campaign ${params.campaign_id} (deletes the campaign permanently)`, identity) : preview(`set campaign ${params.campaign_id} to ${params.status}`, identity);
2288
+ return {
2289
+ output,
2290
+ auditSummary: `Preview: ${params.status} campaign ${params.campaign_id}`,
2291
+ customerId: creds.customerId
2292
+ };
2293
+ }
2294
+ assertUserConfirmedIfClaimed(params);
2295
+ if (isRemove) {
2296
+ await customer.campaigns.remove([rn]);
2297
+ return { output: `\u2705 Campaign ${params.campaign_id} removed (permanently deleted).`, auditSummary: `REMOVED campaign ${params.campaign_id}`, customerId: creds.customerId };
2298
+ }
2299
+ await customer.campaigns.update(updateOp);
2300
+ return {
2301
+ output: `\u2705 Campaign ${params.campaign_id} is now ${params.status}.`,
2302
+ auditSummary: `${params.status} campaign ${params.campaign_id}`,
2303
+ customerId: creds.customerId
2304
+ };
2305
+ }
2306
+ };
2307
+
2308
+ // src/tools/handlers/campaign/budget.ts
2309
+ import { z as z16 } from "zod";
2310
+ import { toMicros as toMicros3 } from "google-ads-api";
2311
+ var schema13 = z16.object({
2312
+ campaign_id: ID,
2313
+ daily_budget: z16.number().positive().describe("New daily amount in account currency (not micros)"),
2314
+ ...confirmFields
2315
+ });
2316
+ var budgetHandler = {
2317
+ action: "budget",
2318
+ summary: "Change a campaign's daily budget (account currency). Requires campaign_id + daily_budget. Shows before/after and warns on shared budgets. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2319
+ readOnly: false,
2320
+ category: "mutate",
2321
+ schema: schema13,
2322
+ async execute(params, ctx) {
2323
+ const creds = await requireConnection(ctx);
2324
+ const customer = ctx.ads.customer(creds);
2325
+ const rows = await ctx.ads.query(
2326
+ creds,
2327
+ `SELECT campaign.id, campaign_budget.resource_name, campaign_budget.amount_micros, campaign_budget.explicitly_shared, campaign_budget.reference_count FROM campaign WHERE campaign.id = ${params.campaign_id} LIMIT 1`
2328
+ );
2329
+ if (!rows.length) throw new Error(`Campaign ${params.campaign_id} not found in the connected account.`);
2330
+ const cb = rows[0].campaign_budget;
2331
+ const budgetResource = cb.resource_name;
2332
+ const currentMicros = Number(cb.amount_micros ?? 0);
2333
+ const newMicros = toMicros3(params.daily_budget);
2334
+ const refCount = Number(cb.reference_count ?? 1);
2335
+ const sharedWarn = cb.explicitly_shared || refCount > 1 ? `
2336
+ \u26A0\uFE0F This budget is SHARED by ${refCount} campaign(s). This change affects all of them, not just ${params.campaign_id}.` : "";
2337
+ const op = [{ resource_name: budgetResource, amount_micros: newMicros }];
2338
+ if (!params.confirmed) {
2339
+ await customer.campaignBudgets.update(op, { validate_only: true });
2340
+ return {
2341
+ output: preview(`change the daily budget of campaign ${params.campaign_id} from ${money(currentMicros)} to ${money(newMicros)}`, {
2342
+ campaign_id: params.campaign_id,
2343
+ daily_budget: params.daily_budget,
2344
+ confirmed: true
2345
+ }) + sharedWarn,
2346
+ auditSummary: `Preview: budget campaign ${params.campaign_id} ${money(currentMicros)}\u2192${money(newMicros)}`,
2347
+ customerId: creds.customerId
2348
+ };
2349
+ }
2350
+ assertUserConfirmed(params);
2351
+ await customer.campaignBudgets.update(op);
2352
+ return {
2353
+ output: `\u2705 Daily budget for campaign ${params.campaign_id} set to ${money(newMicros)} (was ${money(currentMicros)}).${sharedWarn}`,
2354
+ auditSummary: `Set budget campaign ${params.campaign_id} \u2192 ${money(newMicros)}`,
2355
+ customerId: creds.customerId
2356
+ };
2357
+ }
2358
+ };
2359
+
2360
+ // src/tools/handlers/campaign/bidding.ts
2361
+ import { z as z17 } from "zod";
2362
+ import { ResourceNames as ResourceNames2 } from "google-ads-api";
2363
+ var schema14 = z17.object({
2364
+ campaign_id: ID,
2365
+ strategy: z17.enum(BIDDING),
2366
+ target_cpa: z17.number().positive().optional(),
2367
+ target_roas: z17.number().positive().optional(),
2368
+ ...confirmFields
2369
+ });
2370
+ var biddingHandler = {
2371
+ action: "bidding",
2372
+ summary: "Change a campaign's bidding strategy (MANUAL_CPC, MAXIMIZE_CONVERSIONS, MAXIMIZE_CONVERSION_VALUE, TARGET_CPA, TARGET_ROAS). Provide target_cpa/target_roas for the matching strategy. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2373
+ readOnly: false,
2374
+ category: "mutate",
2375
+ schema: schema14,
2376
+ async execute(params, ctx) {
2377
+ const creds = await requireConnection(ctx);
2378
+ if (params.strategy === "TARGET_CPA" && params.target_cpa == null) throw new Error("target_cpa is required for TARGET_CPA.");
2379
+ if (params.strategy === "TARGET_ROAS" && params.target_roas == null) throw new Error("target_roas is required for TARGET_ROAS.");
2380
+ const op = [
2381
+ {
2382
+ resource_name: ResourceNames2.campaign(creds.customerId, params.campaign_id),
2383
+ ...biddingFields(params.strategy, params.target_cpa, params.target_roas, true)
2384
+ }
2385
+ ];
2386
+ const customer = ctx.ads.customer(creds);
2387
+ if (!params.confirmed) {
2388
+ await customer.campaigns.update(op, { validate_only: true });
2389
+ return {
2390
+ output: preview(`change campaign ${params.campaign_id} bidding to ${params.strategy}`, { campaign_id: params.campaign_id, strategy: params.strategy, confirmed: true }),
2391
+ auditSummary: `Preview: bidding ${params.strategy} on campaign ${params.campaign_id}`,
2392
+ customerId: creds.customerId
2393
+ };
2394
+ }
2395
+ assertUserConfirmed(params);
2396
+ await customer.campaigns.update(op);
2397
+ return {
2398
+ output: `\u2705 Campaign ${params.campaign_id} bidding set to ${params.strategy}.`,
2399
+ auditSummary: `Set bidding ${params.strategy} on campaign ${params.campaign_id}`,
2400
+ customerId: creds.customerId
2401
+ };
2402
+ }
2403
+ };
2404
+
2405
+ // src/tools/handlers/campaign/targeting.ts
2406
+ import { z as z18 } from "zod";
2407
+ import { enums as enums5, ResourceNames as ResourceNames3 } from "google-ads-api";
2408
+ var schema15 = z18.object({
2409
+ campaign_id: ID,
2410
+ locations: z18.array(z18.string()).optional().describe("Numeric geo target constant ids. Resolve a name first with google_ads_lookup (action location)."),
2411
+ languages: z18.array(z18.string()).optional().describe("Numeric language constant ids (common names also accepted). Resolve a name with google_ads_lookup (action language)."),
2412
+ ad_schedule: z18.array(
2413
+ z18.object({
2414
+ day: z18.enum(["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]),
2415
+ start_hour: z18.number().int().min(0).max(23),
2416
+ end_hour: z18.number().int().min(1).max(24)
2417
+ })
2418
+ ).optional().describe("Hours ads may run, per day"),
2419
+ ...confirmFields
2420
+ });
2421
+ var targetingHandler = {
2422
+ action: "targeting",
2423
+ summary: "Add targeting to a campaign: locations (numeric geo ids), languages (names or ids), and/or ad_schedule (hours). Provide at least one. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2424
+ readOnly: false,
2425
+ category: "mutate",
2426
+ schema: schema15,
2427
+ async execute(params, ctx) {
2428
+ const creds = await requireConnection(ctx);
2429
+ if (!params.locations?.length && !params.languages?.length && !params.ad_schedule?.length) {
2430
+ throw new Error("Provide at least one of locations, languages, or ad_schedule.");
2431
+ }
2432
+ const campaignRN = ResourceNames3.campaign(creds.customerId, params.campaign_id);
2433
+ const ops = [];
2434
+ for (const loc of params.locations ?? []) {
2435
+ ops.push({ entity: "campaign_criterion", operation: "create", resource: { campaign: campaignRN, location: { geo_target_constant: ResourceNames3.geoTargetConstant(geoId(loc)) } } });
2436
+ }
2437
+ for (const lang of params.languages ?? []) {
2438
+ ops.push({ entity: "campaign_criterion", operation: "create", resource: { campaign: campaignRN, language: { language_constant: ResourceNames3.languageConstant(langId(lang)) } } });
2439
+ }
2440
+ for (const s of params.ad_schedule ?? []) {
2441
+ if (s.end_hour <= s.start_hour) {
2442
+ throw new Error(`ad_schedule for ${s.day}: end_hour (${s.end_hour}) must be greater than start_hour (${s.start_hour}).`);
2443
+ }
2444
+ ops.push({
2445
+ entity: "campaign_criterion",
2446
+ operation: "create",
2447
+ resource: { campaign: campaignRN, ad_schedule: { day_of_week: enums5.DayOfWeek[s.day], start_hour: s.start_hour, start_minute: enums5.MinuteOfHour.ZERO, end_hour: s.end_hour, end_minute: enums5.MinuteOfHour.ZERO } }
2448
+ });
2449
+ }
2450
+ const parts = [];
2451
+ if (params.locations?.length) parts.push(`${params.locations.length} location(s)`);
2452
+ if (params.languages?.length) parts.push(`${params.languages.length} language(s)`);
2453
+ if (params.ad_schedule?.length) parts.push(`${params.ad_schedule.length} schedule(s)`);
2454
+ const what = parts.join(", ");
2455
+ const customer = ctx.ads.customer(creds);
2456
+ if (!params.confirmed) {
2457
+ await customer.mutateResources(ops, { validate_only: true });
2458
+ return {
2459
+ output: preview(`add ${what} targeting to campaign ${params.campaign_id}`, { campaign_id: params.campaign_id, confirmed: true }),
2460
+ auditSummary: `Preview: targeting (${what}) on campaign ${params.campaign_id}`,
2461
+ customerId: creds.customerId
2462
+ };
2463
+ }
2464
+ assertUserConfirmed(params);
2465
+ await customer.mutateResources(ops);
2466
+ return {
2467
+ output: `\u2705 Added ${what} targeting to campaign ${params.campaign_id}.`,
2468
+ auditSummary: `Targeting (${what}) on campaign ${params.campaign_id}`,
2469
+ customerId: creds.customerId
2470
+ };
2471
+ }
2472
+ };
2473
+
2474
+ // src/tools/orchestrators/campaign.ts
2475
+ var campaignTool = defineOrchestrator({
2476
+ name: "google_ads_campaign",
2477
+ purpose: "Create and manage campaigns. create builds a new one for Search, Display, Demand Gen, App, Shopping, or Performance Max (for YouTube, use DEMAND_GEN, not VIDEO). update renames it or changes its dates and network settings. status pauses or resumes it. budget sets the daily budget. bidding sets the bidding strategy. targeting sets location, language, and schedule.",
2478
+ limitations: "Every write previews first; then the user must type yes or ok in chat before confirmed:true + user_confirmation. New campaigns are created PAUSED. All edits here are in-place updates, not delete-and-recreate. Use the right action: update for name, dates, and networks; status for pause or resume; budget for the daily budget; bidding for the strategy; targeting for locations, languages, and schedule. Get campaign_id from report. Performance Max, Demand Gen, and Video all require automated bidding. App needs app_id, app_store, and TARGET_CPA bidding. Shopping needs a merchant_id from a linked Merchant Center account. VIDEO campaigns can no longer be created through the API (Google sunset Video Action campaigns for Demand Gen), so use DEMAND_GEN for YouTube instead. Location and language are numeric ids, so resolve names with google_ads_lookup first.",
2479
+ examples: [
2480
+ '{"action":"create","name":"Brand - Search","channel_type":"SEARCH","daily_budget":50,"bidding":"MAXIMIZE_CONVERSIONS"}',
2481
+ '{"action":"create","name":"App - Installs","channel_type":"APP","daily_budget":50,"bidding":"TARGET_CPA","target_cpa":3,"app_id":"com.example.app","app_store":"GOOGLE_APP_STORE"}',
2482
+ '{"action":"create","name":"Shopping - All Products","channel_type":"SHOPPING","daily_budget":50,"bidding":"MANUAL_CPC","merchant_id":"1234567"}',
2483
+ '{"action":"update","campaign_id":"123","name":"Brand 2026","end_date":"2026-12-31","confirmed":true,"user_confirmation":"yes"}',
2484
+ '{"action":"status","campaign_id":"123","status":"PAUSED","confirmed":true,"user_confirmation":"yes"}',
2485
+ '{"action":"budget","campaign_id":"123","daily_budget":75,"confirmed":true,"user_confirmation":"yes"}',
2486
+ '{"action":"bidding","campaign_id":"123","strategy":"TARGET_ROAS","target_roas":4,"confirmed":true,"user_confirmation":"yes"}',
2487
+ '{"action":"targeting","campaign_id":"123","locations":["2392"],"languages":["1005"],"confirmed":true,"user_confirmation":"yes"}'
2488
+ ],
2489
+ handlers: [createHandler, updateHandler, statusHandler2, budgetHandler, biddingHandler, targetingHandler]
2490
+ });
2491
+
2492
+ // src/tools/handlers/adGroup/create.ts
2493
+ import { z as z19 } from "zod";
2494
+ import { enums as enums6, toMicros as toMicros4, ResourceNames as ResourceNames4 } from "google-ads-api";
2495
+ var schema16 = z19.object({
2496
+ campaign_id: ID,
2497
+ name: z19.string().min(1).max(255),
2498
+ cpc_bid: z19.number().positive().optional().describe("Default max CPC in account currency (manual bidding only)"),
2499
+ ...confirmFields
2500
+ });
2501
+ var createHandler2 = {
2502
+ action: "create",
2503
+ summary: "Create an ad group inside a Search/Display campaign. Requires campaign_id + name. Not for Performance Max. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2504
+ readOnly: false,
2505
+ category: "mutate",
2506
+ schema: schema16,
2507
+ async execute(params, ctx) {
2508
+ const creds = await requireConnection(ctx);
2509
+ const rows = await ctx.ads.query(creds, `SELECT campaign.advertising_channel_type FROM campaign WHERE campaign.id = ${params.campaign_id} LIMIT 1`);
2510
+ const channel = rows[0]?.campaign?.advertising_channel_type;
2511
+ if (channel === enums6.AdvertisingChannelType.PERFORMANCE_MAX) {
2512
+ throw new Error("Performance Max campaigns use asset groups, not ad groups, use the pmax tool (create_asset_group).");
2513
+ }
2514
+ const typeByChannel = {
2515
+ [enums6.AdvertisingChannelType.SEARCH]: enums6.AdGroupType.SEARCH_STANDARD,
2516
+ [enums6.AdvertisingChannelType.DISPLAY]: enums6.AdGroupType.DISPLAY_STANDARD,
2517
+ [enums6.AdvertisingChannelType.SHOPPING]: enums6.AdGroupType.SHOPPING_PRODUCT_ADS,
2518
+ [enums6.AdvertisingChannelType.VIDEO]: enums6.AdGroupType.VIDEO_RESPONSIVE
2519
+ };
2520
+ const resource = {
2521
+ name: params.name,
2522
+ campaign: ResourceNames4.campaign(creds.customerId, params.campaign_id),
2523
+ status: enums6.AdGroupStatus.ENABLED
2524
+ };
2525
+ if (channel != null && typeByChannel[channel] != null) resource.type = typeByChannel[channel];
2526
+ if (params.cpc_bid != null) resource.cpc_bid_micros = toMicros4(params.cpc_bid);
2527
+ const customer = ctx.ads.customer(creds);
2528
+ const ops = [{ entity: "ad_group", operation: "create", resource }];
2529
+ if (!params.confirmed) {
2530
+ await customer.mutateResources(ops, { validate_only: true });
2531
+ return {
2532
+ output: preview(`create ad group "${params.name}" in campaign ${params.campaign_id}`, { campaign_id: params.campaign_id, name: params.name, ...params.cpc_bid != null ? { cpc_bid: params.cpc_bid } : {} }),
2533
+ auditSummary: `Preview: create ad group "${params.name}"`,
2534
+ customerId: creds.customerId
2535
+ };
2536
+ }
2537
+ assertUserConfirmed(params);
2538
+ const result = await customer.mutateResources(ops);
2539
+ const id = createdId(result, "ad_group_result");
2540
+ return {
2541
+ output: `\u2705 Created ad group "${params.name}"${id ? ` (id ${id})` : ""} in campaign ${params.campaign_id}.`,
2542
+ auditSummary: `Created ad group "${params.name}"${id ? ` (${id})` : ""}`,
2543
+ customerId: creds.customerId
2544
+ };
2545
+ }
2546
+ };
2547
+
2548
+ // src/tools/handlers/adGroup/update.ts
2549
+ import { z as z20 } from "zod";
2550
+ import { toMicros as toMicros5 } from "google-ads-api";
2551
+ var schema17 = z20.object({
2552
+ ad_group_id: ID,
2553
+ name: z20.string().min(1).max(255).optional(),
2554
+ cpc_bid: z20.number().positive().optional().describe("Default max CPC in account currency (manual bidding only)"),
2555
+ ...confirmFields
2556
+ });
2557
+ var updateHandler2 = {
2558
+ action: "update",
2559
+ summary: "Edit an ad group's name and/or default max CPC. Requires ad_group_id and at least one of name, cpc_bid. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2560
+ readOnly: false,
2561
+ category: "mutate",
2562
+ schema: schema17,
2563
+ async execute(params, ctx) {
2564
+ if (params.name == null && params.cpc_bid == null) throw new Error("Provide name and/or cpc_bid to change.");
2565
+ const creds = await requireConnection(ctx);
2566
+ const customer = ctx.ads.customer(creds);
2567
+ const resource = { resource_name: `customers/${creds.customerId}/adGroups/${params.ad_group_id}` };
2568
+ if (params.name != null) resource.name = params.name;
2569
+ if (params.cpc_bid != null) resource.cpc_bid_micros = toMicros5(params.cpc_bid);
2570
+ const changes = [params.name != null ? `name\u2192${params.name}` : "", params.cpc_bid != null ? `bid\u2192${params.cpc_bid}` : ""].filter(Boolean).join(", ");
2571
+ const ops = [{ entity: "ad_group", operation: "update", resource }];
2572
+ if (!params.confirmed) {
2573
+ await customer.mutateResources(ops, { validate_only: true });
2574
+ return {
2575
+ output: preview(`update ad group ${params.ad_group_id} (${changes})`, {
2576
+ ad_group_id: params.ad_group_id,
2577
+ ...params.name != null ? { name: params.name } : {},
2578
+ ...params.cpc_bid != null ? { cpc_bid: params.cpc_bid } : {},
2579
+ confirmed: true
2580
+ }),
2581
+ auditSummary: `Preview: update ad group ${params.ad_group_id} (${changes})`,
2582
+ customerId: creds.customerId
2583
+ };
2584
+ }
2585
+ assertUserConfirmed(params);
2586
+ await customer.mutateResources(ops);
2587
+ return {
2588
+ output: `\u2705 Updated ad group ${params.ad_group_id} (${changes}).`,
2589
+ auditSummary: `Updated ad group ${params.ad_group_id} (${changes})`,
2590
+ customerId: creds.customerId
2591
+ };
2592
+ }
2593
+ };
2594
+
2595
+ // src/tools/handlers/adGroup/status.ts
2596
+ import { z as z21 } from "zod";
2597
+ import { enums as enums7 } from "google-ads-api";
2598
+ var schema18 = z21.object({
2599
+ ad_group_id: ID,
2600
+ status: z21.enum(["ENABLED", "PAUSED", "REMOVED"]),
2601
+ ...confirmFields
2602
+ });
2603
+ var statusHandler3 = {
2604
+ action: "status",
2605
+ summary: "Pause, resume, or remove an ad group. Requires ad_group_id + status (ENABLED|PAUSED|REMOVED). REMOVED deletes the ad group. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
2606
+ readOnly: false,
2607
+ category: "mutate",
2608
+ schema: schema18,
2609
+ async execute(params, ctx) {
2610
+ const creds = await requireConnection(ctx);
2611
+ const customer = ctx.ads.customer(creds);
2612
+ const rn = `customers/${creds.customerId}/adGroups/${params.ad_group_id}`;
2613
+ const isRemove = params.status === "REMOVED";
2614
+ const ops = isRemove ? [{ entity: "ad_group", operation: "remove", resource: rn }] : [{ entity: "ad_group", operation: "update", resource: { resource_name: rn, status: enums7.AdGroupStatus[params.status] } }];
2615
+ const identity = { ad_group_id: params.ad_group_id, status: params.status };
2616
+ if (!params.confirmed && (!isRemove || userConfirmationRequired())) {
2617
+ await customer.mutateResources(ops, { validate_only: true });
2618
+ return {
2619
+ output: isRemove ? previewRemove(`ad group ${params.ad_group_id} and all of its ads and keywords`, identity) : preview(`set ad group ${params.ad_group_id} to ${params.status}`, identity),
2620
+ auditSummary: `Preview: ${params.status} ad group ${params.ad_group_id}`,
2621
+ customerId: creds.customerId
2622
+ };
2623
+ }
2624
+ assertUserConfirmedIfClaimed(params);
2625
+ await customer.mutateResources(ops);
2626
+ return {
2627
+ output: isRemove ? `\u2705 Removed ad group ${params.ad_group_id}.` : `\u2705 Ad group ${params.ad_group_id} is now ${params.status}.`,
2628
+ auditSummary: `${params.status} ad group ${params.ad_group_id}`,
2629
+ customerId: creds.customerId
2630
+ };
2631
+ }
2632
+ };
2633
+
2634
+ // src/tools/handlers/adGroup/listAds.ts
2635
+ import { z as z22 } from "zod";
2636
+ import { enums as enums8 } from "google-ads-api";
2637
+ function adLine(r) {
2638
+ const a = r.ad_group_ad;
2639
+ const headline = a.ad?.responsive_search_ad?.headlines?.[0]?.text;
2640
+ const label = headline ? `"${headline}..."` : a.ad?.name || "(no name)";
2641
+ return `\u2022 ad ${a.ad?.id} ${label} | ${enumLabel(enums8.AdGroupAdStatus, a.status)} | type ${enumLabel(enums8.AdType, a.ad?.type)} | ag ${r.ad_group?.id}`;
2642
+ }
2643
+ var schema19 = z22.object({
2644
+ ad_group_id: ID,
2645
+ limit: z22.number().int().min(1).max(2e3).default(200),
2646
+ cursor: z22.string().optional()
2647
+ });
2648
+ var listAdsHandler = {
2649
+ action: "list_ads",
2650
+ summary: "List the ads in an ad group with their ad id and status. Use it to get an ad id for ad_status. Requires ad_group_id. Paginated via cursor.",
2651
+ readOnly: true,
2652
+ category: "report",
2653
+ schema: schema19,
2654
+ async execute(params, ctx) {
2655
+ if (params.cursor) return paginate({ ctx, cursor: params.cursor, title: "", rows: [], summary: "Ads (page)" });
2656
+ const creds = await requireConnection(ctx);
2657
+ const gaql = `SELECT ad_group.id, ad_group_ad.ad.id, ad_group_ad.ad.name, ad_group_ad.ad.type, ad_group_ad.status, ad_group_ad.ad.responsive_search_ad.headlines FROM ad_group_ad WHERE ad_group.id = ${params.ad_group_id} ORDER BY ad_group_ad.ad.id`;
2658
+ const { rows, total, truncated } = await ctx.ads.searchPage(creds, gaql, params.limit);
2659
+ if (!rows.length) return { output: "No ads found in this ad group.", auditSummary: "Listed ads (0)", customerId: creds.customerId };
2660
+ return paginate({
2661
+ ctx,
2662
+ title: `Ads (account ${formatCid(creds.customerId)}, ad group ${params.ad_group_id})${truncated ? ` - top ${params.limit}${total != null ? ` of ${int(total)}` : ""}` : ""}`,
2663
+ rows: rows.map(adLine),
2664
+ summary: `Listed ads (ad group ${params.ad_group_id})`,
2665
+ customerId: creds.customerId
2666
+ });
2667
+ }
2668
+ };
2669
+
2670
+ // src/tools/handlers/adGroup/addAd.ts
2671
+ import { z as z23 } from "zod";
2672
+ import { ResourceNames as ResourceNames9 } from "google-ads-api";
2673
+
2674
+ // src/tools/handlers/adGroup/ads/build.ts
2675
+ import { randomUUID as randomUUID3 } from "crypto";
2676
+ import { ResourceNames as ResourceNames5 } from "google-ads-api";
2677
+
2678
+ // src/lib/fetchImage.ts
2679
+ import http from "http";
2680
+ import https from "https";
2681
+ import dns from "dns";
2682
+ import net from "net";
2683
+ var IMAGE_MAX_BYTES = 5 * 1024 * 1024;
2684
+ var BUNDLE_MAX_BYTES = 2 * 1024 * 1024;
2685
+ var TIMEOUT_MS = 1e4;
2686
+ function expandIpv6(ip) {
2687
+ const [head, tail] = ip.split("::");
2688
+ const h = head ? head.split(":") : [];
2689
+ const t = tail !== void 0 ? tail ? tail.split(":") : [] : null;
2690
+ let groups;
2691
+ if (t === null) {
2692
+ if (h.length !== 8) return null;
2693
+ groups = h;
2694
+ } else {
2695
+ const missing = 8 - h.length - t.length;
2696
+ if (missing < 0) return null;
2697
+ groups = [...h, ...Array(missing).fill("0"), ...t];
2698
+ }
2699
+ return groups.map((g) => parseInt(g || "0", 16) & 65535);
2700
+ }
2701
+ function isPrivateIp(ip) {
2702
+ if (net.isIPv4(ip)) {
2703
+ const p2 = ip.split(".").map(Number);
2704
+ const a = p2[0];
2705
+ const b = p2[1];
2706
+ if (a === 0 || a === 10 || a === 127) return true;
2707
+ if (a === 169 && b === 254) return true;
2708
+ if (a === 172 && b >= 16 && b <= 31) return true;
2709
+ if (a === 192 && b === 168) return true;
2710
+ if (a === 100 && b >= 64 && b <= 127) return true;
2711
+ if (a >= 224) return true;
2712
+ return false;
2713
+ }
2714
+ const lower = ip.toLowerCase().replace(/%.*$/, "");
2715
+ if (lower.startsWith("::ffff:") && lower.includes(".")) return isPrivateIp(lower.slice("::ffff:".length));
2716
+ const g = expandIpv6(lower);
2717
+ if (!g) return lower === "::1" || lower === "::";
2718
+ if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 65535 || g[5] === 0)) {
2719
+ return isPrivateIp(`${g[6] >>> 8}.${g[6] & 255}.${g[7] >>> 8}.${g[7] & 255}`);
2720
+ }
2721
+ if ((g[0] & 65024) === 64512) return true;
2722
+ if ((g[0] & 65472) === 65152) return true;
2723
+ return false;
2724
+ }
2725
+ function guardedLookup(hostname, options, cb) {
2726
+ dns.lookup(hostname, { all: true, family: options.family, hints: options.hints }, (err, addrs) => {
2727
+ if (err) return cb(err, "", 0);
2728
+ for (const a of addrs) {
2729
+ if (isPrivateIp(a.address)) return cb(new Error(`Refusing to fetch a private/internal address (${hostname}).`), "", 0);
2730
+ }
2731
+ const first = addrs[0];
2732
+ if (!first) return cb(new Error(`Could not resolve ${hostname}`), "", 0);
2733
+ cb(null, first.address, first.family);
2734
+ });
2735
+ }
2736
+ function guardedFetch(rawUrl, spec) {
2737
+ return new Promise((resolve, reject) => {
2738
+ let url;
2739
+ try {
2740
+ url = new URL(rawUrl);
2741
+ } catch {
2742
+ return reject(new Error(`Invalid ${spec.what} URL: ${rawUrl}`));
2743
+ }
2744
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
2745
+ return reject(new Error(`${spec.what} URL must be http(s).`));
2746
+ }
2747
+ const host = url.hostname.replace(/^\[|\]$/g, "");
2748
+ if (net.isIP(host) && isPrivateIp(host)) {
2749
+ return reject(new Error(`Refusing to fetch a private/internal address (${host}).`));
2750
+ }
2751
+ const mod = url.protocol === "https:" ? https : http;
2752
+ const req = mod.get(url, { lookup: guardedLookup, autoSelectFamily: false, timeout: TIMEOUT_MS }, (res) => {
2753
+ const status = res.statusCode ?? 0;
2754
+ if (status >= 300 && status < 400) {
2755
+ res.destroy();
2756
+ return reject(new Error(`Refusing redirect (${status}), provide a direct ${spec.what} URL.`));
2757
+ }
2758
+ if (status !== 200) {
2759
+ res.destroy();
2760
+ return reject(new Error(`${spec.what} fetch failed (${status}) for ${url.hostname}, the host may block automated fetches (e.g. Wikimedia returns 403). Use a directly reachable image URL like https://dummyimage.com/600x314/000/fff.png.`));
2761
+ }
2762
+ const ct = String(res.headers["content-type"] ?? "");
2763
+ if (!spec.accept(ct)) {
2764
+ res.destroy();
2765
+ return reject(new Error(`URL did not return a ${spec.what} (content-type: ${ct || "unknown"}).`));
2766
+ }
2767
+ const declared = Number(res.headers["content-length"] ?? 0);
2768
+ if (declared > spec.maxBytes) {
2769
+ res.destroy();
2770
+ return reject(new Error(`${spec.what} too large (${declared} bytes, max ${spec.maxBytes}).`));
2771
+ }
2772
+ const chunks = [];
2773
+ let total = 0;
2774
+ res.on("data", (chunk) => {
2775
+ total += chunk.length;
2776
+ if (total > spec.maxBytes) {
2777
+ res.destroy();
2778
+ reject(new Error(`${spec.what} exceeds ${spec.maxBytes} bytes.`));
2779
+ return;
2780
+ }
2781
+ chunks.push(chunk);
2782
+ });
2783
+ res.on("end", () => total === 0 ? reject(new Error(`${spec.what} was empty.`)) : resolve(Buffer.concat(chunks)));
2784
+ res.on("error", reject);
2785
+ });
2786
+ req.on("timeout", () => req.destroy(new Error(`${spec.what} fetch timed out.`)));
2787
+ req.on(
2788
+ "error",
2789
+ (e) => reject(new Error(`Could not fetch the ${spec.what} from ${url.hostname}: ${e.message}. The host may be down or blocking, use a direct, publicly reachable image URL (no redirects), e.g. https://dummyimage.com/600x314/000/fff.png.`))
2790
+ );
2791
+ });
2792
+ }
2793
+ function fetchImageBytes(rawUrl) {
2794
+ return guardedFetch(rawUrl, { what: "image", accept: (ct) => ct.startsWith("image/"), maxBytes: IMAGE_MAX_BYTES });
2795
+ }
2796
+ function fetchMediaBundleBytes(rawUrl) {
2797
+ return guardedFetch(rawUrl, {
2798
+ what: "media bundle",
2799
+ accept: (ct) => ct.includes("zip") || ct === "application/octet-stream",
2800
+ maxBytes: BUNDLE_MAX_BYTES
2801
+ });
2802
+ }
2803
+
2804
+ // src/tools/handlers/adGroup/ads/build.ts
2805
+ function makeTemp() {
2806
+ let t = -1;
2807
+ return () => String(t--);
2808
+ }
2809
+ async function imageAssets(cid, inputs, nextTemp) {
2810
+ const ops = [];
2811
+ const refs = [];
2812
+ for (const item of inputs) {
2813
+ if (/^\d+$/.test(item)) {
2814
+ refs.push({ asset: ResourceNames5.asset(cid, item) });
2815
+ } else {
2816
+ const data = await fetchImageBytes(item);
2817
+ const aRN = ResourceNames5.asset(cid, nextTemp());
2818
+ ops.push({ entity: "asset", operation: "create", resource: { resource_name: aRN, name: `image-${randomUUID3()}`, image_asset: { data } } });
2819
+ refs.push({ asset: aRN });
2820
+ }
2821
+ }
2822
+ return { ops, refs };
2823
+ }
2824
+
2825
+ // src/tools/handlers/adGroup/ads/responsiveSearch.ts
2826
+ import { enums as enums9 } from "google-ads-api";
2827
+ function buildResponsiveSearchAd(p2, adGroupRN) {
2828
+ if (!p2.headlines || p2.headlines.length < 3) throw new Error("A Responsive Search Ad needs 3-15 headlines (<=30 chars each) in `headlines`.");
2829
+ if (!p2.descriptions || p2.descriptions.length < 2) throw new Error("A Responsive Search Ad needs 2-4 descriptions (<=90 chars each) in `descriptions`.");
2830
+ const rsa = {
2831
+ headlines: p2.headlines.map((text) => ({ text })),
2832
+ descriptions: p2.descriptions.map((text) => ({ text }))
2833
+ };
2834
+ if (p2.path1) rsa.path1 = p2.path1;
2835
+ if (p2.path2) rsa.path2 = p2.path2;
2836
+ return {
2837
+ ops: [
2838
+ {
2839
+ entity: "ad_group_ad",
2840
+ operation: "create",
2841
+ resource: { ad_group: adGroupRN, status: enums9.AdGroupAdStatus.ENABLED, ad: { final_urls: [p2.final_url], responsive_search_ad: rsa } }
2842
+ }
2843
+ ],
2844
+ summary: `responsive search ad (${p2.headlines.length} headlines, ${p2.descriptions.length} descriptions)`
2845
+ };
2846
+ }
2847
+
2848
+ // src/tools/handlers/adGroup/ads/responsiveDisplay.ts
2849
+ import { enums as enums10 } from "google-ads-api";
2850
+ async function buildResponsiveDisplayAd(p2, cid, adGroupRN, nextTemp) {
2851
+ if (!p2.marketing_images?.length) throw new Error("A Responsive Display Ad needs at least one 1.91:1 image in `marketing_images` (URL or asset id).");
2852
+ if (!p2.square_marketing_images?.length) throw new Error("A Responsive Display Ad needs at least one 1:1 image in `square_marketing_images` (URL or asset id).");
2853
+ if (!p2.headlines?.length) throw new Error("A Responsive Display Ad needs at least one short headline (<=30 chars) in `headlines`.");
2854
+ if (!p2.long_headline) throw new Error("A Responsive Display Ad needs a `long_headline` (<=90 chars).");
2855
+ if (!p2.descriptions?.length) throw new Error("A Responsive Display Ad needs at least one description (<=90 chars) in `descriptions`.");
2856
+ if (!p2.business_name) throw new Error("A Responsive Display Ad needs a `business_name` (<=25 chars).");
2857
+ const marketing = await imageAssets(cid, p2.marketing_images, nextTemp);
2858
+ const square = await imageAssets(cid, p2.square_marketing_images, nextTemp);
2859
+ const logos = p2.logo_images?.length ? await imageAssets(cid, p2.logo_images, nextTemp) : { ops: [], refs: [] };
2860
+ const rda = {
2861
+ marketing_images: marketing.refs,
2862
+ square_marketing_images: square.refs,
2863
+ headlines: p2.headlines.map((text) => ({ text })),
2864
+ long_headline: { text: p2.long_headline },
2865
+ descriptions: p2.descriptions.map((text) => ({ text })),
2866
+ business_name: p2.business_name
2867
+ };
2868
+ if (logos.refs.length) rda.logo_images = logos.refs;
2869
+ return {
2870
+ ops: [
2871
+ ...marketing.ops,
2872
+ ...square.ops,
2873
+ ...logos.ops,
2874
+ {
2875
+ entity: "ad_group_ad",
2876
+ operation: "create",
2877
+ resource: { ad_group: adGroupRN, status: enums10.AdGroupAdStatus.ENABLED, ad: { final_urls: [p2.final_url], responsive_display_ad: rda } }
2878
+ }
2879
+ ],
2880
+ summary: `responsive display ad "${p2.business_name}"`
2881
+ };
2882
+ }
2883
+
2884
+ // src/tools/handlers/adGroup/ads/html5Upload.ts
2885
+ import { enums as enums11, ResourceNames as ResourceNames6 } from "google-ads-api";
2886
+ async function buildHtml5UploadAd(p2, cid, adGroupRN, nextTemp) {
2887
+ if (!p2.media_bundle) throw new Error("An HTML5 upload ad needs `media_bundle`: a .zip URL or an existing media-bundle asset id.");
2888
+ const ops = [];
2889
+ let bundleRef;
2890
+ if (/^\d+$/.test(p2.media_bundle)) {
2891
+ bundleRef = { asset: ResourceNames6.asset(cid, p2.media_bundle) };
2892
+ } else {
2893
+ const data = await fetchMediaBundleBytes(p2.media_bundle);
2894
+ const aRN = ResourceNames6.asset(cid, nextTemp());
2895
+ ops.push({ entity: "asset", operation: "create", resource: { resource_name: aRN, media_bundle_asset: { data } } });
2896
+ bundleRef = { asset: aRN };
2897
+ }
2898
+ ops.push({
2899
+ entity: "ad_group_ad",
2900
+ operation: "create",
2901
+ resource: {
2902
+ ad_group: adGroupRN,
2903
+ status: enums11.AdGroupAdStatus.ENABLED,
2904
+ ad: { final_urls: [p2.final_url], display_upload_ad: { display_upload_product_type: enums11.DisplayUploadProductType.HTML5_UPLOAD_AD, media_bundle: bundleRef } }
2905
+ }
2906
+ });
2907
+ return { ops, summary: "HTML5 upload ad" };
2908
+ }
2909
+
2910
+ // src/tools/handlers/adGroup/ads/demandGenMultiAsset.ts
2911
+ import { enums as enums12 } from "google-ads-api";
2912
+ async function buildDemandGenMultiAssetAd(p2, cid, adGroupRN, nextTemp) {
2913
+ if (!p2.square_marketing_images?.length && !p2.marketing_images?.length) {
2914
+ throw new Error("A Demand Gen ad needs at least one image in `square_marketing_images` (1:1) or `marketing_images` (1.91:1), as URLs or asset ids.");
2915
+ }
2916
+ if (!p2.logo_images?.length) throw new Error("A Demand Gen ad needs at least one 1:1 logo in `logo_images` (URL or asset id).");
2917
+ if (!p2.headlines?.length) throw new Error("A Demand Gen ad needs at least one headline in `headlines`.");
2918
+ if (!p2.descriptions?.length) throw new Error("A Demand Gen ad needs at least one description in `descriptions`.");
2919
+ if (!p2.business_name) throw new Error("A Demand Gen ad needs a `business_name`.");
2920
+ const market = p2.marketing_images?.length ? await imageAssets(cid, p2.marketing_images, nextTemp) : { ops: [], refs: [] };
2921
+ const square = p2.square_marketing_images?.length ? await imageAssets(cid, p2.square_marketing_images, nextTemp) : { ops: [], refs: [] };
2922
+ const portrait = p2.portrait_marketing_images?.length ? await imageAssets(cid, p2.portrait_marketing_images, nextTemp) : { ops: [], refs: [] };
2923
+ const tall = p2.tall_portrait_marketing_images?.length ? await imageAssets(cid, p2.tall_portrait_marketing_images, nextTemp) : { ops: [], refs: [] };
2924
+ const logos = await imageAssets(cid, p2.logo_images, nextTemp);
2925
+ const ad = {
2926
+ headlines: p2.headlines.map((text) => ({ text })),
2927
+ descriptions: p2.descriptions.map((text) => ({ text })),
2928
+ business_name: p2.business_name,
2929
+ logo_images: logos.refs
2930
+ };
2931
+ if (market.refs.length) ad.marketing_images = market.refs;
2932
+ if (square.refs.length) ad.square_marketing_images = square.refs;
2933
+ if (portrait.refs.length) ad.portrait_marketing_images = portrait.refs;
2934
+ if (tall.refs.length) ad.tall_portrait_marketing_images = tall.refs;
2935
+ if (p2.call_to_action_text) ad.call_to_action_text = p2.call_to_action_text;
2936
+ return {
2937
+ ops: [
2938
+ ...market.ops,
2939
+ ...square.ops,
2940
+ ...portrait.ops,
2941
+ ...tall.ops,
2942
+ ...logos.ops,
2943
+ {
2944
+ entity: "ad_group_ad",
2945
+ operation: "create",
2946
+ resource: { ad_group: adGroupRN, status: enums12.AdGroupAdStatus.ENABLED, ad: { final_urls: [p2.final_url], demand_gen_multi_asset_ad: ad } }
2947
+ }
2948
+ ],
2949
+ summary: `Demand Gen ad "${p2.business_name}"`
2950
+ };
2951
+ }
2952
+
2953
+ // src/tools/handlers/adGroup/ads/videoResponsive.ts
2954
+ import { enums as enums13, ResourceNames as ResourceNames7 } from "google-ads-api";
2955
+ async function buildVideoResponsiveAd(p2, cid, adGroupRN, nextTemp) {
2956
+ if (!p2.youtube_video_ids?.length) throw new Error("A responsive video ad needs at least one YouTube video in `youtube_video_ids` (id or URL).");
2957
+ if (!p2.headlines?.length) throw new Error("A responsive video ad needs at least one headline in `headlines`.");
2958
+ if (!p2.long_headlines?.length) throw new Error("A responsive video ad needs at least one long headline in `long_headlines`.");
2959
+ if (!p2.descriptions?.length) throw new Error("A responsive video ad needs at least one description in `descriptions`.");
2960
+ if (!p2.business_name) throw new Error("A responsive video ad needs a `business_name`.");
2961
+ const ops = [];
2962
+ const videoRefs = [];
2963
+ for (const v of p2.youtube_video_ids) {
2964
+ const aRN = ResourceNames7.asset(cid, nextTemp());
2965
+ ops.push({ entity: "asset", operation: "create", resource: { resource_name: aRN, youtube_video_asset: { youtube_video_id: youtubeVideoId(v) } } });
2966
+ videoRefs.push({ asset: aRN });
2967
+ }
2968
+ const logos = p2.logo_images?.length ? await imageAssets(cid, p2.logo_images, nextTemp) : { ops: [], refs: [] };
2969
+ ops.push(...logos.ops);
2970
+ const text = (s) => ({ text: s });
2971
+ const ad = {
2972
+ videos: videoRefs,
2973
+ headlines: p2.headlines.map(text),
2974
+ long_headlines: p2.long_headlines.map(text),
2975
+ descriptions: p2.descriptions.map(text),
2976
+ business_name: text(p2.business_name)
2977
+ };
2978
+ if (p2.call_to_actions?.length) ad.call_to_actions = p2.call_to_actions.map(text);
2979
+ if (logos.refs.length) ad.logo_images = logos.refs;
2980
+ if (p2.breadcrumb1) ad.breadcrumb1 = p2.breadcrumb1;
2981
+ if (p2.breadcrumb2) ad.breadcrumb2 = p2.breadcrumb2;
2982
+ ops.push({
2983
+ entity: "ad_group_ad",
2984
+ operation: "create",
2985
+ resource: { ad_group: adGroupRN, status: enums13.AdGroupAdStatus.ENABLED, ad: { final_urls: [p2.final_url], video_responsive_ad: ad } }
2986
+ });
2987
+ return { ops, summary: `responsive video ad "${p2.business_name}"` };
2988
+ }
2989
+
2990
+ // src/tools/handlers/adGroup/ads/appAd.ts
2991
+ import { enums as enums14, ResourceNames as ResourceNames8 } from "google-ads-api";
2992
+ async function buildAppAd(p2, cid, adGroupRN, nextTemp) {
2993
+ if (!p2.headlines?.length) throw new Error("An app ad needs at least one headline in `headlines`.");
2994
+ if (!p2.descriptions?.length) throw new Error("An app ad needs at least one description in `descriptions`.");
2995
+ const ops = [];
2996
+ const text = (s) => ({ text: s });
2997
+ const ad = { headlines: p2.headlines.map(text), descriptions: p2.descriptions.map(text) };
2998
+ const imageInputs = [...p2.marketing_images ?? [], ...p2.square_marketing_images ?? []];
2999
+ if (imageInputs.length) {
3000
+ const imgs = await imageAssets(cid, imageInputs, nextTemp);
3001
+ ops.push(...imgs.ops);
3002
+ ad.images = imgs.refs;
3003
+ }
3004
+ if (p2.youtube_video_ids?.length) {
3005
+ const videos = [];
3006
+ for (const v of p2.youtube_video_ids) {
3007
+ const aRN = ResourceNames8.asset(cid, nextTemp());
3008
+ ops.push({ entity: "asset", operation: "create", resource: { resource_name: aRN, youtube_video_asset: { youtube_video_id: youtubeVideoId(v) } } });
3009
+ videos.push({ asset: aRN });
3010
+ }
3011
+ ad.youtube_videos = videos;
3012
+ }
3013
+ ops.push({ entity: "ad_group_ad", operation: "create", resource: { ad_group: adGroupRN, status: enums14.AdGroupAdStatus.ENABLED, ad: { app_ad: ad } } });
3014
+ return { ops, summary: "app ad" };
3015
+ }
3016
+
3017
+ // src/tools/handlers/adGroup/ads/shoppingProduct.ts
3018
+ import { enums as enums15 } from "google-ads-api";
3019
+ function buildShoppingProductAd(_p, adGroupRN) {
3020
+ return {
3021
+ ops: [
3022
+ {
3023
+ entity: "ad_group_ad",
3024
+ operation: "create",
3025
+ resource: { ad_group: adGroupRN, status: enums15.AdGroupAdStatus.ENABLED, ad: { shopping_product_ad: {} } }
3026
+ }
3027
+ ],
3028
+ summary: "shopping product ad (creative comes from the Merchant Center feed)"
3029
+ };
3030
+ }
3031
+
3032
+ // src/tools/handlers/adGroup/addAd.ts
3033
+ var AD_TYPES = ["RESPONSIVE_SEARCH_AD", "RESPONSIVE_DISPLAY_AD", "HTML5_UPLOAD", "DEMAND_GEN_MULTI_ASSET", "VIDEO_RESPONSIVE", "APP", "SHOPPING_PRODUCT"];
3034
+ var NEEDS_FINAL_URL = /* @__PURE__ */ new Set(["RESPONSIVE_SEARCH_AD", "RESPONSIVE_DISPLAY_AD", "HTML5_UPLOAD", "DEMAND_GEN_MULTI_ASSET", "VIDEO_RESPONSIVE"]);
3035
+ var schema20 = z23.object({
3036
+ ad_group_id: ID,
3037
+ // Each field is tagged with the ad types it applies to; extra fields are ignored.
3038
+ ad_type: z23.enum(AD_TYPES).describe(
3039
+ "Which ad to create; MUST match the ad group campaign channel. RESPONSIVE_SEARCH_AD=Search, RESPONSIVE_DISPLAY_AD=Display, HTML5_UPLOAD=Display (.zip), DEMAND_GEN_MULTI_ASSET=Demand Gen, VIDEO_RESPONSIVE=Video, APP=App, SHOPPING_PRODUCT=Shopping. Send only the fields tagged for your type."
3040
+ ),
3041
+ final_url: z23.string().url().optional().describe("Landing page URL. Required for every ad type except App and Shopping."),
3042
+ headlines: z23.array(z23.string().min(1).max(30)).max(15).optional().describe("Headlines, <=30 chars. Search: 3-15; Display/Demand Gen/Video/App: 1-5."),
3043
+ long_headlines: z23.array(z23.string().min(1).max(90)).max(5).optional().describe("Video: long headlines, <=90 chars."),
3044
+ descriptions: z23.array(z23.string().min(1).max(90)).max(5).optional().describe("Descriptions, <=90 chars. Search: 2-4; Display/Demand Gen/Video/App: 1-5."),
3045
+ call_to_actions: z23.array(z23.string().min(1).max(30)).max(5).optional().describe('Video: CTA labels, e.g. ["Subscribe"].'),
3046
+ youtube_video_ids: z23.array(z23.string()).max(5).optional().describe("Video (required), App (optional): YouTube video ids or URLs (referenced, not uploaded)."),
3047
+ breadcrumb1: z23.string().max(30).optional().describe("Video: breadcrumb 1."),
3048
+ breadcrumb2: z23.string().max(30).optional().describe("Video: breadcrumb 2."),
3049
+ path1: z23.string().max(15).optional().describe("Search: display-URL path 1."),
3050
+ path2: z23.string().max(15).optional().describe("Search: display-URL path 2."),
3051
+ long_headline: z23.string().min(1).max(90).optional().describe("Display: one long headline, <=90 chars."),
3052
+ business_name: z23.string().min(1).max(25).optional().describe("Display/Demand Gen/Video: <=25 chars."),
3053
+ marketing_images: z23.array(z23.string()).max(20).optional().describe("Display/Demand Gen/App: 1.91:1 images (URL or asset id)."),
3054
+ square_marketing_images: z23.array(z23.string()).max(20).optional().describe("Display/Demand Gen/App: 1:1 images (URL or asset id)."),
3055
+ portrait_marketing_images: z23.array(z23.string()).max(20).optional().describe("Demand Gen: 4:5 images (URL or asset id)."),
3056
+ tall_portrait_marketing_images: z23.array(z23.string()).max(20).optional().describe("Demand Gen: 9:16 images (URL or asset id)."),
3057
+ logo_images: z23.array(z23.string()).max(20).optional().describe("Demand Gen (required); Display/Video (optional): 1:1 logos (URL or asset id)."),
3058
+ call_to_action_text: z23.string().max(30).optional().describe('Demand Gen: CTA label, e.g. "Shop now".'),
3059
+ media_bundle: z23.string().optional().describe("HTML5: .zip URL or media-bundle asset id."),
3060
+ ...confirmFields
3061
+ });
3062
+ function dispatch(params, cid, adGroupRN) {
3063
+ const nextTemp = makeTemp();
3064
+ switch (params.ad_type) {
3065
+ case "RESPONSIVE_SEARCH_AD":
3066
+ return buildResponsiveSearchAd(params, adGroupRN);
3067
+ case "RESPONSIVE_DISPLAY_AD":
3068
+ return buildResponsiveDisplayAd(params, cid, adGroupRN, nextTemp);
3069
+ case "HTML5_UPLOAD":
3070
+ return buildHtml5UploadAd(params, cid, adGroupRN, nextTemp);
3071
+ case "DEMAND_GEN_MULTI_ASSET":
3072
+ return buildDemandGenMultiAssetAd(params, cid, adGroupRN, nextTemp);
3073
+ case "VIDEO_RESPONSIVE":
3074
+ return buildVideoResponsiveAd(params, cid, adGroupRN, nextTemp);
3075
+ case "APP":
3076
+ return buildAppAd(params, cid, adGroupRN, nextTemp);
3077
+ case "SHOPPING_PRODUCT":
3078
+ return buildShoppingProductAd(params, adGroupRN);
3079
+ default:
3080
+ throw new Error(`Unsupported ad_type: ${String(params.ad_type)}`);
3081
+ }
3082
+ }
3083
+ var addAdHandler = {
3084
+ action: "add_ad",
3085
+ summary: "Create an ad in an ad group. Pick ad_type: RESPONSIVE_SEARCH_AD (Search), RESPONSIVE_DISPLAY_AD or HTML5_UPLOAD (Display). It must match the ad group campaign channel. Images and HTML5 bundles are passed as http(s) URLs (downloaded, SSRF-guarded) or existing asset ids; the model never sends bytes. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3086
+ readOnly: false,
3087
+ category: "mutate",
3088
+ schema: schema20,
3089
+ async execute(params, ctx) {
3090
+ const creds = await requireConnection(ctx);
3091
+ const cid = creds.customerId;
3092
+ const adGroupRN = ResourceNames9.adGroup(cid, params.ad_group_id);
3093
+ if (NEEDS_FINAL_URL.has(params.ad_type) && !params.final_url) {
3094
+ throw new Error(`final_url is required for ad_type ${params.ad_type}.`);
3095
+ }
3096
+ const build = await dispatch(params, cid, adGroupRN);
3097
+ const customer = ctx.ads.customer(creds);
3098
+ const { confirmed, user_confirmation, ...echo } = params;
3099
+ if (!confirmed) {
3100
+ await customer.mutateResources(build.ops, { validate_only: true });
3101
+ return {
3102
+ output: preview(`create a ${build.summary} in ad group ${params.ad_group_id}`, { ...echo, confirmed: true }),
3103
+ auditSummary: `Preview: create ${build.summary} in ad group ${params.ad_group_id}`,
3104
+ customerId: cid
3105
+ };
3106
+ }
3107
+ assertUserConfirmed({ confirmed, user_confirmation });
3108
+ const result = await customer.mutateResources(build.ops);
3109
+ const id = createdId(result, "ad_group_ad_result");
3110
+ return {
3111
+ output: `\u2705 Created ${build.summary}${id ? ` (id ${id})` : ""} in ad group ${params.ad_group_id}.`,
3112
+ auditSummary: `Created ${build.summary} in ad group ${params.ad_group_id}`,
3113
+ customerId: cid
3114
+ };
3115
+ }
3116
+ };
3117
+
3118
+ // src/tools/handlers/adGroup/adStatus.ts
3119
+ import { z as z24 } from "zod";
3120
+ import { enums as enums16 } from "google-ads-api";
3121
+ var schema21 = z24.object({
3122
+ ad_group_id: ID,
3123
+ ad_id: ID,
3124
+ status: z24.enum(["ENABLED", "PAUSED", "REMOVED"]),
3125
+ ...confirmFields
3126
+ });
3127
+ var adStatusHandler = {
3128
+ action: "ad_status",
3129
+ summary: "Pause, resume, or remove an ad. Requires ad_group_id + ad_id (from list_ads) + status (ENABLED|PAUSED|REMOVED). REMOVED deletes the ad. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3130
+ readOnly: false,
3131
+ category: "mutate",
3132
+ schema: schema21,
3133
+ async execute(params, ctx) {
3134
+ const creds = await requireConnection(ctx);
3135
+ const customer = ctx.ads.customer(creds);
3136
+ const rn = `customers/${creds.customerId}/adGroupAds/${params.ad_group_id}~${params.ad_id}`;
3137
+ const isRemove = params.status === "REMOVED";
3138
+ const ops = isRemove ? [{ entity: "ad_group_ad", operation: "remove", resource: rn }] : [{ entity: "ad_group_ad", operation: "update", resource: { resource_name: rn, status: enums16.AdGroupAdStatus[params.status] } }];
3139
+ const identity = { ad_group_id: params.ad_group_id, ad_id: params.ad_id, status: params.status };
3140
+ if (!params.confirmed && (!isRemove || userConfirmationRequired())) {
3141
+ await customer.mutateResources(ops, { validate_only: true });
3142
+ return {
3143
+ output: isRemove ? previewRemove(`ad ${params.ad_id}`, identity) : preview(`set ad ${params.ad_id} to ${params.status}`, identity),
3144
+ auditSummary: `Preview: ${params.status} ad ${params.ad_id}`,
3145
+ customerId: creds.customerId
3146
+ };
3147
+ }
3148
+ assertUserConfirmedIfClaimed(params);
3149
+ await customer.mutateResources(ops);
3150
+ return {
3151
+ output: isRemove ? `\u2705 Removed ad ${params.ad_id}.` : `\u2705 Ad ${params.ad_id} is now ${params.status}.`,
3152
+ auditSummary: `${params.status} ad ${params.ad_id}`,
3153
+ customerId: creds.customerId
3154
+ };
3155
+ }
3156
+ };
3157
+
3158
+ // src/tools/orchestrators/adGroup.ts
3159
+ var adGroupTool = defineOrchestrator({
3160
+ name: "google_ads_ad_group",
3161
+ purpose: "Create and manage ad groups and the ads inside them. create makes an ad group, update edits it, and status pauses, resumes, or removes it. list_ads lists the ads in a group. add_ad adds an ad (responsive search, display, HTML5, Demand Gen, video, app, or shopping). ad_status pauses, resumes, or removes a single ad.",
3162
+ limitations: "Ad groups exist for Search, Display, Demand Gen, Video, and Shopping campaigns. Performance Max has no ad groups (use pmax asset groups instead). App campaigns create their one ad group automatically, so do not call create for App; find it with list_ads instead. add_ad needs an ad_type that matches the ad group campaign channel: RESPONSIVE_SEARCH_AD for Search, RESPONSIVE_DISPLAY_AD or HTML5_UPLOAD for Display, DEMAND_GEN_MULTI_ASSET for Demand Gen, VIDEO_RESPONSIVE for Video, APP for App, SHOPPING_PRODUCT for Shopping (no creative needed there, products come from Merchant Center). Every write previews first; then the user must type yes or ok in chat before confirmed:true + user_confirmation. Get campaign_id and ad_group_id from the report or campaign tools, and get an ad_id from list_ads. Ad text cannot be edited in place, so remove the ad and add a new one instead.",
3163
+ examples: [
3164
+ '{"action":"create","campaign_id":"123","name":"Locksmith - Exact","confirmed":true,"user_confirmation":"yes"}',
3165
+ '{"action":"update","ad_group_id":"456","cpc_bid":1.5,"confirmed":true,"user_confirmation":"yes"}',
3166
+ '{"action":"status","ad_group_id":"456","status":"PAUSED","confirmed":true,"user_confirmation":"yes"}',
3167
+ '{"action":"list_ads","ad_group_id":"456"}',
3168
+ '{"action":"add_ad","ad_group_id":"456","ad_type":"RESPONSIVE_SEARCH_AD","final_url":"https://example.com","headlines":["A","B","C"],"descriptions":["One","Two"],"confirmed":true,"user_confirmation":"yes"}',
3169
+ '{"action":"add_ad","ad_group_id":"456","ad_type":"RESPONSIVE_DISPLAY_AD","final_url":"https://example.com","headlines":["Big sale"],"long_headline":"Our biggest sale of the year","descriptions":["Shop the deals"],"business_name":"Acme","marketing_images":["https://cdn.example.com/wide.jpg"],"square_marketing_images":["https://cdn.example.com/square.jpg"],"confirmed":true,"user_confirmation":"yes"}',
3170
+ '{"action":"ad_status","ad_group_id":"456","ad_id":"789","status":"PAUSED","confirmed":true,"user_confirmation":"yes"}'
3171
+ ],
3172
+ handlers: [createHandler2, updateHandler2, statusHandler3, listAdsHandler, addAdHandler, adStatusHandler]
3173
+ });
3174
+
3175
+ // src/tools/handlers/keyword/list.ts
3176
+ import { z as z25 } from "zod";
3177
+ import { enums as enums17 } from "google-ads-api";
3178
+ function kwLine(r) {
3179
+ const c = r.ad_group_criterion;
3180
+ const bid = c.cpc_bid_micros != null ? (Number(c.cpc_bid_micros) / 1e6).toFixed(2) : "-";
3181
+ const qs = c.quality_info?.quality_score != null ? String(c.quality_info.quality_score) : "-";
3182
+ const neg = c.negative ? " [NEG]" : "";
3183
+ return `\u2022 "${c.keyword.text}" [${enumLabel(enums17.KeywordMatchType, c.keyword.match_type)}]${neg} crit=${c.criterion_id} ag=${r.ad_group.id} | ${enumLabel(enums17.AdGroupCriterionStatus, c.status)} | bid ${bid} | QS ${qs}`;
3184
+ }
3185
+ var schema22 = z25.object({
3186
+ ad_group_id: ID.optional(),
3187
+ include_negative: z25.boolean().default(false).describe("Include negative (excluded) keywords"),
3188
+ limit: z25.number().int().min(1).max(2e3).default(200),
3189
+ cursor: z25.string().optional()
3190
+ });
3191
+ var listHandler2 = {
3192
+ action: "list",
3193
+ summary: "List keywords with criterion_id, match type, status, max CPC, and Quality Score. Use it to get criterion_id for update. Optionally scope to one ad_group_id. Paginated via cursor.",
3194
+ readOnly: true,
3195
+ category: "report",
3196
+ schema: schema22,
3197
+ async execute(params, ctx) {
3198
+ if (params.cursor) return paginate({ ctx, cursor: params.cursor, title: "", rows: [], summary: "Keywords (page)" });
3199
+ const creds = await requireConnection(ctx);
3200
+ const where = ["ad_group_criterion.type = KEYWORD"];
3201
+ if (params.ad_group_id) where.push(`ad_group.id = ${params.ad_group_id}`);
3202
+ const gaql = `SELECT ad_group.id, ad_group.name, ad_group_criterion.criterion_id, ad_group_criterion.keyword.text, ad_group_criterion.keyword.match_type, ad_group_criterion.status, ad_group_criterion.negative, ad_group_criterion.cpc_bid_micros, ad_group_criterion.quality_info.quality_score FROM ad_group_criterion WHERE ${where.join(" AND ")} ORDER BY ad_group_criterion.keyword.text`;
3203
+ const { rows, total, truncated } = await ctx.ads.searchPage(creds, gaql, params.limit);
3204
+ const kept = params.include_negative ? rows : rows.filter((r) => !r.ad_group_criterion?.negative);
3205
+ if (!kept.length) {
3206
+ return { output: "No keywords found.", auditSummary: "Listed keywords (0)", customerId: creds.customerId };
3207
+ }
3208
+ const title = `Keywords (account ${formatCid(creds.customerId)}${params.ad_group_id ? `, ad group ${params.ad_group_id}` : ""})` + (truncated ? ` - top ${params.limit}${total != null ? ` of ${int(total)}` : ""}` : "");
3209
+ return paginate({
3210
+ ctx,
3211
+ title,
3212
+ rows: kept.map(kwLine),
3213
+ summary: `Listed keywords${params.ad_group_id ? ` (ad group ${params.ad_group_id})` : ""}`,
3214
+ customerId: creds.customerId
3215
+ });
3216
+ }
3217
+ };
3218
+
3219
+ // src/tools/handlers/keyword/add.ts
3220
+ import { z as z26 } from "zod";
3221
+ import { enums as enums18, toMicros as toMicros6, ResourceNames as ResourceNames10 } from "google-ads-api";
3222
+ var schema23 = z26.object({
3223
+ ad_group_id: ID,
3224
+ keywords: z26.array(z26.string().min(1).max(80)).min(1).max(100),
3225
+ match_type: z26.enum(["BROAD", "PHRASE", "EXACT"]),
3226
+ negative: z26.boolean().default(false).describe("true = exclude these terms as negative keywords"),
3227
+ cpc_bid: z26.number().positive().optional().describe("Max CPC for positive keywords (manual bidding only)"),
3228
+ ...confirmFields
3229
+ });
3230
+ var addHandler = {
3231
+ action: "add",
3232
+ summary: "Add keywords to an ad group: positive (to target) or negative (set negative:true to exclude). Requires ad_group_id, keywords[], match_type. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3233
+ readOnly: false,
3234
+ category: "mutate",
3235
+ schema: schema23,
3236
+ async execute(params, ctx) {
3237
+ const creds = await requireConnection(ctx);
3238
+ const adGroupRN = ResourceNames10.adGroup(creds.customerId, params.ad_group_id);
3239
+ const ops = params.keywords.map((text) => {
3240
+ const resource = {
3241
+ ad_group: adGroupRN,
3242
+ status: enums18.AdGroupCriterionStatus.ENABLED,
3243
+ negative: params.negative,
3244
+ keyword: { text, match_type: enums18.KeywordMatchType[params.match_type] }
3245
+ };
3246
+ if (!params.negative && params.cpc_bid != null) resource.cpc_bid_micros = toMicros6(params.cpc_bid);
3247
+ return { entity: "ad_group_criterion", operation: "create", resource };
3248
+ });
3249
+ const verb = params.negative ? "exclude" : "add";
3250
+ const customer = ctx.ads.customer(creds);
3251
+ if (!params.confirmed) {
3252
+ await customer.mutateResources(ops, { validate_only: true });
3253
+ return {
3254
+ output: preview(`${verb} ${params.keywords.length} ${params.match_type} keyword(s) ${params.negative ? "as negatives " : ""}in ad group ${params.ad_group_id}`, {
3255
+ ad_group_id: params.ad_group_id,
3256
+ keywords: params.keywords,
3257
+ match_type: params.match_type,
3258
+ negative: params.negative,
3259
+ confirmed: true
3260
+ }),
3261
+ auditSummary: `Preview: ${verb} ${params.keywords.length} keyword(s) in ad group ${params.ad_group_id}`,
3262
+ customerId: creds.customerId
3263
+ };
3264
+ }
3265
+ assertUserConfirmed(params);
3266
+ await customer.mutateResources(ops);
3267
+ return {
3268
+ output: `\u2705 ${params.negative ? "Excluded" : "Added"} ${params.keywords.length} ${params.match_type} keyword(s) in ad group ${params.ad_group_id}.`,
3269
+ auditSummary: `${params.negative ? "Excluded" : "Added"} ${params.keywords.length} keyword(s) in ad group ${params.ad_group_id}`,
3270
+ customerId: creds.customerId
3271
+ };
3272
+ }
3273
+ };
3274
+
3275
+ // src/tools/handlers/keyword/update.ts
3276
+ import { z as z27 } from "zod";
3277
+ import { enums as enums19, toMicros as toMicros7, ResourceNames as ResourceNames11 } from "google-ads-api";
3278
+ var schema24 = z27.object({
3279
+ ad_group_id: ID,
3280
+ criterion_id: ID,
3281
+ cpc_bid: z27.number().positive().optional().describe("New max CPC in account currency"),
3282
+ status: z27.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
3283
+ ...confirmFields
3284
+ });
3285
+ var updateHandler3 = {
3286
+ action: "update",
3287
+ summary: "Set a keyword's max CPC and/or status (ENABLED, PAUSED, or REMOVED). Requires ad_group_id + criterion_id (from keyword list). Provide cpc_bid and/or status. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3288
+ readOnly: false,
3289
+ category: "mutate",
3290
+ schema: schema24,
3291
+ async execute(params, ctx) {
3292
+ if (params.cpc_bid == null && !params.status) throw new Error("Provide cpc_bid and/or status to change.");
3293
+ const creds = await requireConnection(ctx);
3294
+ const customer = ctx.ads.customer(creds);
3295
+ const rn = ResourceNames11.adGroupCriterion(creds.customerId, params.ad_group_id, params.criterion_id);
3296
+ const isRemove = params.status === "REMOVED";
3297
+ const changes = [params.cpc_bid != null ? `bid\u2192${params.cpc_bid}` : "", params.status ? `status\u2192${params.status}` : ""].filter(Boolean).join(", ");
3298
+ const buildUpdate = () => {
3299
+ const op = { resource_name: rn };
3300
+ if (params.cpc_bid != null) op.cpc_bid_micros = toMicros7(params.cpc_bid);
3301
+ if (params.status && !isRemove) op.status = enums19.AdGroupCriterionStatus[params.status];
3302
+ return op;
3303
+ };
3304
+ const removeIdentity = { ad_group_id: params.ad_group_id, criterion_id: params.criterion_id, status: "REMOVED" };
3305
+ if (!params.confirmed && (!isRemove || userConfirmationRequired())) {
3306
+ if (isRemove) await customer.adGroupCriteria.remove([rn], { validate_only: true });
3307
+ else await customer.adGroupCriteria.update([buildUpdate()], { validate_only: true });
3308
+ const apply = {
3309
+ ad_group_id: params.ad_group_id,
3310
+ criterion_id: params.criterion_id,
3311
+ ...params.cpc_bid != null ? { cpc_bid: params.cpc_bid } : {},
3312
+ ...params.status ? { status: params.status } : {}
3313
+ };
3314
+ return {
3315
+ output: isRemove ? previewRemove(`keyword ${params.criterion_id}`, removeIdentity) : preview(`update keyword ${params.criterion_id} (${changes})`, apply),
3316
+ auditSummary: `Preview: ${isRemove ? "remove" : "update"} keyword ${params.criterion_id} (${changes})`,
3317
+ customerId: creds.customerId
3318
+ };
3319
+ }
3320
+ assertUserConfirmedIfClaimed(params);
3321
+ if (isRemove) {
3322
+ await customer.adGroupCriteria.remove([rn]);
3323
+ return { output: `\u2705 Removed keyword ${params.criterion_id}.`, auditSummary: `Removed keyword ${params.criterion_id}`, customerId: creds.customerId };
3324
+ }
3325
+ await customer.adGroupCriteria.update([buildUpdate()]);
3326
+ return { output: `\u2705 Updated keyword ${params.criterion_id} (${changes}).`, auditSummary: `Updated keyword ${params.criterion_id} (${changes})`, customerId: creds.customerId };
3327
+ }
3328
+ };
3329
+
3330
+ // src/tools/orchestrators/keyword.ts
3331
+ var keywordTool = defineOrchestrator({
3332
+ name: "google_ads_keyword",
3333
+ purpose: "Manage keywords. list shows them with criterion_id and Quality Score. add adds positive or negative keywords. update changes a keyword bid or status.",
3334
+ limitations: 'Every write previews first; then the user must type yes or ok in chat before confirmed:true + user_confirmation. Get criterion_id from action "list" before "update". Negative keywords cannot carry a bid. cpc_bid only applies under manual bidding.',
3335
+ examples: [
3336
+ '{"action":"list","ad_group_id":"456"}',
3337
+ '{"action":"add","ad_group_id":"456","keywords":["24h locksmith"],"match_type":"PHRASE","confirmed":true,"user_confirmation":"yes"}',
3338
+ '{"action":"add","ad_group_id":"456","keywords":["cheap"],"match_type":"BROAD","negative":true,"confirmed":true,"user_confirmation":"yes"}',
3339
+ '{"action":"update","ad_group_id":"456","criterion_id":"789","status":"PAUSED","confirmed":true,"user_confirmation":"yes"}'
3340
+ ],
3341
+ handlers: [listHandler2, addHandler, updateHandler3]
3342
+ });
3343
+
3344
+ // src/tools/handlers/pmax/createAssetGroup.ts
3345
+ import { z as z28 } from "zod";
3346
+ import { enums as enums21, ResourceNames as ResourceNames13 } from "google-ads-api";
3347
+
3348
+ // src/tools/handlers/pmax/images.ts
3349
+ import { randomUUID as randomUUID4 } from "crypto";
3350
+ import { enums as enums20, ResourceNames as ResourceNames12 } from "google-ads-api";
3351
+ async function buildImageOps(cid, agRN, imgs, nextTemp) {
3352
+ const cats = [
3353
+ { list: imgs.marketing_images, field: enums20.AssetFieldType.MARKETING_IMAGE, req: "marketing" },
3354
+ { list: imgs.square_images, field: enums20.AssetFieldType.SQUARE_MARKETING_IMAGE, req: "square" },
3355
+ { list: imgs.portrait_images, field: enums20.AssetFieldType.PORTRAIT_MARKETING_IMAGE, req: null },
3356
+ { list: imgs.logos, field: enums20.AssetFieldType.LOGO, req: "logo" },
3357
+ { list: imgs.landscape_logos, field: enums20.AssetFieldType.LANDSCAPE_LOGO, req: null }
3358
+ ];
3359
+ const toDownload = [];
3360
+ const existing = [];
3361
+ const has = { marketing: 0, square: 0, logo: 0 };
3362
+ for (const c of cats) {
3363
+ for (const item of c.list ?? []) {
3364
+ if (c.req) has[c.req]++;
3365
+ if (/^\d+$/.test(item)) existing.push({ id: item, field: c.field });
3366
+ else toDownload.push({ url: item, field: c.field });
3367
+ }
3368
+ }
3369
+ const downloaded = await Promise.all(toDownload.map(async (t) => ({ buf: await fetchImageBytes(t.url), field: t.field })));
3370
+ const assetOps = [];
3371
+ const linkOps = [];
3372
+ for (const d of downloaded) {
3373
+ const aRN = ResourceNames12.asset(cid, nextTemp());
3374
+ assetOps.push({ entity: "asset", operation: "create", resource: { resource_name: aRN, name: `image-${randomUUID4()}`, image_asset: { data: d.buf } } });
3375
+ linkOps.push({ entity: "asset_group_asset", operation: "create", resource: { asset_group: agRN, asset: aRN, field_type: d.field } });
3376
+ }
3377
+ for (const e of existing) {
3378
+ linkOps.push({ entity: "asset_group_asset", operation: "create", resource: { asset_group: agRN, asset: ResourceNames12.asset(cid, e.id), field_type: e.field } });
3379
+ }
3380
+ return { assetOps, linkOps, total: downloaded.length + existing.length, downloaded: downloaded.length, existing: existing.length, has };
3381
+ }
3382
+
3383
+ // src/tools/handlers/pmax/createAssetGroup.ts
3384
+ var CTA = [
3385
+ "LEARN_MORE",
3386
+ "GET_QUOTE",
3387
+ "APPLY_NOW",
3388
+ "SIGN_UP",
3389
+ "CONTACT_US",
3390
+ "SUBSCRIBE",
3391
+ "DOWNLOAD",
3392
+ "BOOK_NOW",
3393
+ "SHOP_NOW",
3394
+ "BUY_NOW",
3395
+ "DONATE_NOW",
3396
+ "ORDER_NOW",
3397
+ "PLAY_NOW",
3398
+ "SEE_MORE",
3399
+ "START_NOW",
3400
+ "VISIT_SITE",
3401
+ "WATCH_NOW"
3402
+ ];
3403
+ var schema25 = z28.object({
3404
+ campaign_id: ID,
3405
+ name: z28.string().min(1).max(128),
3406
+ final_url: z28.string().url(),
3407
+ headlines: z28.array(z28.string().min(1).max(30)).min(3).max(5),
3408
+ long_headline: z28.string().min(1).max(90),
3409
+ descriptions: z28.array(z28.string().min(1).max(90)).min(2).max(5),
3410
+ business_name: z28.string().min(1).max(25),
3411
+ // Google requires at least one marketing (1.91:1), square (1:1), and logo (1:1) image.
3412
+ marketing_images: z28.array(z28.string()).max(20).optional().describe("REQUIRED (>=1): URLs or asset ids, 1.91:1 landscape marketing images."),
3413
+ square_images: z28.array(z28.string()).max(20).optional().describe("REQUIRED (>=1): URLs or asset ids, 1:1 square marketing images."),
3414
+ logos: z28.array(z28.string()).max(20).optional().describe("REQUIRED (>=1): URLs or asset ids, 1:1 square logos."),
3415
+ portrait_images: z28.array(z28.string()).max(20).optional().describe("Optional: URLs or asset ids, 4:5 portrait marketing images."),
3416
+ landscape_logos: z28.array(z28.string()).max(20).optional().describe("Optional: URLs or asset ids, 4:1 landscape logos."),
3417
+ youtube_video_ids: z28.array(z28.string()).max(5).optional().describe("YouTube video ids or URLs to add as video assets. Recommended: Google auto-generates a weak video if none is supplied."),
3418
+ call_to_action: z28.enum(CTA).optional().describe("Call-to-action button text for the asset group, e.g. SHOP_NOW."),
3419
+ ...confirmFields
3420
+ });
3421
+ var createAssetGroupHandler = {
3422
+ action: "create_asset_group",
3423
+ summary: "Create a Performance Max asset group (so a P-MAX campaign can serve). Requires campaign_id, name, final_url, 3-5 headlines, long_headline, 2-5 descriptions, business_name, AND images (a valid asset group needs at least one marketing_images (1.91:1), one square_images (1:1), and one logos (1:1) image, as URLs or asset ids). Optionally youtube_video_ids and a call_to_action. Add more images later with add_images. Created PAUSED. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3424
+ readOnly: false,
3425
+ category: "mutate",
3426
+ schema: schema25,
3427
+ async execute(params, ctx) {
3428
+ const creds = await requireConnection(ctx);
3429
+ const cid = creds.customerId;
3430
+ if (!params.marketing_images?.length || !params.square_images?.length || !params.logos?.length) {
3431
+ throw new Error("A P-MAX asset group needs at least one marketing_images (1.91:1), one square_images (1:1), and one logos (1:1) image, as reachable http(s) URLs or existing asset ids. Ask the user for those images, then retry.");
3432
+ }
3433
+ let temp = -1;
3434
+ const nextTemp = () => String(temp--);
3435
+ const agRN = ResourceNames13.assetGroup(cid, nextTemp());
3436
+ const agOp = {
3437
+ entity: "asset_group",
3438
+ operation: "create",
3439
+ resource: { resource_name: agRN, name: params.name, campaign: ResourceNames13.campaign(cid, params.campaign_id), final_urls: [params.final_url], status: enums21.AssetGroupStatus.PAUSED }
3440
+ };
3441
+ const assetOps = [];
3442
+ const linkOps = [];
3443
+ const add = (resource, fieldType) => {
3444
+ const aRN = ResourceNames13.asset(cid, nextTemp());
3445
+ assetOps.push({ entity: "asset", operation: "create", resource: { resource_name: aRN, ...resource } });
3446
+ linkOps.push({ entity: "asset_group_asset", operation: "create", resource: { asset_group: agRN, asset: aRN, field_type: fieldType } });
3447
+ };
3448
+ params.headlines.forEach((h) => add({ text_asset: { text: h } }, enums21.AssetFieldType.HEADLINE));
3449
+ add({ text_asset: { text: params.long_headline } }, enums21.AssetFieldType.LONG_HEADLINE);
3450
+ params.descriptions.forEach((d) => add({ text_asset: { text: d } }, enums21.AssetFieldType.DESCRIPTION));
3451
+ add({ text_asset: { text: params.business_name } }, enums21.AssetFieldType.BUSINESS_NAME);
3452
+ (params.youtube_video_ids ?? []).forEach((v) => add({ youtube_video_asset: { youtube_video_id: youtubeVideoId(v) } }, enums21.AssetFieldType.YOUTUBE_VIDEO));
3453
+ if (params.call_to_action) {
3454
+ add({ call_to_action_asset: { call_to_action: enums21.CallToActionType[params.call_to_action] } }, enums21.AssetFieldType.CALL_TO_ACTION_SELECTION);
3455
+ }
3456
+ const img = await buildImageOps(cid, agRN, params, nextTemp);
3457
+ const ops = [agOp, ...assetOps, ...img.assetOps, ...linkOps, ...img.linkOps];
3458
+ const customer = ctx.ads.customer(creds);
3459
+ if (!params.confirmed) {
3460
+ await customer.mutateResources(ops, { validate_only: true });
3461
+ return {
3462
+ output: preview(`create P-MAX asset group "${params.name}" (text + ${img.total} image asset(s)) in campaign ${params.campaign_id}`, { campaign_id: params.campaign_id, name: params.name, confirmed: true }),
3463
+ auditSummary: `Preview: create asset group "${params.name}"`,
3464
+ customerId: cid
3465
+ };
3466
+ }
3467
+ assertUserConfirmed(params);
3468
+ const result = await customer.mutateResources(ops);
3469
+ const id = createdId(result, "asset_group_result");
3470
+ return {
3471
+ output: `\u2705 Created P-MAX asset group "${params.name}"${id ? ` (id ${id})` : ""} in campaign ${params.campaign_id}, PAUSED, with ${img.total} image asset(s).
3472
+ Add more images anytime with add_images. Enable the asset group and the campaign when ready to serve.`,
3473
+ auditSummary: `Created asset group "${params.name}"${id ? ` (${id})` : ""}`,
3474
+ customerId: cid
3475
+ };
3476
+ }
3477
+ };
3478
+
3479
+ // src/tools/handlers/pmax/assetGroup.ts
3480
+ import { z as z29 } from "zod";
3481
+ import { enums as enums22, ResourceNames as ResourceNames14 } from "google-ads-api";
3482
+ var schema26 = z29.object({
3483
+ asset_group_id: ID,
3484
+ name: z29.string().min(1).max(128).optional().describe("New asset group name."),
3485
+ final_url: z29.string().url().optional().describe("New final URL for the asset group."),
3486
+ status: z29.enum(["ENABLED", "PAUSED", "REMOVED"]).optional().describe("ENABLED to serve, PAUSED to stop, REMOVED to delete (permanent)."),
3487
+ ...confirmFields
3488
+ });
3489
+ var assetGroupHandler2 = {
3490
+ action: "asset_group",
3491
+ summary: "Update or delete a P-MAX asset group: rename, change final_url, pause/resume (status ENABLED|PAUSED), or delete (status REMOVED, permanent). Requires asset_group_id (from create_asset_group or list_assets). Provide at least one of name, final_url, status. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3492
+ readOnly: false,
3493
+ category: "mutate",
3494
+ schema: schema26,
3495
+ async execute(params, ctx) {
3496
+ if (params.name == null && params.final_url == null && !params.status) {
3497
+ throw new Error("Provide at least one of name, final_url, or status to change.");
3498
+ }
3499
+ const creds = await requireConnection(ctx);
3500
+ const cid = creds.customerId;
3501
+ const rn = ResourceNames14.assetGroup(cid, params.asset_group_id);
3502
+ const isRemove = params.status === "REMOVED";
3503
+ const customer = ctx.ads.customer(creds);
3504
+ const changes = [params.name != null ? `name "${params.name}"` : "", params.final_url != null ? `final_url ${params.final_url}` : "", params.status ? `status ${params.status}` : ""].filter(Boolean).join(", ");
3505
+ const identity = {
3506
+ asset_group_id: params.asset_group_id,
3507
+ ...params.name != null ? { name: params.name } : {},
3508
+ ...params.final_url != null ? { final_url: params.final_url } : {},
3509
+ ...params.status ? { status: params.status } : {}
3510
+ };
3511
+ const updateResource = { resource_name: rn };
3512
+ if (params.name != null) updateResource.name = params.name;
3513
+ if (params.final_url != null) updateResource.final_urls = [params.final_url];
3514
+ if (params.status && !isRemove) updateResource.status = enums22.AssetGroupStatus[params.status];
3515
+ const ops = isRemove ? [{ entity: "asset_group", operation: "remove", resource: rn }] : [{ entity: "asset_group", operation: "update", resource: updateResource }];
3516
+ if (!params.confirmed && (!isRemove || userConfirmationRequired())) {
3517
+ await customer.mutateResources(ops, { validate_only: true });
3518
+ return {
3519
+ output: isRemove ? previewRemove(`asset group ${params.asset_group_id} (deletes it permanently; the P-MAX campaign stops serving from it)`, identity) : preview(`update asset group ${params.asset_group_id} (${changes})`, identity),
3520
+ auditSummary: `Preview: ${isRemove ? "remove" : "update"} asset group ${params.asset_group_id}`,
3521
+ customerId: cid
3522
+ };
3523
+ }
3524
+ assertUserConfirmedIfClaimed(params);
3525
+ await customer.mutateResources(ops);
3526
+ return {
3527
+ output: isRemove ? `\u2705 Removed asset group ${params.asset_group_id} (permanently deleted).` : `\u2705 Updated asset group ${params.asset_group_id} (${changes}).`,
3528
+ auditSummary: `${isRemove ? "Removed" : "Updated"} asset group ${params.asset_group_id}${isRemove ? "" : ` (${changes})`}`,
3529
+ customerId: cid
3530
+ };
3531
+ }
3532
+ };
3533
+
3534
+ // src/tools/handlers/pmax/addImages.ts
3535
+ import { z as z30 } from "zod";
3536
+ import { ResourceNames as ResourceNames15 } from "google-ads-api";
3537
+ var schema27 = z30.object({
3538
+ asset_group_id: ID,
3539
+ marketing_images: z30.array(z30.string()).max(20).optional().describe("URLs or asset ids, 1.91:1 landscape marketing images"),
3540
+ square_images: z30.array(z30.string()).max(20).optional().describe("URLs or asset ids, 1:1 square marketing images"),
3541
+ portrait_images: z30.array(z30.string()).max(20).optional().describe("URLs or asset ids, 4:5 portrait marketing images"),
3542
+ logos: z30.array(z30.string()).max(20).optional().describe("URLs or asset ids, 1:1 logos"),
3543
+ landscape_logos: z30.array(z30.string()).max(20).optional().describe("URLs or asset ids, 4:1 landscape logos"),
3544
+ ...confirmFields
3545
+ });
3546
+ var addImagesHandler = {
3547
+ action: "add_images",
3548
+ summary: "Add image assets to a P-MAX asset group: marketing (1.91:1), square (1:1), portrait (4:5), logo (1:1), landscape logo (4:1). Pass http(s) URLs (downloaded, SSRF-guarded) or existing asset ids. Requires asset_group_id and at least one image. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3549
+ readOnly: false,
3550
+ category: "mutate",
3551
+ schema: schema27,
3552
+ async execute(params, ctx) {
3553
+ const creds = await requireConnection(ctx);
3554
+ const cid = creds.customerId;
3555
+ const agRN = ResourceNames15.assetGroup(cid, params.asset_group_id);
3556
+ let temp = -1;
3557
+ const { assetOps, linkOps, total, downloaded, existing } = await buildImageOps(cid, agRN, params, () => String(temp--));
3558
+ if (total === 0) throw new Error("Provide at least one image (URL or asset id) in marketing_images, square_images, portrait_images, logos, or landscape_logos.");
3559
+ const ops = [...assetOps, ...linkOps];
3560
+ const customer = ctx.ads.customer(creds);
3561
+ if (!params.confirmed) {
3562
+ await customer.mutateResources(ops, { validate_only: true });
3563
+ return {
3564
+ output: preview(`add ${total} image(s) to asset group ${params.asset_group_id} (${downloaded} downloaded, ${existing} existing)`, { asset_group_id: params.asset_group_id, confirmed: true }),
3565
+ auditSummary: `Preview: add ${total} image(s) to asset group ${params.asset_group_id}`,
3566
+ customerId: cid
3567
+ };
3568
+ }
3569
+ assertUserConfirmed(params);
3570
+ await customer.mutateResources(ops);
3571
+ return {
3572
+ output: `\u2705 Added ${total} image(s) to asset group ${params.asset_group_id}.`,
3573
+ auditSummary: `Added ${total} image(s) to asset group ${params.asset_group_id}`,
3574
+ customerId: cid
3575
+ };
3576
+ }
3577
+ };
3578
+
3579
+ // src/tools/handlers/pmax/listAssets.ts
3580
+ import { z as z31 } from "zod";
3581
+ import { enums as enums23 } from "google-ads-api";
3582
+ function assetLine(r) {
3583
+ const aga = r.asset_group_asset;
3584
+ const a = r.asset;
3585
+ return `\u2022 ${enumLabel(enums23.AssetFieldType, aga.field_type)} | asset ${a?.id} (${enumLabel(enums23.AssetType, a?.type)}) | ${enumLabel(enums23.AssetLinkStatus, aga.status)}
3586
+ ${aga.resource_name}`;
3587
+ }
3588
+ var schema28 = z31.object({
3589
+ asset_group_id: ID,
3590
+ limit: z31.number().int().min(1).max(2e3).default(200),
3591
+ cursor: z31.string().optional()
3592
+ });
3593
+ var listAssetsHandler = {
3594
+ action: "list_assets",
3595
+ summary: "List the assets linked to a P-MAX asset group, with field type and the resource name needed to remove one. Requires asset_group_id. Paginated via cursor.",
3596
+ readOnly: true,
3597
+ category: "report",
3598
+ schema: schema28,
3599
+ async execute(params, ctx) {
3600
+ if (params.cursor) return paginate({ ctx, cursor: params.cursor, title: "", rows: [], summary: "Asset-group assets (page)" });
3601
+ const creds = await requireConnection(ctx);
3602
+ const gaql = `SELECT asset_group_asset.resource_name, asset_group_asset.field_type, asset_group_asset.status, asset.id, asset.type, asset.name FROM asset_group_asset WHERE asset_group.id = ${params.asset_group_id} ORDER BY asset_group_asset.field_type`;
3603
+ const { rows, total, truncated } = await ctx.ads.searchPage(creds, gaql, params.limit);
3604
+ if (!rows.length) return { output: "No assets found in this asset group.", auditSummary: "Listed asset-group assets (0)", customerId: creds.customerId };
3605
+ return paginate({
3606
+ ctx,
3607
+ title: `Asset group ${params.asset_group_id} assets (account ${formatCid(creds.customerId)})${truncated ? ` - top ${params.limit}${total != null ? ` of ${int(total)}` : ""}` : ""}`,
3608
+ rows: rows.map(assetLine),
3609
+ summary: `Listed asset-group assets (${params.asset_group_id})`,
3610
+ customerId: creds.customerId
3611
+ });
3612
+ }
3613
+ };
3614
+
3615
+ // src/tools/handlers/pmax/removeAsset.ts
3616
+ import { z as z32 } from "zod";
3617
+ var schema29 = z32.object({
3618
+ asset_group_asset: z32.string().describe("The asset_group_asset resource name from pmax list_assets"),
3619
+ ...confirmFields
3620
+ });
3621
+ var removeAssetHandler = {
3622
+ action: "remove_asset",
3623
+ summary: "Remove an asset from a P-MAX asset group. Requires asset_group_asset (the resource name from list_assets). To replace an asset, remove it then add a new one. Preview first (omit confirmed). Then ask the user to type yes or ok in chat; only after that re-call with confirmed:true and user_confirmation yes|ok.",
3624
+ readOnly: false,
3625
+ category: "mutate",
3626
+ schema: schema29,
3627
+ async execute(params, ctx) {
3628
+ const creds = await requireConnection(ctx);
3629
+ const prefix = `customers/${creds.customerId}/assetGroupAssets/`;
3630
+ if (!params.asset_group_asset.startsWith(prefix)) {
3631
+ throw new Error(`asset_group_asset must be a resource name from pmax list_assets for this account, e.g. ${prefix}456~789~MARKETING_IMAGE.`);
3632
+ }
3633
+ const customer = ctx.ads.customer(creds);
3634
+ const ops = [{ entity: "asset_group_asset", operation: "remove", resource: params.asset_group_asset }];
3635
+ const identity = { asset_group_asset: params.asset_group_asset };
3636
+ if (!params.confirmed && userConfirmationRequired()) {
3637
+ await customer.mutateResources(ops, { validate_only: true });
3638
+ return {
3639
+ output: previewRemove(`the asset ${params.asset_group_asset}`, identity),
3640
+ auditSummary: "Preview: remove asset-group asset",
3641
+ customerId: creds.customerId
3642
+ };
3643
+ }
3644
+ assertUserConfirmedIfClaimed(params);
3645
+ await customer.mutateResources(ops);
3646
+ return { output: "\u2705 Removed the asset from the asset group.", auditSummary: "Removed asset-group asset", customerId: creds.customerId };
3647
+ }
3648
+ };
3649
+
3650
+ // src/tools/orchestrators/pmax.ts
3651
+ var pmaxTool = defineOrchestrator({
3652
+ name: "google_ads_pmax",
3653
+ purpose: "Performance Max assets. create_asset_group makes an asset group from text plus images (a group needs at least one marketing, square, and logo image, so images are required up front). asset_group updates or deletes a group. add_images adds more images. list_assets lists them. remove_asset removes one.",
3654
+ limitations: "Every write previews first; then the user must type yes or ok in chat before confirmed:true + user_confirmation. create_asset_group needs images up front: at least one marketing image (1.91:1), one square image (1:1), and one logo (1:1), given as reachable http(s) URLs or existing asset ids. It fails without them. Asset groups are created PAUSED; enable one with asset_group status ENABLED. asset_group can rename a group, change its final_url, pause it, or delete it (status REMOVED); remove_asset removes a single asset within a group. Get campaign_id from report, asset_group_id is returned by create_asset_group, and an asset resource name comes from list_assets. Individual assets are not editable in place, so remove one and add a new one instead.",
3655
+ examples: [
3656
+ '{"action":"create_asset_group","campaign_id":"123","name":"Spring","final_url":"https://example.com","headlines":["A","B","C"],"long_headline":"Long one","descriptions":["One","Two"],"business_name":"Acme","marketing_images":["https://example.com/banner.jpg"],"square_images":["https://example.com/sq.jpg"],"logos":["https://example.com/logo.png"],"confirmed":true,"user_confirmation":"yes"}',
3657
+ '{"action":"asset_group","asset_group_id":"999","status":"ENABLED","confirmed":true,"user_confirmation":"yes"}',
3658
+ '{"action":"asset_group","asset_group_id":"999","name":"Spring v2","confirmed":true,"user_confirmation":"yes"}',
3659
+ '{"action":"asset_group","asset_group_id":"999","status":"REMOVED","confirmed":true,"user_confirmation":"yes"}',
3660
+ '{"action":"add_images","asset_group_id":"999","marketing_images":["https://example.com/banner.jpg"],"square_images":["https://example.com/sq.jpg"],"logos":["https://example.com/logo.png"],"confirmed":true,"user_confirmation":"yes"}',
3661
+ '{"action":"list_assets","asset_group_id":"999"}',
3662
+ '{"action":"remove_asset","asset_group_asset":"customers/1234567890/assetGroupAssets/999~555~MARKETING_IMAGE","confirmed":true,"user_confirmation":"yes"}'
3663
+ ],
3664
+ handlers: [createAssetGroupHandler, assetGroupHandler2, addImagesHandler, listAssetsHandler, removeAssetHandler]
3665
+ });
3666
+
3667
+ // src/tools/handlers/lookup/location.ts
3668
+ import { z as z33 } from "zod";
3669
+ var NAME = /^[\p{L}\p{N} .,'\-()/&]+$/u;
3670
+ function gaqlString(s) {
3671
+ return s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3672
+ }
3673
+ var schema30 = z33.object({
3674
+ name: z33.string().min(1).max(80).describe('Location name, e.g. "California", "Tokyo", "United States"'),
3675
+ country_code: z33.string().length(2).optional().describe('ISO 3166-1 alpha-2 country code to disambiguate, e.g. "US", "JP"'),
3676
+ limit: z33.number().int().min(1).max(50).default(20)
3677
+ });
3678
+ var locationHandler = {
3679
+ action: "location",
3680
+ summary: "Resolve a location NAME to its numeric geo target constant id (needed by campaign targeting and geo reports). Requires name; optional country_code disambiguates same-named places.",
3681
+ readOnly: true,
3682
+ category: "report",
3683
+ schema: schema30,
3684
+ async execute(params, ctx) {
3685
+ if (!NAME.test(params.name)) throw new Error('Location name has unexpected characters. Use a plain place name like "California".');
3686
+ const creds = await requireConnection(ctx);
3687
+ const cc = params.country_code ? ` AND geo_target_constant.country_code = '${gaqlString(params.country_code.toUpperCase())}'` : "";
3688
+ const gaql = `SELECT geo_target_constant.id, geo_target_constant.name, geo_target_constant.canonical_name, geo_target_constant.country_code, geo_target_constant.target_type FROM geo_target_constant WHERE geo_target_constant.status = 'ENABLED' AND geo_target_constant.name = '${gaqlString(params.name)}'${cc} LIMIT ${params.limit}`;
3689
+ const rows = await ctx.ads.query(creds, gaql);
3690
+ if (!rows.length) {
3691
+ return {
3692
+ output: `No location found named "${params.name}"${params.country_code ? ` in ${params.country_code.toUpperCase()}` : ""}. Check spelling and capitalization, or try again with a country_code.`,
3693
+ auditSummary: `Geo lookup: ${params.name} (0)`,
3694
+ customerId: creds.customerId
3695
+ };
3696
+ }
3697
+ const lines = rows.map((r) => {
3698
+ const g = r.geo_target_constant;
3699
+ return `\u2022 id ${g.id} | ${g.canonical_name || g.name} (${g.target_type}, ${g.country_code})`;
3700
+ });
3701
+ return {
3702
+ output: [`Geo target constants for "${params.name}" - use the id in campaign targeting or a geo report:`, ...lines].join("\n"),
3703
+ auditSummary: `Geo lookup: ${params.name} (${rows.length})`,
3704
+ customerId: creds.customerId
3705
+ };
3706
+ }
3707
+ };
3708
+
3709
+ // src/tools/handlers/lookup/language.ts
3710
+ import { z as z34 } from "zod";
3711
+ var schema31 = z34.object({
3712
+ name: z34.string().min(1).max(40).describe('Language name, e.g. "Japanese", "English"')
3713
+ });
3714
+ var languageHandler = {
3715
+ action: "language",
3716
+ summary: "Resolve a language NAME to its numeric language constant id (needed by campaign targeting). Requires name.",
3717
+ readOnly: true,
3718
+ category: "report",
3719
+ schema: schema31,
3720
+ async execute(params, ctx) {
3721
+ const creds = await requireConnection(ctx);
3722
+ const rows = await ctx.ads.query(
3723
+ creds,
3724
+ "SELECT language_constant.id, language_constant.name, language_constant.code, language_constant.targetable FROM language_constant"
3725
+ );
3726
+ const q = params.name.trim().toLowerCase();
3727
+ const matches = rows.filter((r) => {
3728
+ const lc = r.language_constant;
3729
+ return lc.targetable && (String(lc.name).toLowerCase().includes(q) || String(lc.code).toLowerCase() === q);
3730
+ });
3731
+ if (!matches.length) {
3732
+ return { output: `No targetable language found matching "${params.name}".`, auditSummary: `Language lookup: ${params.name} (0)`, customerId: creds.customerId };
3733
+ }
3734
+ const lines = matches.map((r) => `\u2022 id ${r.language_constant.id} | ${r.language_constant.name} (${r.language_constant.code})`);
3735
+ return {
3736
+ output: [`Language constants for "${params.name}" - use the id in campaign targeting:`, ...lines].join("\n"),
3737
+ auditSummary: `Language lookup: ${params.name} (${matches.length})`,
3738
+ customerId: creds.customerId
3739
+ };
3740
+ }
3741
+ };
3742
+
3743
+ // src/tools/orchestrators/lookup.ts
3744
+ var lookupTool = defineOrchestrator({
3745
+ name: "google_ads_lookup",
3746
+ purpose: "Resolve a location or language name to the numeric id the other tools need. Campaign targeting and geo reports take ids, not names, so call this first.",
3747
+ limitations: "Read-only reference lookup. Location matching is by exact name, so use country_code to tell same-named places apart. Always resolve a location or language to an id here before passing it to campaign targeting or a geo report.",
3748
+ examples: ['{"action":"location","name":"California","country_code":"US"}', '{"action":"language","name":"Japanese"}'],
3749
+ handlers: [locationHandler, languageHandler]
3750
+ });
3751
+
3752
+ // src/tools/registry.ts
3753
+ function registerAllTools(server, ctx) {
3754
+ const tools = [accountTool, reportTool, campaignTool, adGroupTool, keywordTool, pmaxTool, lookupTool];
3755
+ for (const tool of tools) {
3756
+ server.registerTool(
3757
+ tool.name,
3758
+ {
3759
+ description: tool.description,
3760
+ annotations: tool.annotations,
3761
+ inputSchema: tool.parameters
3762
+ },
3763
+ wrapForMcp(tool, ctx)
3764
+ );
3765
+ }
3766
+ }
3767
+
3768
+ // src/tools/instructions.ts
3769
+ var ORIENTATION = "Google Ads (advertiser) tools for the user's connected account. Each tool takes an `action`; its action list, fields, and per-action help live on the tool itself. Below is only the cross-cutting map and the hard rules.";
3770
+ var DATA_MODEL = [
3771
+ "DATA MODEL",
3772
+ "- account (10-digit customer id, no dashes) > campaign > ad group > ads and keywords.",
3773
+ "- Performance Max uses asset groups, not ad groups, and needs text plus image assets to serve.",
3774
+ "- ROAS is computed as conversion value divided by cost. Average Position does not exist, use impression share instead. Quality Score only applies to Search keywords."
3775
+ ].join("\n");
3776
+ var TOOLS = [
3777
+ "TOOLS (capability summary; each tool lists and documents its own actions)",
3778
+ "- google_ads_account (4): check connection/token, list and switch accounts, read the audit trail.",
3779
+ "- google_ads_report (5): performance, impression share, P-MAX asset groups, and custom GAQL (call metadata first).",
3780
+ "- google_ads_campaign (6): create campaigns and edit them (update, status, budget, bidding, targeting).",
3781
+ "- google_ads_ad_group (6): ad groups and the ads inside them (create, update, status, list_ads, add_ad, ad_status).",
3782
+ "- google_ads_keyword (3): list keywords, add positive/negative, update bid or status.",
3783
+ "- google_ads_pmax (4): build and manage Performance Max asset groups and their image assets.",
3784
+ "- google_ads_lookup (2): resolve a location or language name to the numeric id other tools need."
3785
+ ].join("\n");
3786
+ var ROUTING = [
3787
+ "QUICK REFERENCE (user says -> tool action)",
3788
+ '- "check connection" -> account status ; "what accounts" -> account list ; "use account N" -> account switch',
3789
+ '- "how are my ads doing" / "campaign performance" -> report campaign_performance (compare_previous for vs the prior period)',
3790
+ '- "impression share" -> report impression_share (Search) ; "P-MAX asset performance" -> report asset_group',
3791
+ '- "by day" / "trend" / "search terms" / "custom report" -> report metadata, then report gaql',
3792
+ '- "create a campaign" -> campaign create ; rename/dates/networks -> campaign update ; pause/resume -> campaign status ; delete -> campaign status REMOVED ; budget/bidding/targeting -> the matching campaign action',
3793
+ '- "make an ad group" -> ad_group create ; "write an ad" -> ad_group add_ad ; list/pause/remove ads -> ad_group list_ads / ad_status',
3794
+ "- keywords -> keyword list/add/update (negative=true to exclude; status REMOVED to delete)",
3795
+ '- "P-MAX assets" -> pmax create_asset_group, then add_images',
3796
+ '- "target a place / a language" -> lookup location/language for the id, then campaign targeting'
3797
+ ].join("\n");
3798
+ var RULES = [
3799
+ "RULES",
3800
+ '- Truth: while connected, this server is the source of truth for the account. Answer only from a tool result, or for a specific action from the schema for that action. Never state data, metrics, ids, or enums from memory, and never fabricate a number, row, id, or status. If a call returns nothing, say "no data" plainly.',
3801
+ '- Confirmation (every write): never one-shot a write. (1) Call without confirmed to get a validate-only preview. (2) Show the exact field values to the user and ask them to type "yes" or "ok" (anything else = cancel). (3) Only after they type yes/ok in chat, call again with confirmed:true and user_confirmation:"yes" or "ok". Never invent approval; never set confirmed:true on the first call. If they change a value, preview again. Do not invent values the user did not give; ask about each meaningful choice, unless the user delegates it (e.g. "pick a random name").',
3802
+ "- Legal and deletes: legal declarations (contains_eu_political_advertising) and permanent deletes (status REMOVED, pmax remove_asset) must be shown to the user verbatim; they must type yes or ok. Never auto-fill or silently default them.",
3803
+ "- New campaigns and P-MAX asset groups are created PAUSED; nothing spends until the user enables them.",
3804
+ "- Show human-readable names to the user, but pass ids between calls. Read ids from a list or report first; never invent customer, campaign, ad group, or criterion ids."
3805
+ ].join("\n");
3806
+ var SERVER_INSTRUCTIONS = [ORIENTATION, DATA_MODEL, TOOLS, ROUTING, RULES].join("\n\n");
3807
+
3808
+ // src/mcp/server.ts
3809
+ var SERVER_NAME = "google-ads-mcp";
3810
+ function packageVersion() {
3811
+ try {
3812
+ return createRequire2(import.meta.url)("../../package.json").version ?? "0.0.0";
3813
+ } catch {
3814
+ return "0.0.0";
3815
+ }
3816
+ }
3817
+ var SERVER_VERSION = packageVersion();
3818
+ function createSessionServer(ctx) {
3819
+ const server = new McpServer(
3820
+ { name: SERVER_NAME, version: SERVER_VERSION },
3821
+ {
3822
+ capabilities: { logging: {}, tools: { listChanged: false } },
3823
+ instructions: SERVER_INSTRUCTIONS
3824
+ }
3825
+ );
3826
+ registerAllTools(server, ctx);
3827
+ return server;
3828
+ }
3829
+
3830
+ // src/mcp/requestAuth.ts
3831
+ import { AsyncLocalStorage } from "async_hooks";
3832
+ function bindRequestAuth(base2) {
3833
+ const als = new AsyncLocalStorage();
3834
+ const ctx = {
3835
+ ...base2,
3836
+ get credentials() {
3837
+ return als.getStore()?.creds ?? null;
3838
+ },
3839
+ get oauthSessionId() {
3840
+ return als.getStore()?.sessionId;
3841
+ }
3842
+ };
3843
+ return {
3844
+ ctx,
3845
+ runWithAuth: (creds, sessionId, fn) => als.run({ creds, sessionId }, fn)
3846
+ };
3847
+ }
3848
+
3849
+ // src/oauth/google.ts
3850
+ import { OAuth2Client } from "google-auth-library";
3851
+ var ADWORDS_SCOPE = "https://www.googleapis.com/auth/adwords";
3852
+ function makeOAuthClient(opts) {
3853
+ return new OAuth2Client({ clientId: opts.clientId, clientSecret: opts.clientSecret, redirectUri: opts.redirectUri });
3854
+ }
3855
+ function authUrl(client, state) {
3856
+ return client.generateAuthUrl({
3857
+ access_type: "offline",
3858
+ // ask for a refresh token
3859
+ prompt: "consent",
3860
+ // force a NEW refresh token even on re-consent
3861
+ scope: [ADWORDS_SCOPE],
3862
+ include_granted_scopes: true,
3863
+ state
3864
+ });
3865
+ }
3866
+ async function exchangeCode(client, code) {
3867
+ const { tokens } = await client.getToken(code);
3868
+ return tokens.refresh_token ?? void 0;
3869
+ }
3870
+
3871
+ // src/oauth/broker.ts
3872
+ var CONSENT_COOKIE = "gads_oauth";
3873
+ var OAuthBroker = class {
3874
+ constructor(deps) {
3875
+ this.deps = deps;
3876
+ brandName = deps.appName;
3877
+ }
3878
+ deps;
3879
+ url(path3) {
3880
+ return `${this.deps.issuer}${path3}`;
3881
+ }
3882
+ /** Is redirect origin on the allowlist? Closes the open-DCR phishing vector: a code can only go to a trusted origin, never an attacker-controlled one. */
3883
+ isRedirectAllowed(uri) {
3884
+ return redirectMatchesAllowlist(uri, this.deps.allowedRedirectOrigins);
3885
+ }
3886
+ authServerMetadata() {
3887
+ return {
3888
+ kind: "json",
3889
+ status: 200,
3890
+ body: {
3891
+ issuer: this.deps.issuer,
3892
+ authorization_endpoint: this.url("/authorize"),
3893
+ token_endpoint: this.url("/token"),
3894
+ registration_endpoint: this.url("/register"),
3895
+ revocation_endpoint: this.url("/revoke"),
3896
+ response_types_supported: ["code"],
3897
+ grant_types_supported: ["authorization_code", "refresh_token"],
3898
+ code_challenge_methods_supported: ["S256"],
3899
+ token_endpoint_auth_methods_supported: ["none"],
3900
+ scopes_supported: [ADWORDS_SCOPE]
3901
+ }
3902
+ };
3903
+ }
3904
+ protectedResourceMetadata() {
3905
+ return {
3906
+ kind: "json",
3907
+ status: 200,
3908
+ body: { resource: this.url("/mcp"), authorization_servers: [this.deps.issuer] }
3909
+ };
3910
+ }
3911
+ // Dynamic Client Registration (RFC 7591)
3912
+ register(body) {
3913
+ const b = body ?? {};
3914
+ const uris = Array.isArray(b.redirect_uris) ? b.redirect_uris.filter((u) => typeof u === "string") : [];
3915
+ if (!uris.length) return jsonErr(400, "invalid_client_metadata", "redirect_uris is required");
3916
+ for (const u of uris) {
3917
+ try {
3918
+ new URL(u);
3919
+ } catch {
3920
+ return jsonErr(400, "invalid_redirect_uri", `not an absolute URL: ${u}`);
3921
+ }
3922
+ if (!this.isRedirectAllowed(u)) return jsonErr(400, "invalid_redirect_uri", `redirect origin is not on the allowlist: ${u}`);
3923
+ }
3924
+ const name = typeof b.client_name === "string" ? b.client_name : void 0;
3925
+ const client = this.deps.store.registerClient(uris, name);
3926
+ return {
3927
+ kind: "json",
3928
+ status: 201,
3929
+ body: {
3930
+ client_id: client.clientId,
3931
+ redirect_uris: client.redirectUris,
3932
+ token_endpoint_auth_method: "none",
3933
+ grant_types: ["authorization_code", "refresh_token"],
3934
+ response_types: ["code"],
3935
+ ...name ? { client_name: name } : {}
3936
+ }
3937
+ };
3938
+ }
3939
+ // Authorization (GET /authorize) → consent page
3940
+ authorize(q) {
3941
+ const client = q.client_id ? this.deps.store.getClient(q.client_id) : null;
3942
+ if (!client || !q.redirect_uri || !client.redirectUris.includes(q.redirect_uri) || !this.isRedirectAllowed(q.redirect_uri)) {
3943
+ return errorPage("Invalid client or redirect URI", "The application is not registered, its redirect URL does not match, or that destination is not allowed. Start the connection again from the chat.", 400);
3944
+ }
3945
+ const redirectUri = q.redirect_uri;
3946
+ const state = q.state;
3947
+ const back = (error, desc) => ({ kind: "redirect", url: appendParams(redirectUri, { error, error_description: desc, ...state ? { state } : {} }) });
3948
+ if (q.response_type !== "code") return back("unsupported_response_type", "only response_type=code is supported");
3949
+ if (q.code_challenge_method !== "S256") return back("invalid_request", "PKCE with S256 is required");
3950
+ if (!q.code_challenge) return back("invalid_request", "code_challenge is required");
3951
+ if (!/^[A-Za-z0-9_-]{43}$/.test(q.code_challenge)) return back("invalid_request", "code_challenge must be a base64url-encoded S256 digest (43 chars)");
3952
+ const txnState = this.deps.store.putTxn({ clientId: client.clientId, clientRedirectUri: redirectUri, clientState: state, pkceChallenge: q.code_challenge, createdMs: Date.now() });
3953
+ return {
3954
+ kind: "html",
3955
+ status: 200,
3956
+ cookies: [{ name: CONSENT_COOKIE, value: txnState, maxAgeSec: 600 }],
3957
+ html: consentPage(client.clientName, txnState, this.url("/consent"), new URL(redirectUri).host)
3958
+ };
3959
+ }
3960
+ // Consent (POST /consent) → redirect to Google
3961
+ consent(formState, cookieState) {
3962
+ if (!formState || !cookieState || formState !== cookieState) {
3963
+ return { ...errorPage("Session expired", "This consent link is invalid or expired. Start again from the chat.", 400), cookies: [{ name: CONSENT_COOKIE, value: "", clear: true }] };
3964
+ }
3965
+ return { kind: "redirect", url: this.deps.buildGoogleAuthUrl(formState) };
3966
+ }
3967
+ // Google callback (GET /auth/callback)
3968
+ async callback(q, cookieState) {
3969
+ const clear = [{ name: CONSENT_COOKIE, value: "", clear: true }];
3970
+ if (q.error) return { ...errorPage("Authorization cancelled", `Google returned: ${escapeHtml(q.error)}`, 400), cookies: clear };
3971
+ if (!q.code || !q.state) return { ...errorPage("Invalid response", "Missing authorization code or state.", 400), cookies: clear };
3972
+ if (!cookieState || cookieState !== q.state) {
3973
+ return { ...errorPage("Could not verify sign-in", "This sign-in could not be verified in this browser. Start again from the chat.", 400), cookies: clear };
3974
+ }
3975
+ const txn = this.deps.store.takeTxn(q.state);
3976
+ if (!txn) return { ...errorPage("Link expired", "This authorization expired or was already used. Start again from the chat.", 400), cookies: clear };
3977
+ let refreshToken;
3978
+ try {
3979
+ refreshToken = await this.deps.exchangeCodeForRefreshToken(q.code);
3980
+ } catch (e) {
3981
+ this.deps.logger.error("oauth", "google token exchange failed", { error: errMsg(e) });
3982
+ return { ...errorPage("Connection failed", "Could not complete authorization with Google. Please try again.", 502), cookies: clear };
3983
+ }
3984
+ if (!refreshToken) {
3985
+ return { ...errorPage("No refresh token", "Google didn't return a refresh token. Remove this app under your Google Account \u2192 Security \u2192 Third-party access, then connect again.", 400), cookies: clear };
3986
+ }
3987
+ let accessible = [];
3988
+ try {
3989
+ accessible = await this.deps.listAccessibleCustomers(refreshToken);
3990
+ } catch (e) {
3991
+ this.deps.logger.warn("oauth", "could not list accessible customers (continuing)", { error: errMsg(e) });
3992
+ }
3993
+ const sessionId = this.deps.store.createSession({
3994
+ clientId: txn.clientId,
3995
+ googleRefreshToken: refreshToken,
3996
+ scope: ADWORDS_SCOPE,
3997
+ customerId: accessible[0] ?? null,
3998
+ accessibleCustomers: accessible
3999
+ });
4000
+ const code = this.deps.store.issueCode({ clientId: txn.clientId, redirectUri: txn.clientRedirectUri, pkceChallenge: txn.pkceChallenge, sessionId });
4001
+ this.deps.logger.info("oauth", "authorization granted", { accounts: accessible.length });
4002
+ return { kind: "redirect", url: appendParams(txn.clientRedirectUri, { code, ...txn.clientState ? { state: txn.clientState } : {} }), cookies: clear };
4003
+ }
4004
+ // Token endpoint (POST /token)
4005
+ token(body) {
4006
+ const grant = body.grant_type;
4007
+ if (grant === "authorization_code") {
4008
+ if (!body.code || !body.code_verifier) return jsonErr(400, "invalid_request", "code and code_verifier are required");
4009
+ if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(body.code_verifier)) return jsonErr(400, "invalid_grant", "code_verifier must be 43-128 unreserved characters (RFC 7636)");
4010
+ const info = this.deps.store.takeCode(body.code);
4011
+ if (!info) return jsonErr(400, "invalid_grant", "authorization code is invalid or expired");
4012
+ if (body.client_id && info.clientId !== body.client_id) return jsonErr(400, "invalid_grant", "client_id mismatch");
4013
+ if (body.redirect_uri && info.redirectUri !== body.redirect_uri) return jsonErr(400, "invalid_grant", "redirect_uri mismatch");
4014
+ if (!verifyPkceS256(body.code_verifier, info.pkceChallenge)) return jsonErr(400, "invalid_grant", "PKCE verification failed");
4015
+ const access = this.deps.store.issueToken(info.sessionId, "access", ACCESS_TTL_MS);
4016
+ const refresh = this.deps.store.issueToken(info.sessionId, "refresh", REFRESH_TTL_MS);
4017
+ return tokenOk(access, refresh);
4018
+ }
4019
+ if (grant === "refresh_token") {
4020
+ if (!body.refresh_token) return jsonErr(400, "invalid_request", "refresh_token is required");
4021
+ const sessionId = this.deps.store.resolveToken(body.refresh_token, "refresh");
4022
+ if (!sessionId) return jsonErr(400, "invalid_grant", "refresh token is invalid or expired");
4023
+ this.deps.store.touchRefreshToken(body.refresh_token, REFRESH_TTL_MS);
4024
+ this.deps.store.touchSession(sessionId);
4025
+ return tokenOk(this.deps.store.issueToken(sessionId, "access", ACCESS_TTL_MS));
4026
+ }
4027
+ return jsonErr(400, "unsupported_grant_type", `unsupported grant_type: ${grant ?? "(none)"}`);
4028
+ }
4029
+ // Revocation (POST /revoke, RFC 7009)
4030
+ revoke(body) {
4031
+ if (body.token) {
4032
+ const session = this.deps.store.resolveToken(body.token, "refresh");
4033
+ if (session) this.deps.store.deleteSession(session);
4034
+ else this.deps.store.revokeToken(body.token);
4035
+ }
4036
+ return { kind: "json", status: 200, body: {} };
4037
+ }
4038
+ };
4039
+ function redirectMatchesAllowlist(uri, patterns) {
4040
+ let u;
4041
+ try {
4042
+ u = new URL(uri);
4043
+ } catch {
4044
+ return false;
4045
+ }
4046
+ const host = u.hostname.toLowerCase();
4047
+ const origin = u.origin.toLowerCase();
4048
+ const httpsOrLocal = u.protocol === "https:" || host === "localhost" || host === "127.0.0.1";
4049
+ for (const p2 of patterns) {
4050
+ if (p2.includes("://")) {
4051
+ if (origin === p2) return true;
4052
+ continue;
4053
+ }
4054
+ if (!httpsOrLocal) continue;
4055
+ if (p2.startsWith("*.")) {
4056
+ const suffix = p2.slice(1);
4057
+ if (host.length > suffix.length && host.endsWith(suffix)) return true;
4058
+ } else if (host === p2) {
4059
+ return true;
4060
+ }
4061
+ }
4062
+ return false;
4063
+ }
4064
+ function tokenOk(access, refresh) {
4065
+ return {
4066
+ kind: "json",
4067
+ status: 200,
4068
+ body: { access_token: access, token_type: "Bearer", expires_in: Math.floor(ACCESS_TTL_MS / 1e3), ...refresh ? { refresh_token: refresh } : {}, scope: ADWORDS_SCOPE }
4069
+ };
4070
+ }
4071
+ function jsonErr(status, error, description) {
4072
+ return { kind: "json", status, body: { error, error_description: description } };
4073
+ }
4074
+ function appendParams(base2, params) {
4075
+ const u = new URL(base2);
4076
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
4077
+ return u.toString();
4078
+ }
4079
+ function escapeHtml(s) {
4080
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
4081
+ }
4082
+ function errMsg(e) {
4083
+ return e instanceof Error ? e.message : String(e);
4084
+ }
4085
+ var PAGE_CSS = `
4086
+ *,*::before,*::after{box-sizing:border-box}
4087
+ :root{
4088
+ color-scheme:light;
4089
+ --bg:oklch(0.984 0.004 255); --surface:oklch(0.997 0.0015 255);
4090
+ --ink:oklch(0.27 0.03 262); --muted:oklch(0.52 0.028 260); --faint:oklch(0.60 0.02 260);
4091
+ --line:oklch(0.915 0.012 255);
4092
+ --accent:oklch(0.50 0.15 255); --accent-press:oklch(0.43 0.15 255);
4093
+ --on-accent:oklch(0.99 0.01 255); --ring:oklch(0.50 0.15 255 / 0.35);
4094
+ --positive:oklch(0.55 0.11 162); --danger:oklch(0.55 0.17 25);
4095
+ --shadow:0 1px 2px oklch(0.27 0.03 262 / 0.05), 0 14px 36px -14px oklch(0.27 0.03 262 / 0.20);
4096
+ --font-display:ui-serif,"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
4097
+ --font-body:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
4098
+ --font-mono:ui-monospace,"SF Mono","Cascadia Code",Menlo,monospace;
4099
+ }
4100
+ @media (prefers-color-scheme:dark){
4101
+ :root{
4102
+ color-scheme:dark;
4103
+ --bg:oklch(0.205 0.02 262); --surface:oklch(0.25 0.022 262);
4104
+ --ink:oklch(0.96 0.008 255); --muted:oklch(0.74 0.02 256); --faint:oklch(0.62 0.02 256);
4105
+ --line:oklch(0.36 0.02 262);
4106
+ --accent:oklch(0.72 0.13 255); --accent-press:oklch(0.66 0.13 255);
4107
+ --on-accent:oklch(0.18 0.03 262); --ring:oklch(0.72 0.13 255 / 0.40);
4108
+ --positive:oklch(0.74 0.12 162); --danger:oklch(0.70 0.15 25);
4109
+ --shadow:0 1px 2px oklch(0 0 0 / 0.3), 0 20px 50px -18px oklch(0 0 0 / 0.6);
4110
+ }
4111
+ }
4112
+ html{-webkit-text-size-adjust:100%}
4113
+ body{
4114
+ margin:0; min-height:100svh; padding:24px; background:var(--bg); color:var(--ink);
4115
+ font-family:var(--font-body); line-height:1.55; -webkit-font-smoothing:antialiased;
4116
+ text-rendering:optimizeLegibility; display:grid; place-items:center;
4117
+ background-image:radial-gradient(120% 70% at 50% -8%, oklch(0.50 0.15 255 / 0.06), transparent 60%);
4118
+ }
4119
+ .card{
4120
+ width:100%; max-width:30rem; background:var(--surface); border:1px solid var(--line);
4121
+ border-radius:18px; box-shadow:var(--shadow); padding:clamp(28px,5vw,44px);
4122
+ }
4123
+ .brand{display:flex; align-items:center; gap:10px; margin-bottom:30px}
4124
+ .brand b{font-weight:600; font-size:.94rem; letter-spacing:-0.01em}
4125
+ .eyebrow,.status{font-size:.72rem; letter-spacing:.14em; text-transform:uppercase; font-weight:600; margin:0 0 12px}
4126
+ .eyebrow{color:var(--faint)}
4127
+ .status{display:inline-flex; align-items:center; gap:8px; color:var(--danger)}
4128
+ .status .dot{width:7px; height:7px; border-radius:50%; background:var(--danger)}
4129
+ h1{
4130
+ font-family:var(--font-display); font-weight:600; line-height:1.08; letter-spacing:-0.015em;
4131
+ font-size:clamp(1.7rem, 1.15rem + 2.3vw, 2.3rem); margin:0 0 18px; color:var(--ink); text-wrap:balance;
4132
+ }
4133
+ .lead,.msg{font-size:1.02rem; color:var(--muted); margin:0 0 24px; max-width:36ch}
4134
+ .lead b{color:var(--ink); font-weight:600}
4135
+ ul.assurances{list-style:none; margin:0 0 26px; padding:0; display:grid; gap:13px}
4136
+ ul.assurances li{display:grid; grid-template-columns:18px 1fr; gap:11px; align-items:start; font-size:.94rem; color:var(--ink)}
4137
+ ul.assurances svg{margin-top:3px; color:var(--positive)}
4138
+ .return-block{margin:0 0 28px}
4139
+ .return{font-size:.88rem; color:var(--muted); margin:0 0 6px}
4140
+ .return .host{font-family:var(--font-mono); font-size:.82rem; color:var(--ink); background:oklch(0.50 0.15 255 / 0.09); padding:1px 7px; border-radius:6px}
4141
+ .caution{font-size:.8rem; color:var(--faint); margin:0}
4142
+ .btn{
4143
+ appearance:none; cursor:pointer; width:100%; border:0; border-radius:12px;
4144
+ font-family:var(--font-body); font-size:1rem; font-weight:600; letter-spacing:-0.005em;
4145
+ color:var(--on-accent); background:var(--accent); padding:14px 20px;
4146
+ display:inline-flex; align-items:center; justify-content:center; gap:9px;
4147
+ box-shadow:0 1px 0 oklch(1 0 0 / 0.16) inset, 0 8px 20px -10px var(--accent);
4148
+ transition:background .15s ease, transform .12s cubic-bezier(.2,.7,.3,1);
4149
+ }
4150
+ .btn:hover{background:var(--accent-press)}
4151
+ .btn:active{transform:translateY(1px)}
4152
+ .btn:focus-visible{outline:3px solid var(--ring); outline-offset:2px}
4153
+ .btn .arrow{transition:transform .2s cubic-bezier(.2,.7,.3,1)}
4154
+ .btn:hover .arrow{transform:translateX(3px)}
4155
+ .foot{margin:18px 0 0; font-size:.8rem; color:var(--faint)}
4156
+ @media (prefers-reduced-motion:no-preference){
4157
+ .card>*{animation:rise .5s cubic-bezier(.2,.7,.3,1) both}
4158
+ .card>*:nth-child(2){animation-delay:.04s}
4159
+ .card>*:nth-child(3){animation-delay:.08s}
4160
+ .card>*:nth-child(4){animation-delay:.12s}
4161
+ .card>*:nth-child(5){animation-delay:.16s}
4162
+ .card>*:nth-child(6){animation-delay:.20s}
4163
+ .card>*:nth-child(7){animation-delay:.24s}
4164
+ .card>*:nth-child(8){animation-delay:.28s}
4165
+ @keyframes rise{from{opacity:0; transform:translateY(9px)}to{opacity:1; transform:none}}
4166
+ }`;
4167
+ var BRAND_MARK = `<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M5 16 11 5l6 11" stroke="var(--accent)" stroke-width="1.6" stroke-linejoin="round"/><circle cx="5" cy="16" r="2.4" fill="var(--accent)"/><circle cx="17" cy="16" r="2.4" fill="var(--accent)"/><circle cx="11" cy="5" r="2.4" fill="var(--accent)"/></svg>`;
4168
+ var CHECK = `<svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M3 8.5 6.5 12 13 4.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
4169
+ var ARROW = `<svg class="arrow" width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true"><path d="M3.5 9H13M9 4.5 13.5 9 9 13.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
4170
+ var brandName = "Google Ads MCP";
4171
+ function brandLockup() {
4172
+ return `<div class="brand">${BRAND_MARK}<b>${escapeHtml(brandName)}</b></div>`;
4173
+ }
4174
+ function shell(titleText, inner) {
4175
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
4176
+ <meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex">
4177
+ <title>${escapeHtml(titleText)}</title><style>${PAGE_CSS}</style></head>
4178
+ <body><main class="card">${inner}</main></body></html>`;
4179
+ }
4180
+ function errorPage(title, message, status) {
4181
+ const inner = `${brandLockup()}
4182
+ <p class="status"><span class="dot"></span>Couldn't continue</p>
4183
+ <h1>${escapeHtml(title)}</h1>
4184
+ <p class="msg">${escapeHtml(message)}</p>
4185
+ <p class="foot">${escapeHtml(brandName)} \xB7 secure connection to Google Ads</p>`;
4186
+ return { kind: "html", status, html: shell(title, inner) };
4187
+ }
4188
+ function consentPage(clientName, state, action, redirectHost) {
4189
+ const who = clientName ? escapeHtml(clientName) : "An application";
4190
+ const inner = `${brandLockup()}
4191
+ <p class="eyebrow">Authorize access</p>
4192
+ <h1>Connect your Google Ads account</h1>
4193
+ <p class="lead"><b>${who}</b> is requesting access to manage your <b>Google&nbsp;Ads</b> account.</p>
4194
+ <ul class="assurances">
4195
+ <li>${CHECK}<span>Google handles sign-in. We never see your password.</span></li>
4196
+ <li>${CHECK}<span>The application never receives your Google token.</span></li>
4197
+ <li>${CHECK}<span>Your authorization is stored encrypted on this server.</span></li>
4198
+ </ul>
4199
+ <div class="return-block">
4200
+ <p class="return">After you approve, you return to <span class="host">${escapeHtml(redirectHost)}</span>.</p>
4201
+ <p class="caution">Only continue if you started this connection yourself, from your AI chat.</p>
4202
+ </div>
4203
+ <form method="post" action="${escapeHtml(action)}">
4204
+ <input type="hidden" name="state" value="${escapeHtml(state)}">
4205
+ <button class="btn" type="submit"><span>Continue to Google</span>${ARROW}</button>
4206
+ </form>
4207
+ <p class="foot">You can revoke access anytime in your Google Account under Security.</p>`;
4208
+ return shell("Connect Google Ads", inner);
4209
+ }
4210
+
4211
+ // src/oauth/routes.ts
4212
+ import { getCookie, setCookie, deleteCookie } from "hono/cookie";
4213
+ function send(c, r) {
4214
+ for (const ck of r.cookies ?? []) applyCookie(c, ck);
4215
+ if (r.kind === "redirect") return c.redirect(r.url, 302);
4216
+ if (r.kind === "html") {
4217
+ c.header("X-Frame-Options", "DENY");
4218
+ c.header("Content-Security-Policy", "frame-ancestors 'none'");
4219
+ return c.html(r.html, r.status);
4220
+ }
4221
+ return c.json(r.body, r.status);
4222
+ }
4223
+ function applyCookie(c, ck) {
4224
+ if (ck.clear) {
4225
+ deleteCookie(c, ck.name, { path: "/" });
4226
+ return;
4227
+ }
4228
+ setCookie(c, ck.name, ck.value, { httpOnly: true, secure: true, sameSite: "Lax", path: "/", maxAge: ck.maxAgeSec });
4229
+ }
4230
+ function queryObj(c) {
4231
+ return c.req.query();
4232
+ }
4233
+ async function readForm(c) {
4234
+ const ct = c.req.header("content-type") ?? "";
4235
+ const raw = ct.includes("application/json") ? await c.req.json().catch(() => ({})) : await c.req.parseBody().catch(() => ({}));
4236
+ const out = {};
4237
+ for (const [k, v] of Object.entries(raw)) if (typeof v === "string") out[k] = v;
4238
+ return out;
4239
+ }
4240
+ function registerOAuthRoutes(app, broker) {
4241
+ app.get("/.well-known/oauth-authorization-server", (c) => send(c, broker.authServerMetadata()));
4242
+ app.get("/.well-known/oauth-protected-resource", (c) => send(c, broker.protectedResourceMetadata()));
4243
+ app.post("/register", async (c) => send(c, broker.register(await c.req.json().catch(() => ({})))));
4244
+ app.get("/authorize", (c) => send(c, broker.authorize(queryObj(c))));
4245
+ app.post("/consent", async (c) => {
4246
+ const form = await c.req.parseBody().catch(() => ({}));
4247
+ const state = typeof form.state === "string" ? form.state : void 0;
4248
+ return send(c, broker.consent(state, getCookie(c, CONSENT_COOKIE)));
4249
+ });
4250
+ app.get("/auth/callback", async (c) => send(c, await broker.callback(queryObj(c), getCookie(c, CONSENT_COOKIE))));
4251
+ app.post("/token", async (c) => send(c, broker.token(await readForm(c))));
4252
+ app.post("/revoke", async (c) => send(c, broker.revoke(await readForm(c))));
4253
+ }
4254
+
4255
+ // src/app.ts
4256
+ async function startHttp(config, deps) {
4257
+ const { ads, audit, logger, store } = deps;
4258
+ const pageCache = new PageCache();
4259
+ const oauthLimiter = new RateLimiter(60, 6e4);
4260
+ const mcpLimiter = new RateLimiter(120, 6e4);
4261
+ const makeServer = () => {
4262
+ const { ctx, runWithAuth } = bindRequestAuth({ ads, mode: "http", pageCache, audit, logger, oauthStore: store });
4263
+ const server2 = createSessionServer(ctx);
4264
+ return { server: server2, runWithAuth };
4265
+ };
4266
+ const sessions = new McpSessionManager(makeServer, logger);
4267
+ const redirectUri = `${config.publicBaseUrl}/auth/callback`;
4268
+ const googleClient = () => makeOAuthClient({ clientId: config.oauthClientId, clientSecret: config.oauthClientSecret, redirectUri });
4269
+ const broker = new OAuthBroker({
4270
+ store,
4271
+ issuer: config.publicBaseUrl,
4272
+ allowedRedirectOrigins: config.allowedRedirectOrigins,
4273
+ buildGoogleAuthUrl: (state) => authUrl(googleClient(), state),
4274
+ exchangeCodeForRefreshToken: (code) => exchangeCode(googleClient(), code),
4275
+ listAccessibleCustomers: (rt) => ads.listAccessibleCustomers(rt),
4276
+ logger,
4277
+ appName: config.appName
4278
+ });
4279
+ const app = new Hono();
4280
+ app.use("*", bodyLimit({ maxSize: 1024 * 1024, onError: (c) => c.json({ error: "Request body too large (max 1MB)." }, 413) }));
4281
+ const clientIp = (c) => {
4282
+ const xff = c.req.header("x-forwarded-for");
4283
+ if (xff) return xff.split(",")[0].trim();
4284
+ const remote = c.env?.incoming?.socket?.remoteAddress;
4285
+ return remote || "unknown";
4286
+ };
4287
+ app.get("/healthz", (c) => c.json({ status: "ok", mode: "http" }));
4288
+ app.use("/register", async (c, next) => {
4289
+ if (!oauthLimiter.allow(`reg:${clientIp(c)}`)) return c.json({ error: "rate_limit", message: "Too many registration requests. Retry later." }, 429);
4290
+ return next();
4291
+ });
4292
+ app.use("/token", async (c, next) => {
4293
+ if (!oauthLimiter.allow(`tok:${clientIp(c)}`)) return c.json({ error: "rate_limit", message: "Too many token requests. Retry later." }, 429);
4294
+ return next();
4295
+ });
4296
+ app.use("/authorize", async (c, next) => {
4297
+ if (!oauthLimiter.allow(`authz:${clientIp(c)}`)) return c.text("Too many authorize requests. Retry later.", 429);
4298
+ return next();
4299
+ });
4300
+ app.use("/consent", async (c, next) => {
4301
+ if (!oauthLimiter.allow(`consent:${clientIp(c)}`)) return c.text("Too many consent requests. Retry later.", 429);
4302
+ return next();
4303
+ });
4304
+ registerOAuthRoutes(app, broker);
4305
+ const resolveSession = (c) => {
4306
+ const auth = c.req.header("authorization") ?? "";
4307
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
4308
+ const sessionId = token ? store.resolveToken(token, "access") : null;
4309
+ if (!sessionId) return null;
4310
+ const session = store.getSession(sessionId);
4311
+ const refreshToken = session ? store.getGoogleRefreshToken(sessionId) : null;
4312
+ if (!session || !refreshToken) return null;
4313
+ store.touchSession(sessionId);
4314
+ return { creds: { refreshToken, customerId: session.customerId ?? "" }, sessionId };
4315
+ };
4316
+ const wwwAuth = `Bearer resource_metadata="${config.publicBaseUrl}/.well-known/oauth-protected-resource"`;
4317
+ const rateLimitMcp = (c) => mcpLimiter.allow(`mcp:${clientIp(c)}`);
4318
+ app.post("/mcp", async (c) => {
4319
+ if (!rateLimitMcp(c)) return c.json({ error: "rate_limit", message: "Too many MCP requests. Retry later." }, 429);
4320
+ const s = resolveSession(c);
4321
+ if (!s) {
4322
+ c.header("WWW-Authenticate", wwwAuth);
4323
+ return c.json({ error: "unauthorized" }, 401);
4324
+ }
4325
+ const body = await c.req.json().catch(() => void 0);
4326
+ await sessions.handlePost(c.env.incoming, c.env.outgoing, body, s.creds, s.sessionId);
4327
+ return RESPONSE_ALREADY_SENT;
4328
+ });
4329
+ app.get("/mcp", async (c) => {
4330
+ if (!rateLimitMcp(c)) return c.json({ error: "rate_limit", message: "Too many MCP requests. Retry later." }, 429);
4331
+ const s = resolveSession(c);
4332
+ if (!s) {
4333
+ c.header("WWW-Authenticate", wwwAuth);
4334
+ return c.json({ error: "unauthorized" }, 401);
4335
+ }
4336
+ await sessions.handleGet(c.env.incoming, c.env.outgoing);
4337
+ return RESPONSE_ALREADY_SENT;
4338
+ });
4339
+ app.delete("/mcp", async (c) => {
4340
+ if (!rateLimitMcp(c)) return c.json({ error: "rate_limit", message: "Too many MCP requests. Retry later." }, 429);
4341
+ const s = resolveSession(c);
4342
+ if (!s) {
4343
+ c.header("WWW-Authenticate", wwwAuth);
4344
+ return c.json({ error: "unauthorized" }, 401);
4345
+ }
4346
+ await sessions.handleDelete(c.env.incoming, c.env.outgoing);
4347
+ return RESPONSE_ALREADY_SENT;
4348
+ });
4349
+ const server = serve(
4350
+ {
4351
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4352
+ fetch: app.fetch,
4353
+ port: config.port,
4354
+ hostname: "0.0.0.0"
4355
+ },
4356
+ () => {
4357
+ logger.info("http", `listening on :${config.port}`);
4358
+ runStartupChecks(config, logger);
4359
+ }
4360
+ );
4361
+ server.on("error", (err) => {
4362
+ const cause = err.code === "EADDRINUSE" ? `port ${config.port} is already in use (another instance running? set PORT to a free port)` : `server error: ${err.message}`;
4363
+ logger.error("http", cause, { error: err.stack ?? err.message });
4364
+ process.exit(1);
4365
+ });
4366
+ const sweep = setInterval(() => store.sweepExpired(), 10 * 6e4);
4367
+ sweep.unref?.();
4368
+ return {
4369
+ async close() {
4370
+ clearInterval(sweep);
4371
+ sessions.close();
4372
+ const srv = server;
4373
+ await new Promise((resolve) => {
4374
+ const timer = setTimeout(() => {
4375
+ srv.closeAllConnections?.();
4376
+ resolve();
4377
+ }, 5e3);
4378
+ timer.unref?.();
4379
+ srv.close(() => {
4380
+ clearTimeout(timer);
4381
+ resolve();
4382
+ });
4383
+ });
4384
+ }
4385
+ };
4386
+ }
4387
+
4388
+ // src/transports/stdio.ts
4389
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4390
+ async function startStdio(ctx) {
4391
+ const server = createSessionServer(ctx);
4392
+ const transport = new StdioServerTransport();
4393
+ await server.connect(transport);
4394
+ }
4395
+
4396
+ // src/index.ts
4397
+ function parseMode(argv) {
4398
+ if (argv.includes("--http")) return "http";
4399
+ if (argv.includes("--stdio")) return "stdio";
4400
+ return "stdio";
4401
+ }
4402
+ var USAGE = `google-ads-mcp: Google Ads API MCP server (stdio or http)
4403
+
4404
+ Usage: google-ads-mcp [--stdio | --http]
4405
+
4406
+ --stdio default; single account over stdio (Claude Code / local).
4407
+ Env: GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_OAUTH_CLIENT_ID,
4408
+ GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN,
4409
+ GOOGLE_ADS_CUSTOMER_ID.
4410
+ --http multi-tenant HTTP with a built-in OAuth broker (for hosted AI chat platforms).
4411
+ Needs Node >= 22.5. See the README for the env it wants.
4412
+
4413
+ -h, --help print this help
4414
+ -v, --version print the version
4415
+
4416
+ Any config value can also be a flag instead of an env var (env name lowercased,
4417
+ dashes for underscores): --app-name="My Ads Bot", --port=3002, --log-level=debug,
4418
+ --no-require-user-confirmation.
4419
+ `;
4420
+ function version() {
4421
+ try {
4422
+ return createRequire3(import.meta.url)("../package.json").version ?? "0.0.0";
4423
+ } catch {
4424
+ return "0.0.0";
4425
+ }
4426
+ }
4427
+ async function main() {
4428
+ const argv = process.argv.slice(2);
4429
+ if (argv.includes("--help") || argv.includes("-h")) {
4430
+ process.stdout.write(USAGE);
4431
+ return;
4432
+ }
4433
+ if (argv.includes("--version") || argv.includes("-v")) {
4434
+ process.stdout.write(`${version()}
4435
+ `);
4436
+ return;
4437
+ }
4438
+ const { env: flagEnv, unknown } = parseConfigFlags(argv);
4439
+ for (const flag of unknown) process.stderr.write(`google-ads-mcp: ignoring unknown flag --${flag}
4440
+ `);
4441
+ Object.assign(process.env, flagEnv);
4442
+ const mode = parseMode(argv);
4443
+ const config = loadConfig(mode);
4444
+ const logger = new Logger(config.logLevel);
4445
+ process.on("unhandledRejection", (reason) => {
4446
+ logger.error("main", "unhandled promise rejection", {
4447
+ error: reason instanceof Error ? reason.stack ?? reason.message : String(reason)
4448
+ });
4449
+ });
4450
+ process.on("uncaughtException", (err) => {
4451
+ logger.error("main", "uncaught exception, exiting", { error: err.stack ?? err.message });
4452
+ process.exit(1);
4453
+ });
4454
+ const ads = new AdsService({
4455
+ developerToken: config.developerToken,
4456
+ clientId: config.oauthClientId,
4457
+ clientSecret: config.oauthClientSecret
4458
+ });
4459
+ if (mode === "stdio") {
4460
+ const audit2 = new Audit(null, logger);
4461
+ const credentials = config.refreshToken && config.customerId ? { refreshToken: config.refreshToken, customerId: config.customerId, loginCustomerId: config.loginCustomerId || void 0 } : null;
4462
+ if (!credentials) {
4463
+ logger.warn("main", 'stdio: GOOGLE_ADS_REFRESH_TOKEN / GOOGLE_ADS_CUSTOMER_ID not set, tools will report "not connected" until provided');
4464
+ }
4465
+ const ctx = { ads, credentials, mode: "stdio", pageCache: new PageCache(), audit: audit2, logger };
4466
+ await startStdio(ctx);
4467
+ logger.info("main", "stdio MCP server ready");
4468
+ return;
4469
+ }
4470
+ const db = openDb(config.auditDbPath);
4471
+ const audit = new Audit(db, logger);
4472
+ const store = new OAuthStore(db, loadEncKey(config.tokenEncKey));
4473
+ const running = await startHttp(config, { ads, audit, logger, store });
4474
+ const shutdown = (sig) => {
4475
+ logger.info("main", `received ${sig}, shutting down`);
4476
+ const hard = setTimeout(() => process.exit(1), 8e3);
4477
+ hard.unref?.();
4478
+ running.close().finally(() => {
4479
+ try {
4480
+ db.close();
4481
+ } catch {
4482
+ }
4483
+ clearTimeout(hard);
4484
+ process.exit(0);
4485
+ });
4486
+ };
4487
+ process.on("SIGINT", () => shutdown("SIGINT"));
4488
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
4489
+ }
4490
+ main().catch((err) => {
4491
+ process.stderr.write(`fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}
4492
+ `);
4493
+ process.exit(1);
4494
+ });