@channel47/google-ads-mcp 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 +195 -0
- package/package.json +48 -0
- package/server/auth.js +74 -0
- package/server/index.js +199 -0
- package/server/prompts/templates.js +231 -0
- package/server/resources/index.js +67 -0
- package/server/tools/gaql-query.js +141 -0
- package/server/tools/list-accounts.js +61 -0
- package/server/tools/mutate.js +64 -0
- package/server/utils/gaql-templates.js +417 -0
- package/server/utils/mutations.js +436 -0
- package/server/utils/query-validator.js +74 -0
- package/server/utils/response-format.js +138 -0
- package/server/utils/validation.js +166 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutation execution service for Google Ads write operations
|
|
3
|
+
* All mutations support dry_run mode for validation without changes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getCustomerClient } from '../auth.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute Google Ads mutate operations
|
|
10
|
+
* @param {string} customerId - Google Ads customer ID
|
|
11
|
+
* @param {string} resourceType - Type of resource to mutate
|
|
12
|
+
* @param {Array} operations - Array of mutate operations
|
|
13
|
+
* @param {boolean} validateOnly - If true, validate without making changes (dry_run)
|
|
14
|
+
* @returns {Object} Mutation results with success/failure details
|
|
15
|
+
*/
|
|
16
|
+
export async function executeMutations(customerId, resourceType, operations, validateOnly = true) {
|
|
17
|
+
const customer = getCustomerClient(customerId);
|
|
18
|
+
|
|
19
|
+
const results = {
|
|
20
|
+
success: false,
|
|
21
|
+
dry_run: validateOnly,
|
|
22
|
+
operations_count: operations.length,
|
|
23
|
+
successful: [],
|
|
24
|
+
failed: [],
|
|
25
|
+
warnings: []
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
// Build the mutation request payload
|
|
30
|
+
const mutateOptions = {
|
|
31
|
+
partial_failure: true,
|
|
32
|
+
validate_only: validateOnly
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let response;
|
|
36
|
+
|
|
37
|
+
// Use the appropriate service method based on resource type
|
|
38
|
+
// google-ads-api v21 uses service-specific methods
|
|
39
|
+
switch (resourceType) {
|
|
40
|
+
case 'campaign_criterion':
|
|
41
|
+
response = await customer.campaignCriteria.create(operations, mutateOptions);
|
|
42
|
+
break;
|
|
43
|
+
case 'ad_group_criterion':
|
|
44
|
+
response = await customer.adGroupCriteria.create(operations, mutateOptions);
|
|
45
|
+
break;
|
|
46
|
+
case 'shared_criterion':
|
|
47
|
+
response = await customer.sharedCriteria.create(operations, mutateOptions);
|
|
48
|
+
break;
|
|
49
|
+
case 'campaign':
|
|
50
|
+
response = await customer.campaigns.update(operations, mutateOptions);
|
|
51
|
+
break;
|
|
52
|
+
case 'ad_group':
|
|
53
|
+
response = await customer.adGroups.update(operations, mutateOptions);
|
|
54
|
+
break;
|
|
55
|
+
case 'campaign_budget':
|
|
56
|
+
response = await customer.campaignBudgets.update(operations, mutateOptions);
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
throw new Error(`Unsupported resource type: ${resourceType}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Process results
|
|
63
|
+
if (response.results) {
|
|
64
|
+
response.results.forEach((result, index) => {
|
|
65
|
+
if (result.resourceName) {
|
|
66
|
+
results.successful.push({
|
|
67
|
+
index: index,
|
|
68
|
+
resource_name: result.resourceName,
|
|
69
|
+
operation: operations[index]
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check for partial failures
|
|
76
|
+
if (response.partial_failure_error) {
|
|
77
|
+
const errors = response.partial_failure_error.errors || [];
|
|
78
|
+
errors.forEach(error => {
|
|
79
|
+
results.failed.push({
|
|
80
|
+
index: error.location?.fieldPathElements?.[0]?.index || -1,
|
|
81
|
+
message: error.message,
|
|
82
|
+
error_code: error.errorCode
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
results.success = results.failed.length === 0;
|
|
88
|
+
|
|
89
|
+
} catch (error) {
|
|
90
|
+
results.failed.push({
|
|
91
|
+
index: -1,
|
|
92
|
+
message: error.message,
|
|
93
|
+
error_code: error.code || 'UNKNOWN'
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a negative keyword criterion operation
|
|
102
|
+
* @param {Object} params
|
|
103
|
+
* @param {string} params.keyword - Keyword text
|
|
104
|
+
* @param {string} params.match_type - EXACT, PHRASE, or BROAD
|
|
105
|
+
* @param {string} [params.campaign_id] - Campaign to add negative to
|
|
106
|
+
* @param {string} [params.ad_group_id] - Ad group to add negative to
|
|
107
|
+
* @returns {Object} Mutate operation
|
|
108
|
+
*/
|
|
109
|
+
export function buildNegativeKeywordOperation(params) {
|
|
110
|
+
const { keyword, match_type, campaign_id, ad_group_id } = params;
|
|
111
|
+
|
|
112
|
+
if (ad_group_id) {
|
|
113
|
+
// Ad group level negative
|
|
114
|
+
return {
|
|
115
|
+
create: {
|
|
116
|
+
ad_group: `customers/${params.customer_id}/adGroups/${ad_group_id}`,
|
|
117
|
+
negative: true,
|
|
118
|
+
keyword: {
|
|
119
|
+
text: keyword,
|
|
120
|
+
match_type: match_type
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
} else if (campaign_id) {
|
|
125
|
+
// Campaign level negative
|
|
126
|
+
return {
|
|
127
|
+
create: {
|
|
128
|
+
campaign: `customers/${params.customer_id}/campaigns/${campaign_id}`,
|
|
129
|
+
negative: true,
|
|
130
|
+
keyword: {
|
|
131
|
+
text: keyword,
|
|
132
|
+
match_type: match_type
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error('Either campaign_id or ad_group_id is required');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build a bid adjustment operation
|
|
143
|
+
* @param {Object} params
|
|
144
|
+
* @param {string} params.resource_type - keyword, product_group, or ad_group
|
|
145
|
+
* @param {string} params.resource_id - Resource ID to adjust
|
|
146
|
+
* @param {string} params.adjustment_type - SET, INCREASE_PERCENT, DECREASE_PERCENT, etc.
|
|
147
|
+
* @param {number} params.value - Adjustment value
|
|
148
|
+
* @param {number} [params.current_bid] - Current bid in micros (needed for percentage adjustments)
|
|
149
|
+
* @param {number} [params.max_bid] - Maximum bid cap
|
|
150
|
+
* @param {number} [params.min_bid] - Minimum bid floor
|
|
151
|
+
* @returns {Object} Mutate operation with calculated bid
|
|
152
|
+
*/
|
|
153
|
+
export function buildBidAdjustmentOperation(params) {
|
|
154
|
+
const {
|
|
155
|
+
customer_id,
|
|
156
|
+
resource_type,
|
|
157
|
+
resource_id,
|
|
158
|
+
adjustment_type,
|
|
159
|
+
value,
|
|
160
|
+
current_bid,
|
|
161
|
+
max_bid,
|
|
162
|
+
min_bid
|
|
163
|
+
} = params;
|
|
164
|
+
|
|
165
|
+
// Calculate new bid based on adjustment type
|
|
166
|
+
let newBidMicros;
|
|
167
|
+
|
|
168
|
+
switch (adjustment_type) {
|
|
169
|
+
case 'SET':
|
|
170
|
+
newBidMicros = Math.round(value * 1000000);
|
|
171
|
+
break;
|
|
172
|
+
case 'INCREASE_PERCENT':
|
|
173
|
+
if (!current_bid) throw new Error('current_bid required for percentage adjustments');
|
|
174
|
+
newBidMicros = Math.round(current_bid * (1 + value / 100));
|
|
175
|
+
break;
|
|
176
|
+
case 'DECREASE_PERCENT':
|
|
177
|
+
if (!current_bid) throw new Error('current_bid required for percentage adjustments');
|
|
178
|
+
newBidMicros = Math.round(current_bid * (1 - value / 100));
|
|
179
|
+
break;
|
|
180
|
+
case 'INCREASE_AMOUNT':
|
|
181
|
+
if (!current_bid) throw new Error('current_bid required for amount adjustments');
|
|
182
|
+
newBidMicros = current_bid + Math.round(value * 1000000);
|
|
183
|
+
break;
|
|
184
|
+
case 'DECREASE_AMOUNT':
|
|
185
|
+
if (!current_bid) throw new Error('current_bid required for amount adjustments');
|
|
186
|
+
newBidMicros = current_bid - Math.round(value * 1000000);
|
|
187
|
+
break;
|
|
188
|
+
default:
|
|
189
|
+
throw new Error(`Invalid adjustment_type: ${adjustment_type}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Apply caps
|
|
193
|
+
if (max_bid !== undefined) {
|
|
194
|
+
const maxBidMicros = Math.round(max_bid * 1000000);
|
|
195
|
+
newBidMicros = Math.min(newBidMicros, maxBidMicros);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (min_bid !== undefined) {
|
|
199
|
+
const minBidMicros = Math.round(min_bid * 1000000);
|
|
200
|
+
newBidMicros = Math.max(newBidMicros, minBidMicros);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Ensure minimum bid of $0.01
|
|
204
|
+
newBidMicros = Math.max(newBidMicros, 10000);
|
|
205
|
+
|
|
206
|
+
// Build operation based on resource type
|
|
207
|
+
// Note: ad_group_criterion uses .create() method which needs update wrapper
|
|
208
|
+
// ad_group uses .update() method which does NOT need update wrapper
|
|
209
|
+
if (resource_type === 'keyword' || resource_type === 'product_group') {
|
|
210
|
+
return {
|
|
211
|
+
update: {
|
|
212
|
+
resource_name: `customers/${customer_id}/adGroupCriteria/${resource_id}`,
|
|
213
|
+
cpc_bid_micros: newBidMicros
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
} else if (resource_type === 'ad_group') {
|
|
217
|
+
return {
|
|
218
|
+
resource_name: `customers/${customer_id}/adGroups/${resource_id}`,
|
|
219
|
+
cpc_bid_micros: newBidMicros
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
throw new Error(`Unsupported resource_type: ${resource_type}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build a campaign status/budget update operation
|
|
228
|
+
* @param {Object} params
|
|
229
|
+
* @param {string} params.customer_id - Customer ID
|
|
230
|
+
* @param {string} params.campaign_id - Campaign ID to update
|
|
231
|
+
* @param {string} [params.status] - ENABLED or PAUSED
|
|
232
|
+
* @param {number} [params.daily_budget] - New daily budget in account currency
|
|
233
|
+
* @returns {Object} Mutate operation(s)
|
|
234
|
+
*/
|
|
235
|
+
export function buildCampaignUpdateOperation(params) {
|
|
236
|
+
const { customer_id, campaign_id, status, daily_budget } = params;
|
|
237
|
+
|
|
238
|
+
const operations = [];
|
|
239
|
+
|
|
240
|
+
// Status update
|
|
241
|
+
if (status) {
|
|
242
|
+
operations.push({
|
|
243
|
+
type: 'campaign',
|
|
244
|
+
operation: {
|
|
245
|
+
update: {
|
|
246
|
+
resource_name: `customers/${customer_id}/campaigns/${campaign_id}`,
|
|
247
|
+
status: status
|
|
248
|
+
},
|
|
249
|
+
update_mask: 'status'
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Budget update requires finding and updating the campaign budget
|
|
255
|
+
if (daily_budget !== undefined) {
|
|
256
|
+
operations.push({
|
|
257
|
+
type: 'campaign_budget',
|
|
258
|
+
operation: {
|
|
259
|
+
update: {
|
|
260
|
+
// Note: In practice, you'd need to look up the budget resource name
|
|
261
|
+
// This is a simplified version
|
|
262
|
+
amount_micros: Math.round(daily_budget * 1000000)
|
|
263
|
+
},
|
|
264
|
+
update_mask: 'amount_micros'
|
|
265
|
+
},
|
|
266
|
+
requires_lookup: true,
|
|
267
|
+
campaign_id: campaign_id
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return operations;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check for conflicts before adding negative keywords
|
|
276
|
+
* @param {string} customerId - Customer ID
|
|
277
|
+
* @param {Array} negatives - Proposed negative keywords
|
|
278
|
+
* @param {string} [campaignId] - Campaign to check
|
|
279
|
+
* @param {string} [adGroupId] - Ad group to check
|
|
280
|
+
* @returns {Array} Array of conflict warnings
|
|
281
|
+
*/
|
|
282
|
+
export async function checkNegativeConflicts(customerId, negatives, campaignId, adGroupId) {
|
|
283
|
+
const customer = getCustomerClient(customerId);
|
|
284
|
+
const conflicts = [];
|
|
285
|
+
|
|
286
|
+
// Build query to check for existing negatives and positive keywords
|
|
287
|
+
const filters = [];
|
|
288
|
+
if (campaignId) {
|
|
289
|
+
filters.push(`campaign.id = ${campaignId}`);
|
|
290
|
+
}
|
|
291
|
+
if (adGroupId) {
|
|
292
|
+
filters.push(`ad_group.id = ${adGroupId}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const filterClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
|
|
296
|
+
|
|
297
|
+
// Check for duplicate negatives
|
|
298
|
+
const existingNegativesQuery = `
|
|
299
|
+
SELECT
|
|
300
|
+
campaign_criterion.keyword.text,
|
|
301
|
+
campaign_criterion.keyword.match_type,
|
|
302
|
+
campaign_criterion.negative
|
|
303
|
+
FROM campaign_criterion
|
|
304
|
+
WHERE campaign_criterion.negative = true
|
|
305
|
+
${filterClause}
|
|
306
|
+
`;
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const existingNegatives = await customer.query(existingNegativesQuery);
|
|
310
|
+
const existingSet = new Set(
|
|
311
|
+
existingNegatives.map(r =>
|
|
312
|
+
`${r.campaign_criterion?.keyword?.text?.toLowerCase()}|${r.campaign_criterion?.keyword?.match_type}`
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
negatives.forEach(neg => {
|
|
317
|
+
const key = `${neg.keyword.toLowerCase()}|${neg.match_type}`;
|
|
318
|
+
if (existingSet.has(key)) {
|
|
319
|
+
conflicts.push({
|
|
320
|
+
type: 'DUPLICATE',
|
|
321
|
+
keyword: neg.keyword,
|
|
322
|
+
match_type: neg.match_type,
|
|
323
|
+
message: `Negative keyword "${neg.keyword}" [${neg.match_type}] already exists`
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
} catch (error) {
|
|
328
|
+
// Log but don't fail - conflict check is advisory
|
|
329
|
+
console.error('Conflict check failed:', error.message);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check if negatives would block positive keywords
|
|
333
|
+
const positiveKeywordsQuery = `
|
|
334
|
+
SELECT
|
|
335
|
+
ad_group_criterion.keyword.text,
|
|
336
|
+
ad_group_criterion.keyword.match_type
|
|
337
|
+
FROM keyword_view
|
|
338
|
+
WHERE ad_group_criterion.status = 'ENABLED'
|
|
339
|
+
${filterClause}
|
|
340
|
+
`;
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const positiveKeywords = await customer.query(positiveKeywordsQuery);
|
|
344
|
+
|
|
345
|
+
negatives.forEach(neg => {
|
|
346
|
+
const negLower = neg.keyword.toLowerCase();
|
|
347
|
+
|
|
348
|
+
positiveKeywords.forEach(pos => {
|
|
349
|
+
const posText = pos.ad_group_criterion?.keyword?.text?.toLowerCase();
|
|
350
|
+
|
|
351
|
+
// Check if negative would block positive
|
|
352
|
+
if (neg.match_type === 'EXACT' && posText === negLower) {
|
|
353
|
+
conflicts.push({
|
|
354
|
+
type: 'BLOCKS_POSITIVE',
|
|
355
|
+
keyword: neg.keyword,
|
|
356
|
+
match_type: neg.match_type,
|
|
357
|
+
blocked_keyword: pos.ad_group_criterion?.keyword?.text,
|
|
358
|
+
message: `Negative "${neg.keyword}" would block positive keyword "${pos.ad_group_criterion?.keyword?.text}"`
|
|
359
|
+
});
|
|
360
|
+
} else if (neg.match_type === 'PHRASE' && posText?.includes(negLower)) {
|
|
361
|
+
conflicts.push({
|
|
362
|
+
type: 'MAY_BLOCK_POSITIVE',
|
|
363
|
+
keyword: neg.keyword,
|
|
364
|
+
match_type: neg.match_type,
|
|
365
|
+
affected_keyword: pos.ad_group_criterion?.keyword?.text,
|
|
366
|
+
message: `Phrase negative "${neg.keyword}" may block positive keyword "${pos.ad_group_criterion?.keyword?.text}"`
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
} catch (error) {
|
|
372
|
+
console.error('Positive keyword check failed:', error.message);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return conflicts;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Validate bid adjustment limits
|
|
380
|
+
* @param {number} currentBid - Current bid in currency
|
|
381
|
+
* @param {number} newBid - Proposed new bid in currency
|
|
382
|
+
* @param {Object} limits - Safety limits
|
|
383
|
+
* @returns {Object} Validation result with warnings
|
|
384
|
+
*/
|
|
385
|
+
export function validateBidChange(currentBid, newBid, limits = {}) {
|
|
386
|
+
const {
|
|
387
|
+
max_change_percent = 50,
|
|
388
|
+
max_bid = null,
|
|
389
|
+
min_bid = 0.01
|
|
390
|
+
} = limits;
|
|
391
|
+
|
|
392
|
+
const warnings = [];
|
|
393
|
+
let adjustedBid = newBid;
|
|
394
|
+
|
|
395
|
+
// Check percentage change
|
|
396
|
+
if (currentBid > 0) {
|
|
397
|
+
const percentChange = ((newBid - currentBid) / currentBid) * 100;
|
|
398
|
+
|
|
399
|
+
if (Math.abs(percentChange) > max_change_percent) {
|
|
400
|
+
warnings.push({
|
|
401
|
+
type: 'LARGE_CHANGE',
|
|
402
|
+
message: `Bid change of ${percentChange.toFixed(1)}% exceeds ${max_change_percent}% limit`,
|
|
403
|
+
original_bid: currentBid,
|
|
404
|
+
proposed_bid: newBid
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Check max bid
|
|
410
|
+
if (max_bid !== null && newBid > max_bid) {
|
|
411
|
+
adjustedBid = max_bid;
|
|
412
|
+
warnings.push({
|
|
413
|
+
type: 'MAX_BID_EXCEEDED',
|
|
414
|
+
message: `Bid capped at maximum of $${max_bid}`,
|
|
415
|
+
original_bid: newBid,
|
|
416
|
+
adjusted_bid: adjustedBid
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Check min bid
|
|
421
|
+
if (newBid < min_bid) {
|
|
422
|
+
adjustedBid = min_bid;
|
|
423
|
+
warnings.push({
|
|
424
|
+
type: 'MIN_BID_APPLIED',
|
|
425
|
+
message: `Bid raised to minimum of $${min_bid}`,
|
|
426
|
+
original_bid: newBid,
|
|
427
|
+
adjusted_bid: adjustedBid
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
valid: true,
|
|
433
|
+
adjusted_bid: adjustedBid,
|
|
434
|
+
warnings
|
|
435
|
+
};
|
|
436
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAQL query validation utilities
|
|
3
|
+
*
|
|
4
|
+
* Helps identify common issues before sending queries to Google Ads API
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validate a GAQL query for common issues
|
|
9
|
+
* @param {string} query - The GAQL query string
|
|
10
|
+
* @returns {{ valid: boolean, warnings: string[], errors: string[] }}
|
|
11
|
+
*/
|
|
12
|
+
export function validateQuery(query) {
|
|
13
|
+
const warnings = [];
|
|
14
|
+
const errors = [];
|
|
15
|
+
|
|
16
|
+
// Check for unreplaced template variables
|
|
17
|
+
const templateVars = query.match(/\{\{[A-Z_]+\}\}/g);
|
|
18
|
+
if (templateVars) {
|
|
19
|
+
errors.push(`Unreplaced template variables: ${templateVars.join(', ')}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check for basic syntax
|
|
23
|
+
if (!query.trim().toUpperCase().startsWith('SELECT')) {
|
|
24
|
+
errors.push('Query must start with SELECT');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!query.toUpperCase().includes('FROM')) {
|
|
28
|
+
errors.push('Query must include FROM clause');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Check for common field name issues
|
|
32
|
+
const commonIssues = [
|
|
33
|
+
{ pattern: /conversions_value/i, correct: 'conversions_value', note: 'Should be conversions_value (with underscore)' },
|
|
34
|
+
{ pattern: /conversion_value(?!s)/i, correct: 'conversions_value', note: 'Should be conversions_value (plural)' },
|
|
35
|
+
{ pattern: /segments\.product_item_id.*FROM shopping_performance_view/i, correct: 'shopping_performance_view fields', note: 'segments.product_item_id may not be available in shopping_performance_view' }
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
commonIssues.forEach(issue => {
|
|
39
|
+
if (issue.pattern.test(query)) {
|
|
40
|
+
warnings.push(issue.note);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Check for potentially incompatible field combinations
|
|
45
|
+
if (query.includes('keyword_view') && query.includes('segments.product_')) {
|
|
46
|
+
warnings.push('keyword_view and product segments may be incompatible');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (query.includes('shopping_performance_view') && query.includes('ad_group_criterion.keyword')) {
|
|
50
|
+
warnings.push('shopping_performance_view and keyword fields may be incompatible');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check for missing required segments with certain metrics
|
|
54
|
+
if (query.includes('segments.date') && !query.includes('WHERE')) {
|
|
55
|
+
warnings.push('Query uses segments.date but has no WHERE clause - may return too much data');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
valid: errors.length === 0,
|
|
60
|
+
warnings,
|
|
61
|
+
errors
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Log validation results
|
|
67
|
+
* @param {string} query - The query being validated
|
|
68
|
+
* @param {{ valid: boolean, warnings: string[], errors: string[] }} validation - Validation results
|
|
69
|
+
*/
|
|
70
|
+
export function logValidation(query, validation) {
|
|
71
|
+
if (validation.errors.length > 0) {
|
|
72
|
+
console.error('GAQL validation errors:', validation.errors.join('; '));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format successful tool response
|
|
5
|
+
* @param {Object} options
|
|
6
|
+
* @param {string} options.summary - Human-readable summary
|
|
7
|
+
* @param {Array} options.data - Result data
|
|
8
|
+
* @param {Object} [options.metadata] - Additional metadata
|
|
9
|
+
* @returns {Object} MCP-formatted response
|
|
10
|
+
*/
|
|
11
|
+
export function formatSuccess({ summary, data, metadata = {} }) {
|
|
12
|
+
return {
|
|
13
|
+
content: [{
|
|
14
|
+
type: 'text',
|
|
15
|
+
text: JSON.stringify({
|
|
16
|
+
success: true,
|
|
17
|
+
summary,
|
|
18
|
+
data,
|
|
19
|
+
metadata: {
|
|
20
|
+
rowCount: data.length,
|
|
21
|
+
warnings: [],
|
|
22
|
+
...metadata
|
|
23
|
+
}
|
|
24
|
+
}, null, 2)
|
|
25
|
+
}]
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format and throw error as MCP error
|
|
31
|
+
* @param {Error} error - Original error
|
|
32
|
+
* @throws {McpError} Always throws
|
|
33
|
+
*/
|
|
34
|
+
export function formatError(error) {
|
|
35
|
+
// Extract error message - handle various error object structures
|
|
36
|
+
let message = '';
|
|
37
|
+
|
|
38
|
+
if (typeof error === 'string') {
|
|
39
|
+
message = error;
|
|
40
|
+
} else if (error?.message) {
|
|
41
|
+
message = error.message;
|
|
42
|
+
} else if (error?.details && typeof error.details === 'string') {
|
|
43
|
+
message = error.details;
|
|
44
|
+
} else if (error?.failures && Array.isArray(error.failures)) {
|
|
45
|
+
// Google Ads API error structure: extract failure messages
|
|
46
|
+
const failures = error.failures.map(f => {
|
|
47
|
+
if (typeof f === 'string') return f;
|
|
48
|
+
if (f?.message) return f.message;
|
|
49
|
+
if (f?.error_message) return f.error_message;
|
|
50
|
+
if (f?.errors) {
|
|
51
|
+
return f.errors.map(e => e?.message || e?.error_message || JSON.stringify(e)).join('; ');
|
|
52
|
+
}
|
|
53
|
+
return JSON.stringify(f);
|
|
54
|
+
}).filter(Boolean).join('; ');
|
|
55
|
+
message = failures || 'Google Ads API error (see failures array)';
|
|
56
|
+
} else if (error?.errors && Array.isArray(error.errors)) {
|
|
57
|
+
// Alternative Google Ads error structure
|
|
58
|
+
const errors = error.errors.map(e => {
|
|
59
|
+
if (typeof e === 'string') return e;
|
|
60
|
+
if (e?.message) return e.message;
|
|
61
|
+
if (e?.error_message) return e.error_message;
|
|
62
|
+
return JSON.stringify(e);
|
|
63
|
+
}).filter(Boolean).join('; ');
|
|
64
|
+
message = errors || 'Google Ads API error (see errors array)';
|
|
65
|
+
} else if (error?.toString && typeof error.toString === 'function') {
|
|
66
|
+
// Try toString() method
|
|
67
|
+
const str = error.toString();
|
|
68
|
+
if (str && str !== '[object Object]') {
|
|
69
|
+
message = str;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If still no message, try to serialize the error object
|
|
74
|
+
if (!message) {
|
|
75
|
+
try {
|
|
76
|
+
const errorProps = Object.getOwnPropertyNames(error);
|
|
77
|
+
const errorObj = {};
|
|
78
|
+
errorProps.forEach(prop => {
|
|
79
|
+
try {
|
|
80
|
+
// Handle circular references and complex objects
|
|
81
|
+
const value = error[prop];
|
|
82
|
+
if (value !== null && typeof value === 'object') {
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
errorObj[prop] = value.map(v =>
|
|
85
|
+
typeof v === 'object' ? JSON.stringify(v) : v
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
errorObj[prop] = JSON.stringify(value);
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
errorObj[prop] = value;
|
|
92
|
+
}
|
|
93
|
+
} catch (propError) {
|
|
94
|
+
errorObj[prop] = `[Could not serialize: ${propError.message}]`;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
const serialized = JSON.stringify(errorObj, null, 2);
|
|
98
|
+
if (serialized && serialized !== '{}') {
|
|
99
|
+
message = serialized;
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Serialization failed, continue with empty message
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If message is still empty, use a fallback
|
|
107
|
+
if (!message || message === '{}' || message === '[object Object]') {
|
|
108
|
+
message = `Unknown error type: ${error?.constructor?.name || typeof error} - check server logs for details`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Map to appropriate MCP error codes
|
|
112
|
+
if (message.includes('required') || message.includes('Invalid')) {
|
|
113
|
+
throw new McpError(ErrorCode.InvalidParams, message);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (message.includes('RATE_LIMIT') || message.includes('quota')) {
|
|
117
|
+
throw new McpError(
|
|
118
|
+
ErrorCode.InternalError,
|
|
119
|
+
'Google Ads API rate limit exceeded. Please try again in a few moments.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (message.includes('AUTHENTICATION') || message.includes('credentials') || message.includes('auth')) {
|
|
124
|
+
throw new McpError(
|
|
125
|
+
ErrorCode.InternalError,
|
|
126
|
+
'Authentication failed. Please check your Google Ads credentials.'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Generic error - include all available details
|
|
131
|
+
const errorName = error?.name && error.name !== 'Error' ? `${error.name}: ` : '';
|
|
132
|
+
const errorCode = error?.code ? ` (code: ${error.code})` : '';
|
|
133
|
+
|
|
134
|
+
throw new McpError(
|
|
135
|
+
ErrorCode.InternalError,
|
|
136
|
+
`${errorName}${message}${errorCode}`
|
|
137
|
+
);
|
|
138
|
+
}
|