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/LICENSE +21 -0
- package/README.md +263 -0
- package/config.example.json +26 -0
- package/dist/build-info.json +1 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +117 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1751 -0
- package/dist/resilience.d.ts +3 -0
- package/dist/resilience.js +99 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +574 -0
- package/package.json +71 -0
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"));
|