@channel47/google-ads-mcp 1.0.0 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@channel47/google-ads-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Google Ads MCP Server - Query and mutate Google Ads data using GAQL",
5
5
  "main": "server/index.js",
6
6
  "bin": {
package/server/index.js CHANGED
@@ -91,7 +91,19 @@ const TOOLS = [
91
91
  },
92
92
  {
93
93
  name: 'mutate',
94
- description: 'Execute write operations using GoogleAdsService.Mutate. Supports all operation types. Default dry_run=true for safety.',
94
+ description: `Execute write operations using GoogleAdsService.Mutate. Default dry_run=true for safety.
95
+
96
+ Supports two operation formats:
97
+
98
+ 1. Standard Google Ads format (auto-transformed):
99
+ { "update": { "resource_name": "customers/123/campaigns/456", "status": "PAUSED" } }
100
+ { "create": { "ad_group": "customers/123/adGroups/456", "keyword": {...} } }
101
+ { "remove": "customers/123/labels/789" }
102
+
103
+ 2. Opteo library format:
104
+ { "entity": "campaign", "operation": "update", "resource": { "resource_name": "...", "status": "PAUSED" } }
105
+
106
+ Entity types are auto-inferred from resource_name patterns for updates/removes.`,
95
107
  inputSchema: {
96
108
  type: 'object',
97
109
  properties: {
@@ -101,7 +113,7 @@ const TOOLS = [
101
113
  },
102
114
  operations: {
103
115
  type: 'array',
104
- description: 'Array of mutation operation objects',
116
+ description: 'Array of mutation operations. Supports standard format ({ create/update/remove: ... }) or Opteo format ({ entity, operation, resource }).',
105
117
  items: {
106
118
  type: 'object'
107
119
  }
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { getCustomerClient } from '../auth.js';
3
+ import { formatSuccess, formatError } from '../utils/response-format.js';
4
+ import { normalizeOperations } from '../utils/operation-transform.js';
3
5
 
4
6
  /**
5
7
  * Execute mutation operations using GoogleAdsService.Mutate
@@ -22,43 +24,118 @@ export async function mutate(params) {
22
24
 
23
25
  // Validate required parameters
24
26
  if (!customer_id) {
25
- return {
26
- success: false,
27
- error: 'customer_id is required (either as parameter or GOOGLE_ADS_CUSTOMER_ID env var)'
28
- };
27
+ return formatError(new Error('customer_id is required (either as parameter or GOOGLE_ADS_CUSTOMER_ID env var)'));
29
28
  }
30
29
 
31
30
  if (!operations || !Array.isArray(operations) || operations.length === 0) {
32
- return {
33
- success: false,
34
- error: 'operations array is required and must contain at least one operation'
35
- };
31
+ return formatError(new Error('operations array is required and must contain at least one operation'));
36
32
  }
37
33
 
34
+ const customer = getCustomerClient(customer_id);
35
+ let response;
36
+ let partialFailureErrors = [];
37
+
38
+ // Transform operations to Opteo library format if needed
39
+ let normalizedOps;
38
40
  try {
39
- const customer = getCustomerClient(customer_id);
41
+ const result = normalizeOperations(operations);
42
+ normalizedOps = result.operations;
43
+ // Log transformation warnings for debugging
44
+ if (result.warnings.length > 0) {
45
+ console.error('Operation format transformations:', result.warnings);
46
+ }
47
+ } catch (transformError) {
48
+ return formatError(transformError);
49
+ }
40
50
 
51
+ try {
41
52
  // Execute mutation with validation options
42
- const response = await customer.mutateResources(operations, {
53
+ response = await customer.mutateResources(normalizedOps, {
43
54
  partialFailure: partial_failure,
44
55
  validateOnly: dry_run
45
56
  });
46
-
47
- return {
48
- success: true,
49
- dry_run,
50
- results: response,
51
- message: dry_run
52
- ? 'Validation successful - no changes made'
53
- : 'Mutations applied successfully',
54
- operations_count: operations.length
55
- };
56
57
  } catch (error) {
58
+ // The Opteo library throws exceptions with error.errors array for partial failures
59
+ // Extract failure details if available
60
+ if (partial_failure && error.errors && Array.isArray(error.errors)) {
61
+ for (const err of error.errors) {
62
+ const opIndex = err.location?.field_path_elements?.[0]?.index ?? -1;
63
+ partialFailureErrors.push({
64
+ message: err.message || JSON.stringify(err.error_code),
65
+ error_code: err.error_code,
66
+ operation_index: opIndex
67
+ });
68
+ }
69
+
70
+ // If not all operations failed, treat as partial success
71
+ if (partialFailureErrors.length < operations.length) {
72
+ response = { mutate_operation_responses: [], partial_failure_error: null };
73
+ } else {
74
+ // All operations failed
75
+ const errorMessages = partialFailureErrors.map(e => e.message).join('; ');
76
+ return formatError(new Error(`All operations failed: ${errorMessages}`));
77
+ }
78
+ } else {
79
+ // Not a partial failure - re-throw as regular error
80
+ return formatError(error);
81
+ }
82
+ }
83
+
84
+ // Extract results from response
85
+ const results = response.mutate_operation_responses || [];
86
+
87
+ // Check for partial failure errors in response body (alternative structure)
88
+ if (response.partial_failure_error) {
89
+ const errorDetails = response.partial_failure_error.errors
90
+ || response.partial_failure_error.details
91
+ || [];
92
+
93
+ for (const error of errorDetails) {
94
+ partialFailureErrors.push({
95
+ message: error.message || error.error_message || JSON.stringify(error),
96
+ error_code: error.error_code || error.code,
97
+ operation_index: error.location?.field_path_elements?.[0]?.index ?? -1
98
+ });
99
+ }
100
+ }
101
+
102
+ // Calculate success/failure counts
103
+ const failCount = partialFailureErrors.length;
104
+ const successCount = operations.length - failCount;
105
+
106
+ // Extract resource names from nested result structure
107
+ // Results come as: { campaign_result: { resource_name: "..." }, response: "campaign_result" }
108
+ const extractedResults = results.map(r => {
109
+ // Find the result field (campaign_result, ad_group_result, etc.)
110
+ const resultKey = r?.response;
111
+ const resultData = resultKey ? r[resultKey] : r;
57
112
  return {
58
- success: false,
59
- dry_run,
60
- error: error.message,
61
- error_details: error.errors || null
113
+ resource_name: resultData?.resource_name || null
62
114
  };
115
+ });
116
+
117
+ // Build appropriate message
118
+ let message;
119
+ if (dry_run) {
120
+ message = failCount > 0
121
+ ? `Validation completed with ${failCount} error(s) - no changes made`
122
+ : 'Validation successful - no changes made';
123
+ } else {
124
+ message = failCount > 0
125
+ ? `Mutations completed: ${successCount} succeeded, ${failCount} failed`
126
+ : 'Mutations applied successfully';
63
127
  }
128
+
129
+ return formatSuccess({
130
+ summary: `${message} (${operations.length} operation${operations.length !== 1 ? 's' : ''})`,
131
+ data: extractedResults,
132
+ metadata: {
133
+ dry_run,
134
+ operations_count: operations.length,
135
+ success_count: successCount,
136
+ failure_count: failCount,
137
+ customer_id,
138
+ ...(partialFailureErrors.length > 0 && { errors: partialFailureErrors })
139
+ }
140
+ });
64
141
  }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Operation format transformation utility
3
+ * Converts standard Google Ads API format to Opteo library format
4
+ */
5
+
6
+ /**
7
+ * Resource name URL path segments to entity type mapping
8
+ * Based on Google Ads API resource name patterns
9
+ */
10
+ const RESOURCE_PATH_TO_ENTITY = {
11
+ 'campaigns': 'campaign',
12
+ 'adGroups': 'ad_group',
13
+ 'adGroupCriteria': 'ad_group_criterion',
14
+ 'campaignCriteria': 'campaign_criterion',
15
+ 'labels': 'label',
16
+ 'sharedSets': 'shared_set',
17
+ 'sharedCriteria': 'shared_criterion',
18
+ 'campaignBudgets': 'campaign_budget',
19
+ 'biddingStrategies': 'bidding_strategy',
20
+ 'ads': 'ad_group_ad',
21
+ 'adGroupAds': 'ad_group_ad',
22
+ 'assets': 'asset',
23
+ 'conversionActions': 'conversion_action',
24
+ 'customerNegativeCriteria': 'customer_negative_criterion',
25
+ 'campaignLabels': 'campaign_label',
26
+ 'adGroupLabels': 'ad_group_label',
27
+ 'customerLabels': 'customer_label',
28
+ 'keywordPlanCampaigns': 'keyword_plan_campaign',
29
+ 'keywordPlanAdGroups': 'keyword_plan_ad_group',
30
+ 'keywordPlanAdGroupKeywords': 'keyword_plan_ad_group_keyword',
31
+ 'extensionFeedItems': 'extension_feed_item',
32
+ 'campaignExtensionSettings': 'campaign_extension_setting',
33
+ 'adGroupExtensionSettings': 'ad_group_extension_setting',
34
+ 'remarketingActions': 'remarketing_action',
35
+ 'userLists': 'user_list',
36
+ };
37
+
38
+ /**
39
+ * Extract entity type from resource_name URL pattern
40
+ * @param {string} resourceName - e.g., "customers/123/campaigns/456"
41
+ * @returns {string|null} - e.g., "campaign" or null if not found
42
+ */
43
+ function inferEntityFromResourceName(resourceName) {
44
+ if (!resourceName || typeof resourceName !== 'string') return null;
45
+
46
+ // Parse: customers/{customer_id}/{resource_type}/{resource_id}
47
+ const parts = resourceName.split('/');
48
+ if (parts.length >= 3 && parts[0] === 'customers') {
49
+ const resourceType = parts[2]; // e.g., "campaigns", "adGroups"
50
+ return RESOURCE_PATH_TO_ENTITY[resourceType] || null;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Infer entity type from create operation structure
57
+ * @param {Object} resource - The resource being created
58
+ * @returns {string|null} - Entity type or null
59
+ */
60
+ function inferEntityFromCreateResource(resource) {
61
+ if (!resource || typeof resource !== 'object') return null;
62
+
63
+ const keys = Object.keys(resource);
64
+
65
+ // ad_group_criterion: has ad_group key and keyword/placement/negative
66
+ if (keys.includes('ad_group') && (keys.includes('keyword') || keys.includes('negative') || keys.includes('placement'))) {
67
+ return 'ad_group_criterion';
68
+ }
69
+
70
+ // campaign_criterion: has campaign key and keyword/negative (but not just campaign reference)
71
+ if (keys.includes('campaign') && (keys.includes('keyword') || keys.includes('negative')) && !keys.includes('advertising_channel_type')) {
72
+ return 'campaign_criterion';
73
+ }
74
+
75
+ // shared_criterion: has shared_set key
76
+ if (keys.includes('shared_set')) {
77
+ return 'shared_criterion';
78
+ }
79
+
80
+ // label: has name and text_label
81
+ if (keys.includes('name') && keys.includes('text_label')) {
82
+ return 'label';
83
+ }
84
+
85
+ // ad_group: has campaign key and name but no keyword (and isn't a criterion)
86
+ if (keys.includes('campaign') && keys.includes('name') && !keys.includes('keyword') && !keys.includes('negative')) {
87
+ return 'ad_group';
88
+ }
89
+
90
+ // campaign: has advertising_channel_type
91
+ if (keys.includes('advertising_channel_type')) {
92
+ return 'campaign';
93
+ }
94
+
95
+ // campaign_budget: has amount_micros
96
+ if (keys.includes('amount_micros') && !keys.includes('cpc_bid_micros')) {
97
+ return 'campaign_budget';
98
+ }
99
+
100
+ // asset: has type field (image_asset, text_asset, etc.)
101
+ if (keys.some(k => k.endsWith('_asset'))) {
102
+ return 'asset';
103
+ }
104
+
105
+ // conversion_action: has type and category
106
+ if (keys.includes('type') && keys.includes('category')) {
107
+ return 'conversion_action';
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Check if operation is already in Opteo format
115
+ * @param {Object} operation
116
+ * @returns {boolean}
117
+ */
118
+ function isOpteoFormat(operation) {
119
+ return !!(operation &&
120
+ typeof operation.entity === 'string' &&
121
+ operation.resource !== undefined);
122
+ }
123
+
124
+ /**
125
+ * Get operation type from standard format operation
126
+ * @param {Object} operation
127
+ * @returns {string|null} - 'create', 'update', 'remove', or null
128
+ */
129
+ function getStandardOperationType(operation) {
130
+ if (!operation || typeof operation !== 'object') return null;
131
+ if ('create' in operation) return 'create';
132
+ if ('update' in operation) return 'update';
133
+ if ('remove' in operation) return 'remove';
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Transform standard format operation to Opteo format
139
+ * @param {Object} operation - Standard format operation
140
+ * @param {number} index - Operation index for error messages
141
+ * @returns {Object} - Opteo format operation
142
+ * @throws {Error} - If entity cannot be inferred
143
+ */
144
+ function transformToOpteoFormat(operation, index) {
145
+ const opType = getStandardOperationType(operation);
146
+
147
+ if (!opType) {
148
+ throw new Error(
149
+ `Operation ${index}: Invalid format. Expected { create: {...} }, { update: {...} }, { remove: "..." }, ` +
150
+ `or Opteo format { entity: "...", operation: "...", resource: {...} }`
151
+ );
152
+ }
153
+
154
+ let entity = null;
155
+ let resource = null;
156
+
157
+ if (opType === 'remove') {
158
+ // Remove operations have resource_name as string value
159
+ const resourceName = operation.remove;
160
+ if (typeof resourceName !== 'string') {
161
+ throw new Error(`Operation ${index}: 'remove' value must be a resource_name string`);
162
+ }
163
+ entity = inferEntityFromResourceName(resourceName);
164
+ resource = { resource_name: resourceName };
165
+ } else {
166
+ // Create/Update operations have resource as object
167
+ resource = operation[opType];
168
+ if (!resource || typeof resource !== 'object') {
169
+ throw new Error(`Operation ${index}: '${opType}' value must be an object`);
170
+ }
171
+
172
+ // Try to infer entity from resource_name first (for updates)
173
+ if (resource.resource_name) {
174
+ entity = inferEntityFromResourceName(resource.resource_name);
175
+ }
176
+
177
+ // For creates without resource_name, infer from structure
178
+ if (!entity && opType === 'create') {
179
+ entity = inferEntityFromCreateResource(resource);
180
+ }
181
+ }
182
+
183
+ // Check for explicit entity hint in the operation
184
+ if (!entity && operation._entity) {
185
+ entity = operation._entity;
186
+ }
187
+
188
+ if (!entity) {
189
+ throw new Error(
190
+ `Operation ${index}: Could not infer entity type. Please use Opteo format: ` +
191
+ `{ entity: "campaign", operation: "${opType}", resource: {...} } ` +
192
+ `or add "_entity" field to your operation.`
193
+ );
194
+ }
195
+
196
+ return {
197
+ entity,
198
+ operation: opType,
199
+ resource
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Transform operations array to Opteo format
205
+ * Passes through Opteo format operations, transforms standard format
206
+ * @param {Array} operations - Array of operations (mixed formats allowed)
207
+ * @returns {{ operations: Array, warnings: Array }} - Transformed operations and any warnings
208
+ */
209
+ export function normalizeOperations(operations) {
210
+ const normalizedOps = [];
211
+ const warnings = [];
212
+
213
+ for (let i = 0; i < operations.length; i++) {
214
+ const op = operations[i];
215
+
216
+ if (isOpteoFormat(op)) {
217
+ // Already in Opteo format - pass through
218
+ normalizedOps.push(op);
219
+ } else {
220
+ // Transform from standard format
221
+ const transformed = transformToOpteoFormat(op, i);
222
+ normalizedOps.push(transformed);
223
+ warnings.push(`Operation ${i}: Transformed from standard format (entity: ${transformed.entity})`);
224
+ }
225
+ }
226
+
227
+ return { operations: normalizedOps, warnings };
228
+ }
229
+
230
+ // Export helpers for testing
231
+ export {
232
+ inferEntityFromResourceName,
233
+ inferEntityFromCreateResource,
234
+ isOpteoFormat,
235
+ getStandardOperationType,
236
+ RESOURCE_PATH_TO_ENTITY
237
+ };