@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.
@@ -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
+ }