mcp-google-ads 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1751 @@
1
+ #!/usr/bin/env node
2
+ import { config as dotenvConfig } from "dotenv";
3
+ import { join, dirname } from "path";
4
+ dotenvConfig({ path: join(dirname(new URL(import.meta.url).pathname), "..", ".env") });
5
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
+ import { tools } from "./tools.js";
9
+ import { GoogleAdsApi, enums } from "google-ads-api";
10
+ import { readFileSync, existsSync } from "fs";
11
+ // Log build fingerprint at startup
12
+ try {
13
+ const __buildInfoDir = dirname(new URL(import.meta.url).pathname);
14
+ const buildInfo = JSON.parse(readFileSync(join(__buildInfoDir, "build-info.json"), "utf-8"));
15
+ logger.info({ sha: buildInfo.sha, builtAt: buildInfo.builtAt }, "Build fingerprint");
16
+ }
17
+ catch {
18
+ // build-info.json not present (dev mode)
19
+ }
20
+ function loadConfig() {
21
+ const configPath = join(dirname(new URL(import.meta.url).pathname), "..", "config.json");
22
+ if (!existsSync(configPath)) {
23
+ throw new Error(`Config file not found at ${configPath}. Copy config.example.json to config.json and fill in your credentials.`);
24
+ }
25
+ return JSON.parse(readFileSync(configPath, "utf-8"));
26
+ }
27
+ function getClientFromWorkingDir(config, cwd) {
28
+ for (const [key, client] of Object.entries(config.clients)) {
29
+ if (cwd.startsWith(client.folder) || cwd.includes(key)) {
30
+ return client;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ // ============================================
36
+ // TYPED ERRORS & VALIDATION (extracted to errors.ts)
37
+ // ============================================
38
+ import { GoogleAdsAuthError, GoogleAdsRateLimitError, GoogleAdsServiceError, validateCredentials, classifyError, } from "./errors.js";
39
+ import { withResilience, safeResponse, logger } from "./resilience.js";
40
+ // ============================================
41
+ // GOOGLE ADS CLIENT
42
+ // ============================================
43
+ class GoogleAdsManager {
44
+ api;
45
+ config;
46
+ defaultRefreshToken;
47
+ constructor(config) {
48
+ this.config = config;
49
+ // Validate credentials at startup — fail fast
50
+ const creds = validateCredentials();
51
+ if (!creds.valid) {
52
+ const msg = `Missing required credentials: ${creds.missing.join(", ")}. MCP will not function. Check run-mcp.sh and Keychain entries.`;
53
+ logger.error({ missing: creds.missing }, msg);
54
+ throw new GoogleAdsAuthError(msg);
55
+ }
56
+ logger.info("Credentials validated: all required env vars present");
57
+ this.defaultRefreshToken = process.env.GOOGLE_ADS_REFRESH_TOKEN;
58
+ this.api = new GoogleAdsApi({
59
+ client_id: process.env.GOOGLE_ADS_CLIENT_ID,
60
+ client_secret: process.env.GOOGLE_ADS_CLIENT_SECRET,
61
+ developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN,
62
+ });
63
+ }
64
+ getClientForCustomerId(customerId) {
65
+ const normalized = customerId.replace(/-/g, "");
66
+ for (const client of Object.values(this.config.clients)) {
67
+ if (client.customer_id.replace(/-/g, "") === normalized) {
68
+ return client;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ getCustomer(customerId) {
74
+ const client = this.getClientForCustomerId(customerId);
75
+ let refreshToken = this.defaultRefreshToken;
76
+ if (client?.refresh_token_env && process.env[client.refresh_token_env]) {
77
+ refreshToken = process.env[client.refresh_token_env];
78
+ }
79
+ const customerOpts = {
80
+ customer_id: customerId.replace(/-/g, ""),
81
+ refresh_token: refreshToken,
82
+ };
83
+ // Only route through MCC if the client doesn't have direct admin access.
84
+ // Direct access is needed for accounts where the authenticated user has
85
+ // admin rights but the MCC manager link doesn't permit mutations.
86
+ if (!client?.direct_access) {
87
+ const mccId = client?.mcc_customer_id || this.config.google_ads.mcc_customer_id;
88
+ customerOpts.login_customer_id = mccId.replace(/-/g, "");
89
+ }
90
+ return this.api.Customer(customerOpts);
91
+ }
92
+ // List all campaigns for a customer
93
+ async listCampaigns(customerId) {
94
+ const customer = this.getCustomer(customerId);
95
+ const campaigns = await withResilience(() => customer.query(`
96
+ SELECT
97
+ campaign.id,
98
+ campaign.name,
99
+ campaign.status,
100
+ campaign.advertising_channel_type,
101
+ campaign.tracking_url_template,
102
+ campaign.final_url_suffix,
103
+ campaign.url_custom_parameters
104
+ FROM campaign
105
+ WHERE campaign.status != 'REMOVED'
106
+ ORDER BY campaign.name
107
+ `), "listCampaigns");
108
+ return safeResponse(campaigns, "listCampaigns");
109
+ }
110
+ // Get campaign tracking parameters
111
+ async getCampaignTracking(customerId, campaignId) {
112
+ const customer = this.getCustomer(customerId);
113
+ const result = await withResilience(() => customer.query(`
114
+ SELECT
115
+ campaign.id,
116
+ campaign.name,
117
+ campaign.tracking_url_template,
118
+ campaign.final_url_suffix,
119
+ campaign.url_custom_parameters
120
+ FROM campaign
121
+ WHERE campaign.id = ${campaignId}
122
+ `), "getCampaignTracking");
123
+ if (result.length === 0) {
124
+ throw new Error(`Campaign ${campaignId} not found`);
125
+ }
126
+ const campaign = result[0].campaign;
127
+ return safeResponse({
128
+ campaign_id: campaign?.id,
129
+ campaign_name: campaign?.name,
130
+ tracking_url_template: campaign?.tracking_url_template || null,
131
+ final_url_suffix: campaign?.final_url_suffix || null,
132
+ url_custom_parameters: campaign?.url_custom_parameters || [],
133
+ }, "getCampaignTracking");
134
+ }
135
+ // List ad groups for a campaign
136
+ async listAdGroups(customerId, campaignId) {
137
+ const customer = this.getCustomer(customerId);
138
+ let query = `
139
+ SELECT
140
+ ad_group.id,
141
+ ad_group.name,
142
+ ad_group.status,
143
+ campaign.id,
144
+ campaign.name
145
+ FROM ad_group
146
+ WHERE ad_group.status != 'REMOVED'
147
+ `;
148
+ if (campaignId) {
149
+ query += ` AND campaign.id = ${campaignId}`;
150
+ }
151
+ query += ` ORDER BY campaign.name, ad_group.name`;
152
+ const result = await withResilience(() => customer.query(query), "listAdGroups");
153
+ return safeResponse(result, "listAdGroups");
154
+ }
155
+ // List ads with their status
156
+ async listAds(customerId, options) {
157
+ const customer = this.getCustomer(customerId);
158
+ let query = `
159
+ SELECT
160
+ ad_group_ad.ad.id,
161
+ ad_group_ad.ad.name,
162
+ ad_group_ad.ad.type,
163
+ ad_group_ad.status,
164
+ ad_group_ad.policy_summary.approval_status,
165
+ ad_group_ad.policy_summary.review_status,
166
+ ad_group.id,
167
+ ad_group.name,
168
+ campaign.id,
169
+ campaign.name,
170
+ label.name
171
+ FROM ad_group_ad
172
+ WHERE ad_group_ad.status != 'REMOVED'
173
+ `;
174
+ if (options.campaignId) {
175
+ query += ` AND campaign.id = ${options.campaignId}`;
176
+ }
177
+ if (options.adGroupId) {
178
+ query += ` AND ad_group.id = ${options.adGroupId}`;
179
+ }
180
+ query += ` ORDER BY campaign.name, ad_group.name`;
181
+ const result = await withResilience(() => customer.query(query), "listAds");
182
+ return safeResponse(result, "listAds");
183
+ }
184
+ // List pending changes (paused items with claude- label)
185
+ async listPendingChanges(customerId) {
186
+ const customer = this.getCustomer(customerId);
187
+ // Get paused campaigns with claude label
188
+ const campaigns = await withResilience(() => customer.query(`
189
+ SELECT
190
+ campaign.id,
191
+ campaign.name,
192
+ campaign.status,
193
+ label.name
194
+ FROM campaign
195
+ WHERE campaign.status = 'PAUSED'
196
+ AND label.name LIKE 'claude-%'
197
+ `), "listPendingChanges.campaigns");
198
+ // Get paused ad groups with claude label
199
+ const adGroups = await withResilience(() => customer.query(`
200
+ SELECT
201
+ ad_group.id,
202
+ ad_group.name,
203
+ ad_group.status,
204
+ campaign.name,
205
+ label.name
206
+ FROM ad_group
207
+ WHERE ad_group.status = 'PAUSED'
208
+ AND label.name LIKE 'claude-%'
209
+ `), "listPendingChanges.adGroups");
210
+ // Get paused ads with claude label
211
+ const ads = await withResilience(() => customer.query(`
212
+ SELECT
213
+ ad_group_ad.ad.id,
214
+ ad_group_ad.ad.type,
215
+ ad_group_ad.status,
216
+ ad_group_ad.policy_summary.approval_status,
217
+ ad_group.name,
218
+ campaign.name,
219
+ label.name
220
+ FROM ad_group_ad
221
+ WHERE ad_group_ad.status = 'PAUSED'
222
+ AND label.name LIKE 'claude-%'
223
+ `), "listPendingChanges.ads");
224
+ return safeResponse({ campaigns, adGroups, ads }, "listPendingChanges");
225
+ }
226
+ // Create a label
227
+ async createLabel(customerId, labelName) {
228
+ const customer = this.getCustomer(customerId);
229
+ const label = {
230
+ name: labelName,
231
+ status: enums.LabelStatus.ENABLED,
232
+ };
233
+ try {
234
+ const result = await withResilience(() => customer.labels.create([label]), "createLabel");
235
+ return result;
236
+ }
237
+ catch (e) {
238
+ // Label might already exist
239
+ if (e.message?.includes("DUPLICATE_NAME")) {
240
+ return { existing: true, name: labelName };
241
+ }
242
+ throw e;
243
+ }
244
+ }
245
+ // Ensure a label exists, returning its resource name
246
+ async ensureLabelExists(customerId, labelName) {
247
+ const customer = this.getCustomer(customerId);
248
+ const cleanId = customerId.replace(/-/g, "");
249
+ // Check if label already exists
250
+ const existing = await withResilience(() => customer.query(`
251
+ SELECT label.resource_name, label.name
252
+ FROM label
253
+ WHERE label.name = '${labelName}'
254
+ AND label.status = 'ENABLED'
255
+ `), "ensureLabelExists.query");
256
+ if (existing.length > 0) {
257
+ return existing[0].label.resource_name;
258
+ }
259
+ // Create it
260
+ const result = await this.createLabel(customerId, labelName);
261
+ if (result.existing) {
262
+ // Race condition: re-query
263
+ const requery = await withResilience(() => customer.query(`
264
+ SELECT label.resource_name FROM label WHERE label.name = '${labelName}' AND label.status = 'ENABLED'
265
+ `), "ensureLabelExists.requery");
266
+ return requery[0].label.resource_name;
267
+ }
268
+ return result.results[0].resource_name;
269
+ }
270
+ // Apply a label to ad group criteria (keywords)
271
+ async labelAdGroupCriteria(customerId, criterionResourceNames, labelResourceName) {
272
+ const customer = this.getCustomer(customerId);
273
+ const operations = criterionResourceNames.map(rn => ({
274
+ ad_group_criterion: rn,
275
+ label: labelResourceName,
276
+ }));
277
+ const result = await withResilience(() => customer.adGroupCriterionLabels.create(operations), "labelAdGroupCriteria");
278
+ return result;
279
+ }
280
+ // Update campaign budget — either in-place or by creating a new solo budget
281
+ async updateCampaignBudget(customerId, campaignId, dailyBudgetDollars, createNewBudget = false) {
282
+ const customer = this.getCustomer(customerId);
283
+ const cleanId = customerId.replace(/-/g, "");
284
+ // Get current campaign and budget info
285
+ const [currentCampaign] = await withResilience(() => customer.query(`
286
+ SELECT campaign.name, campaign.id, campaign_budget.id, campaign_budget.amount_micros, campaign_budget.name
287
+ FROM campaign
288
+ WHERE campaign.id = ${campaignId}
289
+ `), "updateCampaignBudget.query");
290
+ if (!currentCampaign?.campaign?.name || !currentCampaign?.campaign_budget?.id) {
291
+ throw new Error(`Campaign ${campaignId} not found or has no budget`);
292
+ }
293
+ const campaignName = currentCampaign.campaign.name;
294
+ const oldBudgetId = currentCampaign.campaign_budget.id;
295
+ const oldAmountMicros = currentCampaign.campaign_budget.amount_micros ?? 0;
296
+ const newAmountMicros = Math.round(dailyBudgetDollars * 1_000_000);
297
+ if (createNewBudget) {
298
+ // Create a new budget and reassign the campaign to it
299
+ const budgetResult = await withResilience(() => customer.campaignBudgets.create([{
300
+ name: `${campaignName} Budget`,
301
+ amount_micros: newAmountMicros,
302
+ delivery_method: enums.BudgetDeliveryMethod.STANDARD,
303
+ }]), "updateCampaignBudget.createBudget");
304
+ const newBudgetResourceName = budgetResult.results[0].resource_name;
305
+ // Update the campaign to use the new budget
306
+ await withResilience(() => customer.campaigns.update([{
307
+ resource_name: `customers/${cleanId}/campaigns/${campaignId}`,
308
+ campaign_budget: newBudgetResourceName,
309
+ }]), "updateCampaignBudget.reassignCampaign");
310
+ return {
311
+ campaign_id: campaignId,
312
+ campaign_name: campaignName,
313
+ action: "created_new_budget",
314
+ old_budget_id: oldBudgetId,
315
+ old_daily_budget: Number(oldAmountMicros) / 1_000_000,
316
+ new_budget_resource: newBudgetResourceName,
317
+ new_daily_budget: dailyBudgetDollars,
318
+ };
319
+ }
320
+ else {
321
+ // Update existing budget amount in place
322
+ await withResilience(() => customer.campaignBudgets.update([{
323
+ resource_name: `customers/${cleanId}/campaignBudgets/${oldBudgetId}`,
324
+ amount_micros: newAmountMicros,
325
+ }]), "updateCampaignBudget.updateInPlace");
326
+ return {
327
+ campaign_id: campaignId,
328
+ campaign_name: campaignName,
329
+ action: "updated_in_place",
330
+ budget_id: oldBudgetId,
331
+ old_daily_budget: Number(oldAmountMicros) / 1_000_000,
332
+ new_daily_budget: dailyBudgetDollars,
333
+ warning: "This update affects ALL campaigns sharing this budget",
334
+ };
335
+ }
336
+ }
337
+ // Create a campaign (paused by default)
338
+ async createCampaign(customerId, campaign) {
339
+ const customer = this.getCustomer(customerId);
340
+ // First create a budget
341
+ const budgetResult = await withResilience(() => customer.campaignBudgets.create([{
342
+ name: `${campaign.name} Budget`,
343
+ amount_micros: campaign.budget_amount_micros,
344
+ delivery_method: enums.BudgetDeliveryMethod.STANDARD,
345
+ }]), "createCampaign.budget");
346
+ const budgetResourceName = budgetResult.results[0].resource_name;
347
+ // Then create the campaign
348
+ const campaignResult = await withResilience(() => customer.campaigns.create([{
349
+ name: campaign.name,
350
+ status: enums.CampaignStatus.PAUSED, // Always create paused
351
+ advertising_channel_type: enums.AdvertisingChannelType.SEARCH,
352
+ campaign_budget: budgetResourceName,
353
+ manual_cpc: {}, // Default to manual CPC
354
+ }]), "createCampaign");
355
+ return campaignResult;
356
+ }
357
+ // Create an ad group (paused by default)
358
+ async createAdGroup(customerId, adGroup) {
359
+ const customer = this.getCustomer(customerId);
360
+ const result = await withResilience(() => customer.adGroups.create([{
361
+ name: adGroup.name,
362
+ campaign: `customers/${customerId.replace(/-/g, "")}/campaigns/${adGroup.campaign_id}`,
363
+ status: enums.AdGroupStatus.PAUSED, // Always create paused
364
+ cpc_bid_micros: adGroup.cpc_bid_micros || 1000000, // $1.00 default
365
+ type: enums.AdGroupType.SEARCH_STANDARD,
366
+ }]), "createAdGroup");
367
+ return result;
368
+ }
369
+ // Create a responsive search ad (paused by default)
370
+ async createResponsiveSearchAd(customerId, ad) {
371
+ const customer = this.getCustomer(customerId);
372
+ // Normalize to { text, pinned_position? } format
373
+ const normalizedHeadlines = ad.headlines.map(h => typeof h === "string" ? { text: h } : h);
374
+ const normalizedDescriptions = ad.descriptions.map(d => typeof d === "string" ? { text: d } : d);
375
+ // Validate
376
+ if (normalizedHeadlines.length < 3 || normalizedHeadlines.length > 15) {
377
+ throw new Error("RSA requires 3-15 headlines");
378
+ }
379
+ if (normalizedDescriptions.length < 2 || normalizedDescriptions.length > 4) {
380
+ throw new Error("RSA requires 2-4 descriptions");
381
+ }
382
+ // Check headline lengths
383
+ for (const h of normalizedHeadlines) {
384
+ if (h.text.length > 30) {
385
+ throw new Error(`Headline too long (${h.text.length} chars, max 30): "${h.text}"`);
386
+ }
387
+ }
388
+ // Check description lengths (account for customizer tokens that render shorter)
389
+ const CUSTOMIZER_RE = /\{CUSTOMIZER\.[^}]+\}/g;
390
+ const CUSTOMIZER_RENDER = 16;
391
+ for (const d of normalizedDescriptions) {
392
+ let effectiveLen = d.text.length;
393
+ const matches = d.text.match(CUSTOMIZER_RE);
394
+ if (matches) {
395
+ for (const m of matches) {
396
+ effectiveLen -= m.length;
397
+ effectiveLen += CUSTOMIZER_RENDER;
398
+ }
399
+ }
400
+ if (effectiveLen > 90) {
401
+ throw new Error(`Description too long (${effectiveLen} chars, max 90): "${d.text}"`);
402
+ }
403
+ }
404
+ // Map pinned_position to ServedAssetFieldType enum values
405
+ const HEADLINE_PIN_MAP = { 1: 2, 2: 3, 3: 4 }; // HEADLINE_1=2, HEADLINE_2=3, HEADLINE_3=4
406
+ const DESCRIPTION_PIN_MAP = { 1: 5, 2: 6 }; // DESCRIPTION_1=5, DESCRIPTION_2=6
407
+ const result = await withResilience(() => customer.adGroupAds.create([{
408
+ ad_group: `customers/${customerId.replace(/-/g, "")}/adGroups/${ad.ad_group_id}`,
409
+ status: enums.AdGroupAdStatus.PAUSED, // Always create paused
410
+ ad: {
411
+ responsive_search_ad: {
412
+ headlines: normalizedHeadlines.map(h => ({
413
+ text: h.text,
414
+ ...(h.pinned_position && HEADLINE_PIN_MAP[h.pinned_position]
415
+ ? { pinned_field: HEADLINE_PIN_MAP[h.pinned_position] }
416
+ : {}),
417
+ })),
418
+ descriptions: normalizedDescriptions.map(d => ({
419
+ text: d.text,
420
+ ...(d.pinned_position && DESCRIPTION_PIN_MAP[d.pinned_position]
421
+ ? { pinned_field: DESCRIPTION_PIN_MAP[d.pinned_position] }
422
+ : {}),
423
+ })),
424
+ path1: ad.path1,
425
+ path2: ad.path2,
426
+ },
427
+ final_urls: ad.final_urls,
428
+ },
429
+ }]), "createResponsiveSearchAd");
430
+ return result;
431
+ }
432
+ // Create keywords (paused by default, auto-labeled for discoverability)
433
+ async createKeywords(customerId, keywords) {
434
+ const customer = this.getCustomer(customerId);
435
+ const cleanId = customerId.replace(/-/g, "");
436
+ const keywordCriteria = keywords.keywords.map(kw => ({
437
+ ad_group: `customers/${cleanId}/adGroups/${keywords.ad_group_id}`,
438
+ status: enums.AdGroupCriterionStatus.PAUSED, // Always create paused
439
+ keyword: {
440
+ text: kw.text,
441
+ match_type: enums.KeywordMatchType[kw.match_type],
442
+ },
443
+ }));
444
+ const result = await withResilience(() => customer.adGroupCriteria.create(keywordCriteria), "createKeywords");
445
+ // Auto-label created keywords for discoverability
446
+ const labelName = keywords.label || `${this.config.defaults.label_prefix}pending`;
447
+ try {
448
+ const labelRN = await this.ensureLabelExists(customerId, labelName);
449
+ const criterionRNs = result.results.map((r) => r.resource_name);
450
+ await this.labelAdGroupCriteria(customerId, criterionRNs, labelRN);
451
+ }
452
+ catch (e) {
453
+ // Labeling is best-effort — don't fail the keyword creation
454
+ console.error(`[WARN] Failed to label keywords with '${labelName}': ${e.message}`);
455
+ }
456
+ return result;
457
+ }
458
+ // Pause keywords (ad group criteria)
459
+ async pauseKeywords(customerId, criterionResourceNames) {
460
+ const customer = this.getCustomer(customerId);
461
+ const operations = criterionResourceNames.map(rn => ({
462
+ resource_name: rn,
463
+ status: enums.AdGroupCriterionStatus.PAUSED,
464
+ }));
465
+ const result = await withResilience(() => customer.adGroupCriteria.update(operations), "pauseKeywords");
466
+ return result;
467
+ }
468
+ // Create a new shared negative keyword list at account level
469
+ async createSharedSet(customerId, name) {
470
+ const customer = this.getCustomer(customerId);
471
+ const sharedSet = {
472
+ name,
473
+ type: enums.SharedSetType.NEGATIVE_KEYWORDS,
474
+ status: enums.SharedSetStatus.ENABLED,
475
+ };
476
+ const result = await withResilience(() => customer.sharedSets.create([sharedSet]), "createSharedSet");
477
+ return result;
478
+ }
479
+ // Link a shared set to campaigns
480
+ async linkSharedSetToCampaigns(customerId, sharedSetId, campaignIds) {
481
+ const customer = this.getCustomer(customerId);
482
+ const cleanId = customerId.replace(/-/g, "");
483
+ const campaignSharedSets = campaignIds.map(cid => ({
484
+ campaign: `customers/${cleanId}/campaigns/${cid}`,
485
+ shared_set: `customers/${cleanId}/sharedSets/${sharedSetId}`,
486
+ }));
487
+ const result = await withResilience(() => customer.campaignSharedSets.create(campaignSharedSets), "linkSharedSetToCampaigns");
488
+ return result;
489
+ }
490
+ // Add keywords to a shared negative keyword list
491
+ async addSharedNegativeKeywords(customerId, sharedSetId, keywords) {
492
+ const customer = this.getCustomer(customerId);
493
+ const cleanId = customerId.replace(/-/g, "");
494
+ const sharedCriteria = keywords.map(kw => ({
495
+ shared_set: `customers/${cleanId}/sharedSets/${sharedSetId}`,
496
+ keyword: {
497
+ text: kw.text,
498
+ match_type: enums.KeywordMatchType[kw.match_type],
499
+ },
500
+ }));
501
+ const result = await withResilience(() => customer.sharedCriteria.create(sharedCriteria), "addSharedNegativeKeywords");
502
+ return result;
503
+ }
504
+ // Unlink a shared set from campaigns
505
+ async unlinkSharedSetFromCampaigns(customerId, sharedSetId, campaignIds) {
506
+ const customer = this.getCustomer(customerId);
507
+ const cleanId = customerId.replace(/-/g, "");
508
+ const resourceNames = campaignIds.map(cid => `customers/${cleanId}/campaignSharedSets/${cid}~${sharedSetId}`);
509
+ const result = await withResilience(() => customer.campaignSharedSets.remove(resourceNames), "unlinkSharedSetFromCampaigns");
510
+ return result;
511
+ }
512
+ // Remove negative keywords from a shared negative keyword list
513
+ async removeSharedNegativeKeywords(customerId, resourceNames) {
514
+ const customer = this.getCustomer(customerId);
515
+ const result = await withResilience(() => customer.sharedCriteria.remove(resourceNames), "removeSharedNegativeKeywords");
516
+ return result;
517
+ }
518
+ // Add campaign-level negative keywords
519
+ async addCampaignNegativeKeywords(customerId, campaignId, keywords) {
520
+ const customer = this.getCustomer(customerId);
521
+ const cleanId = customerId.replace(/-/g, "");
522
+ const criteria = keywords.map(kw => ({
523
+ campaign: `customers/${cleanId}/campaigns/${campaignId}`,
524
+ negative: true,
525
+ keyword: {
526
+ text: kw.text,
527
+ match_type: enums.KeywordMatchType[kw.match_type],
528
+ },
529
+ }));
530
+ const result = await withResilience(() => customer.campaignCriteria.create(criteria), "addCampaignNegativeKeywords");
531
+ return result;
532
+ }
533
+ // Remove campaign-level negative keywords by resource name
534
+ async removeCampaignNegativeKeywords(customerId, resourceNames) {
535
+ const customer = this.getCustomer(customerId);
536
+ const result = await withResilience(() => customer.campaignCriteria.remove(resourceNames), "removeCampaignNegativeKeywords");
537
+ return result;
538
+ }
539
+ // Remove ad-group-level negative keywords by resource name
540
+ async removeAdGroupNegativeKeywords(customerId, resourceNames) {
541
+ const customer = this.getCustomer(customerId);
542
+ const result = await withResilience(() => customer.adGroupCriteria.remove(resourceNames), "removeAdGroupNegativeKeywords");
543
+ return result;
544
+ }
545
+ // Enable ads (requires approval in MCP)
546
+ async enableAds(customerId, adIds) {
547
+ const customer = this.getCustomer(customerId);
548
+ const operations = adIds.map(adId => ({
549
+ resource_name: `customers/${customerId.replace(/-/g, "")}/adGroupAds/${adId}`,
550
+ status: enums.AdGroupAdStatus.ENABLED,
551
+ }));
552
+ const result = await withResilience(() => customer.adGroupAds.update(operations), "enableAds");
553
+ return result;
554
+ }
555
+ // Enable ad groups
556
+ async enableAdGroups(customerId, adGroupIds) {
557
+ const customer = this.getCustomer(customerId);
558
+ const operations = adGroupIds.map(id => ({
559
+ resource_name: `customers/${customerId.replace(/-/g, "")}/adGroups/${id}`,
560
+ status: enums.AdGroupStatus.ENABLED,
561
+ }));
562
+ const result = await withResilience(() => customer.adGroups.update(operations), "enableAdGroups");
563
+ return result;
564
+ }
565
+ // Enable campaigns
566
+ async enableCampaigns(customerId, campaignIds) {
567
+ const customer = this.getCustomer(customerId);
568
+ const operations = campaignIds.map(id => ({
569
+ resource_name: `customers/${customerId.replace(/-/g, "")}/campaigns/${id}`,
570
+ status: enums.CampaignStatus.ENABLED,
571
+ }));
572
+ const result = await withResilience(() => customer.campaigns.update(operations), "enableCampaigns");
573
+ return result;
574
+ }
575
+ // Pause ads
576
+ async pauseAds(customerId, adIds) {
577
+ const customer = this.getCustomer(customerId);
578
+ const operations = adIds.map(adId => ({
579
+ resource_name: `customers/${customerId.replace(/-/g, "")}/adGroupAds/${adId}`,
580
+ status: enums.AdGroupAdStatus.PAUSED,
581
+ }));
582
+ const result = await withResilience(() => customer.adGroupAds.update(operations), "pauseAds");
583
+ return result;
584
+ }
585
+ // Pause ad groups
586
+ async pauseAdGroups(customerId, adGroupIds) {
587
+ const customer = this.getCustomer(customerId);
588
+ const operations = adGroupIds.map(id => ({
589
+ resource_name: `customers/${customerId.replace(/-/g, "")}/adGroups/${id}`,
590
+ status: enums.AdGroupStatus.PAUSED,
591
+ }));
592
+ const result = await withResilience(() => customer.adGroups.update(operations), "pauseAdGroups");
593
+ return result;
594
+ }
595
+ // Pause campaigns
596
+ async pauseCampaigns(customerId, campaignIds) {
597
+ const customer = this.getCustomer(customerId);
598
+ const operations = campaignIds.map(id => ({
599
+ resource_name: `customers/${customerId.replace(/-/g, "")}/campaigns/${id}`,
600
+ status: enums.CampaignStatus.PAUSED,
601
+ }));
602
+ const result = await withResilience(() => customer.campaigns.update(operations), "pauseCampaigns");
603
+ return result;
604
+ }
605
+ // Update campaign tracking parameters (final_url_suffix, tracking_url_template, custom params)
606
+ async updateCampaignTracking(customerId, campaignId, updates) {
607
+ const customer = this.getCustomer(customerId);
608
+ const cleanId = customerId.replace(/-/g, "");
609
+ const campaignUpdate = {
610
+ resource_name: `customers/${cleanId}/campaigns/${campaignId}`,
611
+ };
612
+ if (updates.final_url_suffix !== undefined) {
613
+ campaignUpdate.final_url_suffix = updates.final_url_suffix;
614
+ }
615
+ if (updates.tracking_url_template !== undefined) {
616
+ campaignUpdate.tracking_url_template = updates.tracking_url_template;
617
+ }
618
+ if (updates.url_custom_parameters !== undefined) {
619
+ campaignUpdate.url_custom_parameters = updates.url_custom_parameters;
620
+ }
621
+ const result = await withResilience(() => customer.campaigns.update([campaignUpdate]), "updateCampaignTracking");
622
+ return result;
623
+ }
624
+ // ============================================
625
+ // REPORTING METHODS
626
+ // ============================================
627
+ // Get keyword performance report
628
+ async getKeywordPerformance(customerId, options) {
629
+ const customer = this.getCustomer(customerId);
630
+ let query = `
631
+ SELECT
632
+ campaign.id,
633
+ campaign.name,
634
+ ad_group.id,
635
+ ad_group.name,
636
+ ad_group_criterion.criterion_id,
637
+ ad_group_criterion.keyword.text,
638
+ ad_group_criterion.keyword.match_type,
639
+ ad_group_criterion.status,
640
+ ad_group_criterion.quality_info.quality_score,
641
+ ad_group_criterion.quality_info.creative_quality_score,
642
+ ad_group_criterion.quality_info.post_click_quality_score,
643
+ ad_group_criterion.quality_info.search_predicted_ctr,
644
+ metrics.impressions,
645
+ metrics.clicks,
646
+ metrics.ctr,
647
+ metrics.cost_micros,
648
+ metrics.average_cpc,
649
+ metrics.conversions,
650
+ metrics.conversions_value,
651
+ metrics.all_conversions,
652
+ metrics.all_conversions_value,
653
+ metrics.cost_per_conversion,
654
+ metrics.conversions_from_interactions_rate,
655
+ metrics.search_impression_share,
656
+ metrics.search_top_impression_share,
657
+ metrics.search_absolute_top_impression_share,
658
+ metrics.search_rank_lost_impression_share
659
+ FROM keyword_view
660
+ WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
661
+ AND ad_group_criterion.status != 'REMOVED'
662
+ `;
663
+ if (options.keywordTextContains) {
664
+ query += ` AND ad_group_criterion.keyword.text LIKE '%${options.keywordTextContains}%'`;
665
+ }
666
+ if (options.campaignIds && options.campaignIds.length > 0) {
667
+ query += ` AND campaign.id IN (${options.campaignIds.join(",")})`;
668
+ }
669
+ if (options.adGroupIds && options.adGroupIds.length > 0) {
670
+ query += ` AND ad_group.id IN (${options.adGroupIds.join(",")})`;
671
+ }
672
+ query += ` ORDER BY metrics.cost_micros DESC`;
673
+ const result = await withResilience(() => customer.query(query), "getKeywordPerformance");
674
+ return safeResponse(result, "getKeywordPerformance");
675
+ }
676
+ // Get keyword performance with conversion breakdowns
677
+ async getKeywordPerformanceWithConversions(customerId, options) {
678
+ const customer = this.getCustomer(customerId);
679
+ let query = `
680
+ SELECT
681
+ campaign.id,
682
+ campaign.name,
683
+ ad_group.id,
684
+ ad_group.name,
685
+ ad_group_criterion.criterion_id,
686
+ ad_group_criterion.keyword.text,
687
+ ad_group_criterion.keyword.match_type,
688
+ ad_group_criterion.status,
689
+ ad_group_criterion.quality_info.quality_score,
690
+ ad_group_criterion.quality_info.creative_quality_score,
691
+ ad_group_criterion.quality_info.post_click_quality_score,
692
+ ad_group_criterion.quality_info.search_predicted_ctr,
693
+ metrics.conversions,
694
+ metrics.conversions_value,
695
+ metrics.all_conversions,
696
+ metrics.all_conversions_value,
697
+ segments.conversion_action_name,
698
+ segments.conversion_action
699
+ FROM keyword_view
700
+ WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
701
+ AND ad_group_criterion.status != 'REMOVED'
702
+ `;
703
+ if (options.keywordTextContains) {
704
+ query += ` AND ad_group_criterion.keyword.text LIKE '%${options.keywordTextContains}%'`;
705
+ }
706
+ if (options.campaignIds && options.campaignIds.length > 0) {
707
+ query += ` AND campaign.id IN (${options.campaignIds.join(",")})`;
708
+ }
709
+ if (options.adGroupIds && options.adGroupIds.length > 0) {
710
+ query += ` AND ad_group.id IN (${options.adGroupIds.join(",")})`;
711
+ }
712
+ query += ` ORDER BY ad_group_criterion.keyword.text, segments.conversion_action_name`;
713
+ const result = await withResilience(() => customer.query(query), "getKeywordPerformanceWithConversions");
714
+ return safeResponse(result, "getKeywordPerformanceWithConversions");
715
+ }
716
+ // Get search term report
717
+ async getSearchTermReport(customerId, options) {
718
+ const customer = this.getCustomer(customerId);
719
+ let query = `
720
+ SELECT
721
+ campaign.id,
722
+ campaign.name,
723
+ ad_group.id,
724
+ ad_group.name,
725
+ search_term_view.search_term,
726
+ search_term_view.status,
727
+ metrics.impressions,
728
+ metrics.clicks,
729
+ metrics.ctr,
730
+ metrics.cost_micros,
731
+ metrics.average_cpc,
732
+ metrics.conversions,
733
+ metrics.conversions_value,
734
+ metrics.all_conversions,
735
+ metrics.all_conversions_value,
736
+ metrics.cost_per_conversion,
737
+ metrics.conversions_from_interactions_rate
738
+ FROM search_term_view
739
+ WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
740
+ `;
741
+ if (options.searchTermContains) {
742
+ query += ` AND search_term_view.search_term LIKE '%${options.searchTermContains}%'`;
743
+ }
744
+ if (options.campaignIds && options.campaignIds.length > 0) {
745
+ query += ` AND campaign.id IN (${options.campaignIds.join(",")})`;
746
+ }
747
+ if (options.adGroupIds && options.adGroupIds.length > 0) {
748
+ query += ` AND ad_group.id IN (${options.adGroupIds.join(",")})`;
749
+ }
750
+ query += ` ORDER BY metrics.impressions DESC`;
751
+ const result = await withResilience(() => customer.query(query), "getSearchTermReport");
752
+ return safeResponse(result, "getSearchTermReport");
753
+ }
754
+ // Get search term report with conversion breakdowns
755
+ async getSearchTermReportWithConversions(customerId, options) {
756
+ const customer = this.getCustomer(customerId);
757
+ let query = `
758
+ SELECT
759
+ campaign.id,
760
+ campaign.name,
761
+ ad_group.id,
762
+ ad_group.name,
763
+ search_term_view.search_term,
764
+ search_term_view.status,
765
+ metrics.impressions,
766
+ metrics.clicks,
767
+ metrics.ctr,
768
+ metrics.cost_micros,
769
+ metrics.average_cpc,
770
+ metrics.conversions,
771
+ metrics.all_conversions,
772
+ segments.conversion_action_name,
773
+ segments.conversion_action
774
+ FROM search_term_view
775
+ WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
776
+ `;
777
+ if (options.searchTermContains) {
778
+ query += ` AND search_term_view.search_term LIKE '%${options.searchTermContains}%'`;
779
+ }
780
+ if (options.campaignIds && options.campaignIds.length > 0) {
781
+ query += ` AND campaign.id IN (${options.campaignIds.join(",")})`;
782
+ }
783
+ if (options.adGroupIds && options.adGroupIds.length > 0) {
784
+ query += ` AND ad_group.id IN (${options.adGroupIds.join(",")})`;
785
+ }
786
+ query += ` ORDER BY search_term_view.search_term, segments.conversion_action_name`;
787
+ const result = await withResilience(() => customer.query(query), "getSearchTermReportWithConversions");
788
+ return safeResponse(result, "getSearchTermReportWithConversions");
789
+ }
790
+ // Get ad performance report
791
+ async getAdPerformance(customerId, options) {
792
+ const customer = this.getCustomer(customerId);
793
+ let query = `
794
+ SELECT
795
+ campaign.id,
796
+ campaign.name,
797
+ ad_group.id,
798
+ ad_group.name,
799
+ ad_group_ad.ad.id,
800
+ ad_group_ad.ad.type,
801
+ ad_group_ad.ad.final_urls,
802
+ ad_group_ad.ad.responsive_search_ad.headlines,
803
+ ad_group_ad.ad.responsive_search_ad.descriptions,
804
+ ad_group_ad.ad.responsive_search_ad.path1,
805
+ ad_group_ad.ad.responsive_search_ad.path2,
806
+ ad_group_ad.ad_strength,
807
+ ad_group_ad.status,
808
+ ad_group_ad.policy_summary.approval_status,
809
+ ad_group_ad.policy_summary.review_status,
810
+ metrics.impressions,
811
+ metrics.clicks,
812
+ metrics.ctr,
813
+ metrics.cost_micros,
814
+ metrics.average_cpc,
815
+ metrics.conversions,
816
+ metrics.conversions_value,
817
+ metrics.all_conversions,
818
+ metrics.all_conversions_value,
819
+ metrics.cost_per_conversion,
820
+ metrics.conversions_from_interactions_rate
821
+ FROM ad_group_ad
822
+ WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
823
+ AND ad_group_ad.status != 'REMOVED'
824
+ `;
825
+ if (options.campaignIds && options.campaignIds.length > 0) {
826
+ query += ` AND campaign.id IN (${options.campaignIds.join(",")})`;
827
+ }
828
+ if (options.adGroupIds && options.adGroupIds.length > 0) {
829
+ query += ` AND ad_group.id IN (${options.adGroupIds.join(",")})`;
830
+ }
831
+ query += ` ORDER BY metrics.impressions DESC`;
832
+ const result = await withResilience(() => customer.query(query), "getAdPerformance");
833
+ return safeResponse(result, "getAdPerformance");
834
+ }
835
+ // Get ad performance with conversion breakdowns
836
+ async getAdPerformanceWithConversions(customerId, options) {
837
+ const customer = this.getCustomer(customerId);
838
+ let query = `
839
+ SELECT
840
+ campaign.id,
841
+ campaign.name,
842
+ ad_group.id,
843
+ ad_group.name,
844
+ ad_group_ad.ad.id,
845
+ ad_group_ad.ad.type,
846
+ ad_group_ad.ad.final_urls,
847
+ ad_group_ad.ad.responsive_search_ad.headlines,
848
+ ad_group_ad.ad.responsive_search_ad.descriptions,
849
+ ad_group_ad.ad.responsive_search_ad.path1,
850
+ ad_group_ad.ad.responsive_search_ad.path2,
851
+ ad_group_ad.ad_strength,
852
+ ad_group_ad.status,
853
+ ad_group_ad.policy_summary.approval_status,
854
+ ad_group_ad.policy_summary.review_status,
855
+ metrics.impressions,
856
+ metrics.clicks,
857
+ metrics.ctr,
858
+ metrics.cost_micros,
859
+ metrics.average_cpc,
860
+ metrics.conversions,
861
+ metrics.all_conversions,
862
+ segments.conversion_action_name,
863
+ segments.conversion_action
864
+ FROM ad_group_ad
865
+ WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
866
+ AND ad_group_ad.status != 'REMOVED'
867
+ `;
868
+ if (options.campaignIds && options.campaignIds.length > 0) {
869
+ query += ` AND campaign.id IN (${options.campaignIds.join(",")})`;
870
+ }
871
+ if (options.adGroupIds && options.adGroupIds.length > 0) {
872
+ query += ` AND ad_group.id IN (${options.adGroupIds.join(",")})`;
873
+ }
874
+ query += ` ORDER BY ad_group_ad.ad.id, segments.conversion_action_name`;
875
+ const result = await withResilience(() => customer.query(query), "getAdPerformanceWithConversions");
876
+ return safeResponse(result, "getAdPerformanceWithConversions");
877
+ }
878
+ // List available conversion actions
879
+ async listConversionActions(customerId) {
880
+ const customer = this.getCustomer(customerId);
881
+ const query = `
882
+ SELECT
883
+ conversion_action.id,
884
+ conversion_action.name,
885
+ conversion_action.category,
886
+ conversion_action.type,
887
+ conversion_action.status
888
+ FROM conversion_action
889
+ WHERE conversion_action.status = 'ENABLED'
890
+ ORDER BY conversion_action.name
891
+ `;
892
+ const result = await withResilience(() => customer.query(query), "listConversionActions");
893
+ return safeResponse(result, "listConversionActions");
894
+ }
895
+ // Validate an ad without creating it
896
+ async validateAd(customerId, ad) {
897
+ const errors = [];
898
+ // Check headline count
899
+ if (ad.headlines.length < 3) {
900
+ errors.push(`Need at least 3 headlines, got ${ad.headlines.length}`);
901
+ }
902
+ if (ad.headlines.length > 15) {
903
+ errors.push(`Maximum 15 headlines, got ${ad.headlines.length}`);
904
+ }
905
+ // Check description count
906
+ if (ad.descriptions.length < 2) {
907
+ errors.push(`Need at least 2 descriptions, got ${ad.descriptions.length}`);
908
+ }
909
+ if (ad.descriptions.length > 4) {
910
+ errors.push(`Maximum 4 descriptions, got ${ad.descriptions.length}`);
911
+ }
912
+ // Check headline lengths
913
+ ad.headlines.forEach((h, i) => {
914
+ if (h.length > 30) {
915
+ errors.push(`Headline ${i + 1} too long (${h.length}/30): "${h}"`);
916
+ }
917
+ });
918
+ // Check description lengths (account for customizer tokens that render shorter)
919
+ const CUSTOMIZER_PATTERN = /\{CUSTOMIZER\.[^}]+\}/g;
920
+ const CUSTOMIZER_RENDER_LEN = 16; // conservative estimate of rendered length
921
+ ad.descriptions.forEach((d, i) => {
922
+ let effectiveLen = d.length;
923
+ const matches = d.match(CUSTOMIZER_PATTERN);
924
+ if (matches) {
925
+ for (const m of matches) {
926
+ effectiveLen -= m.length;
927
+ effectiveLen += CUSTOMIZER_RENDER_LEN;
928
+ }
929
+ }
930
+ if (effectiveLen > 90) {
931
+ errors.push(`Description ${i + 1} too long (${effectiveLen}/90): "${d}"`);
932
+ }
933
+ });
934
+ // Check final URLs
935
+ if (ad.final_urls.length === 0) {
936
+ errors.push("At least one final URL is required");
937
+ }
938
+ return {
939
+ valid: errors.length === 0,
940
+ errors,
941
+ };
942
+ }
943
+ // Get search term category insights for a campaign (with trend comparison)
944
+ async getSearchTermInsights(customerId, options) {
945
+ const customer = this.getCustomer(customerId);
946
+ // Current period - get categories with metrics
947
+ const currentQuery = `
948
+ SELECT
949
+ campaign_search_term_insight.campaign_id,
950
+ campaign_search_term_insight.category_label,
951
+ campaign_search_term_insight.id,
952
+ metrics.clicks,
953
+ metrics.impressions,
954
+ metrics.conversions,
955
+ metrics.conversions_value
956
+ FROM campaign_search_term_insight
957
+ WHERE campaign_search_term_insight.campaign_id = '${options.campaignId}'
958
+ AND segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
959
+ `;
960
+ const currentResults = await withResilience(() => customer.query(currentQuery), "getSearchTermInsights.current");
961
+ // If comparison dates provided, get previous period too
962
+ let previousResults = [];
963
+ if (options.compareStartDate && options.compareEndDate) {
964
+ const prevQuery = `
965
+ SELECT
966
+ campaign_search_term_insight.campaign_id,
967
+ campaign_search_term_insight.category_label,
968
+ campaign_search_term_insight.id,
969
+ metrics.clicks,
970
+ metrics.impressions,
971
+ metrics.conversions,
972
+ metrics.conversions_value
973
+ FROM campaign_search_term_insight
974
+ WHERE campaign_search_term_insight.campaign_id = '${options.campaignId}'
975
+ AND segments.date BETWEEN '${options.compareStartDate}' AND '${options.compareEndDate}'
976
+ `;
977
+ previousResults = await withResilience(() => customer.query(prevQuery), "getSearchTermInsights.previous");
978
+ }
979
+ // Build previous period lookup by category label
980
+ const prevByCategory = {};
981
+ for (const row of previousResults) {
982
+ const label = row.campaign_search_term_insight?.category_label || "unknown";
983
+ if (!prevByCategory[label]) {
984
+ prevByCategory[label] = { clicks: 0, impressions: 0, conversions: 0, conversions_value: 0 };
985
+ }
986
+ prevByCategory[label].clicks += row.metrics?.clicks || 0;
987
+ prevByCategory[label].impressions += row.metrics?.impressions || 0;
988
+ prevByCategory[label].conversions += row.metrics?.conversions || 0;
989
+ prevByCategory[label].conversions_value += row.metrics?.conversions_value || 0;
990
+ }
991
+ // Aggregate current period and compute trends
992
+ const currentByCategory = {};
993
+ for (const row of currentResults) {
994
+ const label = row.campaign_search_term_insight?.category_label || "unknown";
995
+ const id = row.campaign_search_term_insight?.id;
996
+ if (!currentByCategory[label]) {
997
+ currentByCategory[label] = { id, clicks: 0, impressions: 0, conversions: 0, conversions_value: 0 };
998
+ }
999
+ currentByCategory[label].clicks += row.metrics?.clicks || 0;
1000
+ currentByCategory[label].impressions += row.metrics?.impressions || 0;
1001
+ currentByCategory[label].conversions += row.metrics?.conversions || 0;
1002
+ currentByCategory[label].conversions_value += row.metrics?.conversions_value || 0;
1003
+ }
1004
+ // Build output with trends
1005
+ const categories = Object.entries(currentByCategory).map(([label, curr]) => {
1006
+ const prev = prevByCategory[label];
1007
+ const result = {
1008
+ category: label,
1009
+ insight_id: curr.id,
1010
+ current_period: {
1011
+ clicks: curr.clicks,
1012
+ impressions: curr.impressions,
1013
+ conversions: curr.conversions,
1014
+ conversions_value: curr.conversions_value,
1015
+ },
1016
+ };
1017
+ if (prev) {
1018
+ result.previous_period = {
1019
+ clicks: prev.clicks,
1020
+ impressions: prev.impressions,
1021
+ conversions: prev.conversions,
1022
+ conversions_value: prev.conversions_value,
1023
+ };
1024
+ result.trends = {
1025
+ clicks_change: prev.clicks > 0 ? `${(((curr.clicks - prev.clicks) / prev.clicks) * 100).toFixed(0)}%` : (curr.clicks > 0 ? "+∞" : "0%"),
1026
+ impressions_change: prev.impressions > 0 ? `${(((curr.impressions - prev.impressions) / prev.impressions) * 100).toFixed(0)}%` : (curr.impressions > 0 ? "+∞" : "0%"),
1027
+ conversions_change: prev.conversions > 0 ? `${(((curr.conversions - prev.conversions) / prev.conversions) * 100).toFixed(0)}%` : (curr.conversions > 0 ? "+∞" : "0%"),
1028
+ };
1029
+ }
1030
+ return result;
1031
+ });
1032
+ // Sort by clicks descending
1033
+ categories.sort((a, b) => b.current_period.clicks - a.current_period.clicks);
1034
+ return safeResponse({
1035
+ campaign_id: options.campaignId,
1036
+ current_period: { start: options.startDate, end: options.endDate },
1037
+ compare_period: options.compareStartDate ? { start: options.compareStartDate, end: options.compareEndDate } : null,
1038
+ total_categories: categories.length,
1039
+ categories,
1040
+ }, "getSearchTermInsights");
1041
+ }
1042
+ // Get individual search terms within a specific insight category
1043
+ async getSearchTermInsightTerms(customerId, options) {
1044
+ const customer = this.getCustomer(customerId);
1045
+ const query = `
1046
+ SELECT
1047
+ campaign_search_term_insight.campaign_id,
1048
+ campaign_search_term_insight.category_label,
1049
+ campaign_search_term_insight.id,
1050
+ segments.search_term,
1051
+ metrics.clicks,
1052
+ metrics.impressions,
1053
+ metrics.conversions,
1054
+ metrics.conversions_value
1055
+ FROM campaign_search_term_insight
1056
+ WHERE campaign_search_term_insight.campaign_id = '${options.campaignId}'
1057
+ AND campaign_search_term_insight.id = '${options.insightId}'
1058
+ AND segments.date BETWEEN '${options.startDate}' AND '${options.endDate}'
1059
+ `;
1060
+ const result = await withResilience(() => customer.query(query), "getSearchTermInsightTerms");
1061
+ return safeResponse(result, "getSearchTermInsightTerms");
1062
+ }
1063
+ // Execute a raw GAQL query
1064
+ async executeGaql(customerId, query) {
1065
+ const customer = this.getCustomer(customerId);
1066
+ const result = await withResilience(() => customer.query(query), "executeGaql");
1067
+ return safeResponse(result, "executeGaql");
1068
+ }
1069
+ async keywordVolume(customerId, keywords, geoTargetConstants = ["geoTargetConstants/2840"], language = "languageConstants/1000") {
1070
+ const customer = this.getCustomer(customerId);
1071
+ const response = await withResilience(() => customer.keywordPlanIdeas.generateKeywordHistoricalMetrics({
1072
+ customer_id: customerId.replace(/-/g, ""),
1073
+ keywords,
1074
+ geo_target_constants: geoTargetConstants,
1075
+ language,
1076
+ keyword_plan_network: enums.KeywordPlanNetwork.GOOGLE_SEARCH,
1077
+ include_adult_keywords: false,
1078
+ }), "keywordVolume");
1079
+ const results = response.results ?? [];
1080
+ return results.map((r) => ({
1081
+ keyword: r.text,
1082
+ avg_monthly_searches: r.keyword_metrics?.avg_monthly_searches ?? null,
1083
+ competition: r.keyword_metrics?.competition ?? null,
1084
+ competition_index: r.keyword_metrics?.competition_index ?? null,
1085
+ low_top_of_page_bid_micros: r.keyword_metrics?.low_top_of_page_bid_micros ?? null,
1086
+ high_top_of_page_bid_micros: r.keyword_metrics?.high_top_of_page_bid_micros ?? null,
1087
+ }));
1088
+ }
1089
+ }
1090
+ // ============================================
1091
+ // MCP SERVER
1092
+ // ============================================
1093
+ const config = loadConfig();
1094
+ const adsManager = new GoogleAdsManager(config);
1095
+ const server = new Server({
1096
+ name: "mcp-google-ads",
1097
+ version: "1.0.0",
1098
+ }, {
1099
+ capabilities: {
1100
+ tools: {},
1101
+ },
1102
+ });
1103
+ // Handle list tools
1104
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1105
+ return { tools };
1106
+ });
1107
+ // Handle tool calls
1108
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1109
+ const { name, arguments: args } = request.params;
1110
+ try {
1111
+ switch (name) {
1112
+ case "google_ads_get_client_context": {
1113
+ const cwd = args?.working_directory;
1114
+ const client = getClientFromWorkingDir(config, cwd);
1115
+ if (!client) {
1116
+ return {
1117
+ content: [{
1118
+ type: "text",
1119
+ text: JSON.stringify({
1120
+ error: "No client found for working directory",
1121
+ working_directory: cwd,
1122
+ available_clients: Object.entries(config.clients).map(([k, v]) => ({
1123
+ key: k,
1124
+ name: v.name,
1125
+ folder: v.folder,
1126
+ })),
1127
+ }, null, 2),
1128
+ }],
1129
+ };
1130
+ }
1131
+ return {
1132
+ content: [{
1133
+ type: "text",
1134
+ text: JSON.stringify({
1135
+ client_name: client.name,
1136
+ customer_id: client.customer_id,
1137
+ folder: client.folder,
1138
+ mcc_id: client.mcc_customer_id || config.google_ads.mcc_customer_id,
1139
+ }, null, 2),
1140
+ }],
1141
+ };
1142
+ }
1143
+ case "google_ads_list_campaigns": {
1144
+ const customerId = args?.customer_id || "";
1145
+ const campaigns = await adsManager.listCampaigns(customerId);
1146
+ return {
1147
+ content: [{
1148
+ type: "text",
1149
+ text: JSON.stringify(campaigns, null, 2),
1150
+ }],
1151
+ };
1152
+ }
1153
+ case "google_ads_list_ad_groups": {
1154
+ const customerId = args?.customer_id || "";
1155
+ const campaignId = args?.campaign_id;
1156
+ const adGroups = await adsManager.listAdGroups(customerId, campaignId);
1157
+ return {
1158
+ content: [{
1159
+ type: "text",
1160
+ text: JSON.stringify(adGroups, null, 2),
1161
+ }],
1162
+ };
1163
+ }
1164
+ case "google_ads_get_campaign_tracking": {
1165
+ const customerId = args?.customer_id || "";
1166
+ const campaignId = args?.campaign_id;
1167
+ const tracking = await adsManager.getCampaignTracking(customerId, campaignId);
1168
+ return {
1169
+ content: [{
1170
+ type: "text",
1171
+ text: JSON.stringify(tracking, null, 2),
1172
+ }],
1173
+ };
1174
+ }
1175
+ case "google_ads_list_pending_changes": {
1176
+ const customerId = args?.customer_id || "";
1177
+ const pending = await adsManager.listPendingChanges(customerId);
1178
+ return {
1179
+ content: [{
1180
+ type: "text",
1181
+ text: JSON.stringify(pending, null, 2),
1182
+ }],
1183
+ };
1184
+ }
1185
+ case "google_ads_validate_ad": {
1186
+ const validation = await adsManager.validateAd("", {
1187
+ headlines: args?.headlines,
1188
+ descriptions: args?.descriptions,
1189
+ final_urls: args?.final_urls,
1190
+ });
1191
+ return {
1192
+ content: [{
1193
+ type: "text",
1194
+ text: JSON.stringify(validation, null, 2),
1195
+ }],
1196
+ };
1197
+ }
1198
+ case "google_ads_create_campaign": {
1199
+ const customerId = args?.customer_id || "";
1200
+ const result = await adsManager.createCampaign(customerId, {
1201
+ name: args?.name,
1202
+ budget_amount_micros: args?.daily_budget * 1000000,
1203
+ });
1204
+ return {
1205
+ content: [{
1206
+ type: "text",
1207
+ text: JSON.stringify({
1208
+ success: true,
1209
+ message: "Campaign created (PAUSED). Review in Google Ads before enabling.",
1210
+ result,
1211
+ }, null, 2),
1212
+ }],
1213
+ };
1214
+ }
1215
+ case "google_ads_create_ad_group": {
1216
+ const customerId = args?.customer_id || "";
1217
+ const result = await adsManager.createAdGroup(customerId, {
1218
+ name: args?.name,
1219
+ campaign_id: args?.campaign_id,
1220
+ cpc_bid_micros: args?.cpc_bid ? args.cpc_bid * 1000000 : undefined,
1221
+ });
1222
+ return {
1223
+ content: [{
1224
+ type: "text",
1225
+ text: JSON.stringify({
1226
+ success: true,
1227
+ message: "Ad group created (PAUSED). Review in Google Ads before enabling.",
1228
+ result,
1229
+ }, null, 2),
1230
+ }],
1231
+ };
1232
+ }
1233
+ case "google_ads_create_responsive_search_ad": {
1234
+ const customerId = args?.customer_id || "";
1235
+ // Normalize headlines/descriptions for validation (extract text strings)
1236
+ const rawHeadlines = args?.headlines;
1237
+ const rawDescriptions = args?.descriptions;
1238
+ const headlineTexts = rawHeadlines.map(h => typeof h === "string" ? h : h.text);
1239
+ const descriptionTexts = rawDescriptions.map(d => typeof d === "string" ? d : d.text);
1240
+ // Validate first
1241
+ const validation = await adsManager.validateAd(customerId, {
1242
+ headlines: headlineTexts,
1243
+ descriptions: descriptionTexts,
1244
+ final_urls: args?.final_urls,
1245
+ });
1246
+ if (!validation.valid) {
1247
+ return {
1248
+ content: [{
1249
+ type: "text",
1250
+ text: JSON.stringify({
1251
+ success: false,
1252
+ message: "Validation failed",
1253
+ errors: validation.errors,
1254
+ }, null, 2),
1255
+ }],
1256
+ };
1257
+ }
1258
+ const result = await adsManager.createResponsiveSearchAd(customerId, {
1259
+ ad_group_id: args?.ad_group_id,
1260
+ final_urls: args?.final_urls,
1261
+ headlines: rawHeadlines,
1262
+ descriptions: rawDescriptions,
1263
+ path1: args?.path1,
1264
+ path2: args?.path2,
1265
+ });
1266
+ return {
1267
+ content: [{
1268
+ type: "text",
1269
+ text: JSON.stringify({
1270
+ success: true,
1271
+ message: "RSA created (PAUSED). Review in Google Ads before enabling.",
1272
+ result,
1273
+ }, null, 2),
1274
+ }],
1275
+ };
1276
+ }
1277
+ case "google_ads_create_keywords": {
1278
+ const customerId = args?.customer_id || "";
1279
+ const result = await adsManager.createKeywords(customerId, {
1280
+ ad_group_id: args?.ad_group_id,
1281
+ keywords: args?.keywords,
1282
+ label: args?.label,
1283
+ });
1284
+ return {
1285
+ content: [{
1286
+ type: "text",
1287
+ text: JSON.stringify({
1288
+ success: true,
1289
+ message: "Keywords created (PAUSED) and labeled. Review in Google Ads before enabling.",
1290
+ result,
1291
+ }, null, 2),
1292
+ }],
1293
+ };
1294
+ }
1295
+ case "google_ads_enable_items": {
1296
+ const customerId = args?.customer_id || "";
1297
+ const results = {};
1298
+ if (args?.campaign_ids) {
1299
+ results.campaigns = await adsManager.enableCampaigns(customerId, args.campaign_ids);
1300
+ }
1301
+ if (args?.ad_group_ids) {
1302
+ results.adGroups = await adsManager.enableAdGroups(customerId, args.ad_group_ids);
1303
+ }
1304
+ if (args?.ad_ids) {
1305
+ results.ads = await adsManager.enableAds(customerId, args.ad_ids);
1306
+ }
1307
+ return {
1308
+ content: [{
1309
+ type: "text",
1310
+ text: JSON.stringify({
1311
+ success: true,
1312
+ message: "Items enabled and now LIVE",
1313
+ results,
1314
+ }, null, 2),
1315
+ }],
1316
+ };
1317
+ }
1318
+ case "google_ads_pause_items": {
1319
+ const customerId = args?.customer_id || "";
1320
+ const results = {};
1321
+ if (args?.campaign_ids) {
1322
+ results.campaigns = await adsManager.pauseCampaigns(customerId, args.campaign_ids);
1323
+ }
1324
+ if (args?.ad_group_ids) {
1325
+ results.adGroups = await adsManager.pauseAdGroups(customerId, args.ad_group_ids);
1326
+ }
1327
+ if (args?.ad_ids) {
1328
+ results.ads = await adsManager.pauseAds(customerId, args.ad_ids);
1329
+ }
1330
+ return {
1331
+ content: [{
1332
+ type: "text",
1333
+ text: JSON.stringify({
1334
+ success: true,
1335
+ message: "Items paused and no longer serving",
1336
+ results,
1337
+ }, null, 2),
1338
+ }],
1339
+ };
1340
+ }
1341
+ case "google_ads_update_campaign_tracking": {
1342
+ const customerId = args?.customer_id || "";
1343
+ const campaignId = args?.campaign_id;
1344
+ // Get current values for the response diff
1345
+ const currentTracking = await adsManager.getCampaignTracking(customerId, campaignId);
1346
+ const updates = {};
1347
+ if (args?.final_url_suffix !== undefined)
1348
+ updates.final_url_suffix = args.final_url_suffix;
1349
+ if (args?.tracking_url_template !== undefined)
1350
+ updates.tracking_url_template = args.tracking_url_template;
1351
+ if (args?.url_custom_parameters !== undefined)
1352
+ updates.url_custom_parameters = args.url_custom_parameters;
1353
+ const result = await adsManager.updateCampaignTracking(customerId, campaignId, updates);
1354
+ // Get updated values
1355
+ const updatedTracking = await adsManager.getCampaignTracking(customerId, campaignId);
1356
+ return {
1357
+ content: [{
1358
+ type: "text",
1359
+ text: JSON.stringify({
1360
+ success: true,
1361
+ campaign_id: campaignId,
1362
+ campaign_name: currentTracking.campaign_name,
1363
+ before: {
1364
+ final_url_suffix: currentTracking.final_url_suffix,
1365
+ tracking_url_template: currentTracking.tracking_url_template,
1366
+ url_custom_parameters: currentTracking.url_custom_parameters,
1367
+ },
1368
+ after: {
1369
+ final_url_suffix: updatedTracking.final_url_suffix,
1370
+ tracking_url_template: updatedTracking.tracking_url_template,
1371
+ url_custom_parameters: updatedTracking.url_custom_parameters,
1372
+ },
1373
+ }, null, 2),
1374
+ }],
1375
+ };
1376
+ }
1377
+ case "google_ads_create_shared_set": {
1378
+ const customerId = args?.customer_id || "";
1379
+ const result = await adsManager.createSharedSet(customerId, args?.name);
1380
+ // Extract the shared set ID from the resource name
1381
+ const resourceName = result?.results?.[0]?.resource_name || "";
1382
+ const newSetId = resourceName.split("/").pop() || "";
1383
+ return {
1384
+ content: [{
1385
+ type: "text",
1386
+ text: JSON.stringify({
1387
+ success: true,
1388
+ message: `Shared negative keyword list created: ${args?.name}`,
1389
+ shared_set_id: newSetId,
1390
+ resource_name: resourceName,
1391
+ results: result,
1392
+ }, null, 2),
1393
+ }],
1394
+ };
1395
+ }
1396
+ case "google_ads_link_shared_set": {
1397
+ const customerId = args?.customer_id || "";
1398
+ const result = await adsManager.linkSharedSetToCampaigns(customerId, args?.shared_set_id, args?.campaign_ids);
1399
+ return {
1400
+ content: [{
1401
+ type: "text",
1402
+ text: JSON.stringify({
1403
+ success: true,
1404
+ message: `Shared set linked to ${(args?.campaign_ids).length} campaigns`,
1405
+ results: result,
1406
+ }, null, 2),
1407
+ }],
1408
+ };
1409
+ }
1410
+ case "google_ads_unlink_shared_set": {
1411
+ const customerId = args?.customer_id || "";
1412
+ const result = await adsManager.unlinkSharedSetFromCampaigns(customerId, args?.shared_set_id, args?.campaign_ids);
1413
+ return {
1414
+ content: [{
1415
+ type: "text",
1416
+ text: JSON.stringify({
1417
+ success: true,
1418
+ message: `Shared set unlinked from ${(args?.campaign_ids).length} campaigns`,
1419
+ results: result,
1420
+ }, null, 2),
1421
+ }],
1422
+ };
1423
+ }
1424
+ case "google_ads_add_shared_negatives": {
1425
+ const customerId = args?.customer_id || "";
1426
+ const result = await adsManager.addSharedNegativeKeywords(customerId, args?.shared_set_id, args?.keywords);
1427
+ return {
1428
+ content: [{
1429
+ type: "text",
1430
+ text: JSON.stringify({
1431
+ success: true,
1432
+ message: "Negative keywords added to shared list",
1433
+ results: result,
1434
+ }, null, 2),
1435
+ }],
1436
+ };
1437
+ }
1438
+ case "google_ads_remove_shared_negatives": {
1439
+ const customerId = args?.customer_id || "";
1440
+ const resourceNames = args?.resource_names;
1441
+ const result = await adsManager.removeSharedNegativeKeywords(customerId, resourceNames);
1442
+ return {
1443
+ content: [{
1444
+ type: "text",
1445
+ text: JSON.stringify({
1446
+ success: true,
1447
+ message: `Removed ${resourceNames.length} negative keywords from shared list`,
1448
+ results: result,
1449
+ }, null, 2),
1450
+ }],
1451
+ };
1452
+ }
1453
+ case "google_ads_add_campaign_negatives": {
1454
+ const customerId = args?.customer_id || "";
1455
+ const result = await adsManager.addCampaignNegativeKeywords(customerId, args?.campaign_id, args?.keywords);
1456
+ return {
1457
+ content: [{
1458
+ type: "text",
1459
+ text: JSON.stringify({
1460
+ success: true,
1461
+ message: "Campaign-level negative keywords added",
1462
+ results: result,
1463
+ }, null, 2),
1464
+ }],
1465
+ };
1466
+ }
1467
+ case "google_ads_remove_campaign_negatives": {
1468
+ const customerId = args?.customer_id || "";
1469
+ const resourceNames = args?.resource_names;
1470
+ const result = await adsManager.removeCampaignNegativeKeywords(customerId, resourceNames);
1471
+ return {
1472
+ content: [{
1473
+ type: "text",
1474
+ text: JSON.stringify({
1475
+ success: true,
1476
+ message: `Removed ${resourceNames.length} campaign-level negative keywords`,
1477
+ results: result,
1478
+ }, null, 2),
1479
+ }],
1480
+ };
1481
+ }
1482
+ case "google_ads_remove_adgroup_negatives": {
1483
+ const customerId = args?.customer_id || "";
1484
+ const resourceNames = args?.resource_names;
1485
+ const result = await adsManager.removeAdGroupNegativeKeywords(customerId, resourceNames);
1486
+ return {
1487
+ content: [{
1488
+ type: "text",
1489
+ text: JSON.stringify({
1490
+ success: true,
1491
+ message: `Removed ${resourceNames.length} ad-group-level negative keywords`,
1492
+ results: result,
1493
+ }, null, 2),
1494
+ }],
1495
+ };
1496
+ }
1497
+ case "google_ads_pause_keywords": {
1498
+ const customerId = args?.customer_id || "";
1499
+ const result = await adsManager.pauseKeywords(customerId, args?.criterion_resource_names);
1500
+ return {
1501
+ content: [{
1502
+ type: "text",
1503
+ text: JSON.stringify({
1504
+ success: true,
1505
+ message: "Keywords paused",
1506
+ results: result,
1507
+ }, null, 2),
1508
+ }],
1509
+ };
1510
+ }
1511
+ // ============================================
1512
+ // REPORTING HANDLERS
1513
+ // ============================================
1514
+ case "google_ads_keyword_performance": {
1515
+ const customerId = args?.customer_id || "";
1516
+ const result = await adsManager.getKeywordPerformance(customerId, {
1517
+ startDate: args?.start_date,
1518
+ endDate: args?.end_date,
1519
+ keywordTextContains: args?.keyword_text_contains,
1520
+ campaignIds: args?.campaign_ids,
1521
+ adGroupIds: args?.ad_group_ids,
1522
+ });
1523
+ return {
1524
+ content: [{
1525
+ type: "text",
1526
+ text: JSON.stringify(result, null, 2),
1527
+ }],
1528
+ };
1529
+ }
1530
+ case "google_ads_keyword_performance_by_conversion": {
1531
+ const customerId = args?.customer_id || "";
1532
+ const result = await adsManager.getKeywordPerformanceWithConversions(customerId, {
1533
+ startDate: args?.start_date,
1534
+ endDate: args?.end_date,
1535
+ keywordTextContains: args?.keyword_text_contains,
1536
+ campaignIds: args?.campaign_ids,
1537
+ adGroupIds: args?.ad_group_ids,
1538
+ });
1539
+ return {
1540
+ content: [{
1541
+ type: "text",
1542
+ text: JSON.stringify(result, null, 2),
1543
+ }],
1544
+ };
1545
+ }
1546
+ case "google_ads_search_term_report": {
1547
+ const customerId = args?.customer_id || "";
1548
+ const result = await adsManager.getSearchTermReport(customerId, {
1549
+ startDate: args?.start_date,
1550
+ endDate: args?.end_date,
1551
+ keywordTextContains: args?.keyword_text_contains,
1552
+ searchTermContains: args?.search_term_contains,
1553
+ campaignIds: args?.campaign_ids,
1554
+ adGroupIds: args?.ad_group_ids,
1555
+ });
1556
+ return {
1557
+ content: [{
1558
+ type: "text",
1559
+ text: JSON.stringify(result, null, 2),
1560
+ }],
1561
+ };
1562
+ }
1563
+ case "google_ads_search_term_report_by_conversion": {
1564
+ const customerId = args?.customer_id || "";
1565
+ const result = await adsManager.getSearchTermReportWithConversions(customerId, {
1566
+ startDate: args?.start_date,
1567
+ endDate: args?.end_date,
1568
+ keywordTextContains: args?.keyword_text_contains,
1569
+ searchTermContains: args?.search_term_contains,
1570
+ campaignIds: args?.campaign_ids,
1571
+ adGroupIds: args?.ad_group_ids,
1572
+ });
1573
+ return {
1574
+ content: [{
1575
+ type: "text",
1576
+ text: JSON.stringify(result, null, 2),
1577
+ }],
1578
+ };
1579
+ }
1580
+ case "google_ads_ad_performance": {
1581
+ const customerId = args?.customer_id || "";
1582
+ const result = await adsManager.getAdPerformance(customerId, {
1583
+ startDate: args?.start_date,
1584
+ endDate: args?.end_date,
1585
+ campaignIds: args?.campaign_ids,
1586
+ adGroupIds: args?.ad_group_ids,
1587
+ });
1588
+ return {
1589
+ content: [{
1590
+ type: "text",
1591
+ text: JSON.stringify(result, null, 2),
1592
+ }],
1593
+ };
1594
+ }
1595
+ case "google_ads_ad_performance_by_conversion": {
1596
+ const customerId = args?.customer_id || "";
1597
+ const result = await adsManager.getAdPerformanceWithConversions(customerId, {
1598
+ startDate: args?.start_date,
1599
+ endDate: args?.end_date,
1600
+ campaignIds: args?.campaign_ids,
1601
+ adGroupIds: args?.ad_group_ids,
1602
+ });
1603
+ return {
1604
+ content: [{
1605
+ type: "text",
1606
+ text: JSON.stringify(result, null, 2),
1607
+ }],
1608
+ };
1609
+ }
1610
+ case "google_ads_list_conversion_actions": {
1611
+ const customerId = args?.customer_id || "";
1612
+ const result = await adsManager.listConversionActions(customerId);
1613
+ return {
1614
+ content: [{
1615
+ type: "text",
1616
+ text: JSON.stringify(result, null, 2),
1617
+ }],
1618
+ };
1619
+ }
1620
+ case "google_ads_search_term_insights": {
1621
+ const customerId = args?.customer_id || "";
1622
+ const result = await adsManager.getSearchTermInsights(customerId, {
1623
+ campaignId: args?.campaign_id,
1624
+ startDate: args?.start_date,
1625
+ endDate: args?.end_date,
1626
+ compareStartDate: args?.compare_start_date,
1627
+ compareEndDate: args?.compare_end_date,
1628
+ });
1629
+ return {
1630
+ content: [{
1631
+ type: "text",
1632
+ text: JSON.stringify(result, null, 2),
1633
+ }],
1634
+ };
1635
+ }
1636
+ case "google_ads_search_term_insight_terms": {
1637
+ const customerId = args?.customer_id || "";
1638
+ const result = await adsManager.getSearchTermInsightTerms(customerId, {
1639
+ campaignId: args?.campaign_id,
1640
+ insightId: args?.insight_id,
1641
+ startDate: args?.start_date,
1642
+ endDate: args?.end_date,
1643
+ });
1644
+ return {
1645
+ content: [{
1646
+ type: "text",
1647
+ text: JSON.stringify(result, null, 2),
1648
+ }],
1649
+ };
1650
+ }
1651
+ case "google_ads_update_campaign_budget": {
1652
+ const customerId = args?.customer_id || "";
1653
+ const campaignId = args?.campaign_id;
1654
+ const dailyBudget = args?.daily_budget;
1655
+ const createNew = args?.create_new_budget || false;
1656
+ const result = await adsManager.updateCampaignBudget(customerId, campaignId, dailyBudget, createNew);
1657
+ return {
1658
+ content: [{
1659
+ type: "text",
1660
+ text: JSON.stringify({
1661
+ success: true,
1662
+ ...result,
1663
+ }, null, 2),
1664
+ }],
1665
+ };
1666
+ }
1667
+ case "google_ads_gaql_query": {
1668
+ const customerId = args?.customer_id || "";
1669
+ const query = args?.query;
1670
+ const result = await adsManager.executeGaql(customerId, query);
1671
+ return {
1672
+ content: [{
1673
+ type: "text",
1674
+ text: JSON.stringify(result, null, 2),
1675
+ }],
1676
+ };
1677
+ }
1678
+ case "google_ads_keyword_volume": {
1679
+ const customerId = args?.customer_id || "";
1680
+ const keywords = args?.keywords;
1681
+ const geoTargetConstants = args?.geo_target_constants;
1682
+ const language = args?.language;
1683
+ const result = await adsManager.keywordVolume(customerId, keywords, geoTargetConstants, language);
1684
+ return {
1685
+ content: [{
1686
+ type: "text",
1687
+ text: JSON.stringify(result, null, 2),
1688
+ }],
1689
+ };
1690
+ }
1691
+ default:
1692
+ throw new Error(`Unknown tool: ${name}`);
1693
+ }
1694
+ }
1695
+ catch (rawError) {
1696
+ const error = classifyError(rawError);
1697
+ // Log classified error type for debugging
1698
+ logger.error({ errorType: error.name, tool: name }, error.message);
1699
+ const response = {
1700
+ error: true,
1701
+ error_type: error.name,
1702
+ message: error.message,
1703
+ };
1704
+ if (error instanceof GoogleAdsAuthError) {
1705
+ response.action_required = "Re-authenticate: check refresh token in macOS Keychain. Token may be expired or revoked.";
1706
+ response.hint = "Run: security find-generic-password -a google-ads-drak -s GOOGLE_ADS_REFRESH_TOKEN -w";
1707
+ }
1708
+ else if (error instanceof GoogleAdsRateLimitError) {
1709
+ response.retry_after_ms = error.retryAfterMs;
1710
+ response.action_required = `Rate limited. Retry after ${Math.ceil(error.retryAfterMs / 1000)} seconds.`;
1711
+ }
1712
+ else if (error instanceof GoogleAdsServiceError) {
1713
+ response.action_required = "Google Ads API server error. This is transient — retry in a few minutes.";
1714
+ }
1715
+ else {
1716
+ response.details = rawError.errors || rawError.stack;
1717
+ }
1718
+ return {
1719
+ content: [{
1720
+ type: "text",
1721
+ text: JSON.stringify(response, null, 2),
1722
+ }],
1723
+ isError: true,
1724
+ };
1725
+ }
1726
+ });
1727
+ // Start server
1728
+ async function main() {
1729
+ // Startup health check: verify credentials work with a lightweight API call
1730
+ try {
1731
+ const firstClient = Object.values(config.clients)[0];
1732
+ if (firstClient) {
1733
+ const customer = adsManager.getCustomer(firstClient.customer_id);
1734
+ await withResilience(() => customer.query(`SELECT customer.id FROM customer LIMIT 1`), "startup.authCheck");
1735
+ logger.info({ customerId: firstClient.customer_id, clientName: firstClient.name }, "Auth verified: successfully queried account");
1736
+ }
1737
+ }
1738
+ catch (err) {
1739
+ const classified = classifyError(err);
1740
+ if (classified instanceof GoogleAdsAuthError) {
1741
+ logger.error({ error: classified.message }, "Auth check FAILED — MCP will start but ALL API calls will fail until auth is fixed");
1742
+ }
1743
+ else {
1744
+ logger.warn({ error: err.message }, "Auth check returned non-auth error (may be OK)");
1745
+ }
1746
+ }
1747
+ const transport = new StdioServerTransport();
1748
+ await server.connect(transport);
1749
+ logger.info("MCP Google Ads server running");
1750
+ }
1751
+ main().catch((err) => logger.error({ error: err.message, stack: err.stack }, "Fatal startup error"));