@channel47/google-ads-mcp 1.0.1 → 1.0.4

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.1",
3
+ "version": "1.0.4",
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,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { getCustomerClient } from '../auth.js';
3
3
  import { formatSuccess, formatError } from '../utils/response-format.js';
4
+ import { normalizeOperations } from '../utils/operation-transform.js';
4
5
 
5
6
  /**
6
7
  * Execute mutation operations using GoogleAdsService.Mutate
@@ -34,9 +35,22 @@ export async function mutate(params) {
34
35
  let response;
35
36
  let partialFailureErrors = [];
36
37
 
38
+ // Transform operations to Opteo library format if needed
39
+ let normalizedOps;
40
+ try {
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
+ }
50
+
37
51
  try {
38
52
  // Execute mutation with validation options
39
- response = await customer.mutateResources(operations, {
53
+ response = await customer.mutateResources(normalizedOps, {
40
54
  partialFailure: partial_failure,
41
55
  validateOnly: dry_run
42
56
  });
@@ -46,10 +60,14 @@ export async function mutate(params) {
46
60
  if (partial_failure && error.errors && Array.isArray(error.errors)) {
47
61
  for (const err of error.errors) {
48
62
  const opIndex = err.location?.field_path_elements?.[0]?.index ?? -1;
63
+ // Extract full field path for debugging (e.g., "operations.create.campaign_bidding_strategy")
64
+ const fieldPath = err.location?.field_path_elements?.map(e => e.field_name).join('.') || null;
49
65
  partialFailureErrors.push({
50
66
  message: err.message || JSON.stringify(err.error_code),
51
67
  error_code: err.error_code,
52
- operation_index: opIndex
68
+ operation_index: opIndex,
69
+ field_path: fieldPath,
70
+ trigger: err.trigger?.string_value || null
53
71
  });
54
72
  }
55
73
 
@@ -77,10 +95,13 @@ export async function mutate(params) {
77
95
  || [];
78
96
 
79
97
  for (const error of errorDetails) {
98
+ const fieldPath = error.location?.field_path_elements?.map(e => e.field_name).join('.') || null;
80
99
  partialFailureErrors.push({
81
100
  message: error.message || error.error_message || JSON.stringify(error),
82
101
  error_code: error.error_code || error.code,
83
- operation_index: error.location?.field_path_elements?.[0]?.index ?? -1
102
+ operation_index: error.location?.field_path_elements?.[0]?.index ?? -1,
103
+ field_path: fieldPath,
104
+ trigger: error.trigger?.string_value || null
84
105
  });
85
106
  }
86
107
  }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Operation format transformation utility
3
+ * Converts standard Google Ads API format to Opteo library format
4
+ */
5
+
6
+ /**
7
+ * Entities that legitimately use resource_name in CREATE operations.
8
+ * These use temporary resource IDs (-1, -2, etc.) for atomic multi-resource creation.
9
+ * See: https://developers.google.com/google-ads/api/docs/mutating/overview
10
+ */
11
+ const ENTITIES_REQUIRING_RESOURCE_NAME_IN_CREATE = new Set([
12
+ 'campaign_budget' // Used for temp IDs when creating budget + campaign atomically
13
+ ]);
14
+
15
+ /**
16
+ * Resource name URL path segments to entity type mapping
17
+ * Based on Google Ads API resource name patterns
18
+ */
19
+ const RESOURCE_PATH_TO_ENTITY = {
20
+ 'campaigns': 'campaign',
21
+ 'adGroups': 'ad_group',
22
+ 'adGroupCriteria': 'ad_group_criterion',
23
+ 'campaignCriteria': 'campaign_criterion',
24
+ 'labels': 'label',
25
+ 'sharedSets': 'shared_set',
26
+ 'sharedCriteria': 'shared_criterion',
27
+ 'campaignBudgets': 'campaign_budget',
28
+ 'biddingStrategies': 'bidding_strategy',
29
+ 'ads': 'ad_group_ad',
30
+ 'adGroupAds': 'ad_group_ad',
31
+ 'assets': 'asset',
32
+ 'conversionActions': 'conversion_action',
33
+ 'customerNegativeCriteria': 'customer_negative_criterion',
34
+ 'campaignLabels': 'campaign_label',
35
+ 'adGroupLabels': 'ad_group_label',
36
+ 'customerLabels': 'customer_label',
37
+ 'keywordPlanCampaigns': 'keyword_plan_campaign',
38
+ 'keywordPlanAdGroups': 'keyword_plan_ad_group',
39
+ 'keywordPlanAdGroupKeywords': 'keyword_plan_ad_group_keyword',
40
+ 'extensionFeedItems': 'extension_feed_item',
41
+ 'campaignExtensionSettings': 'campaign_extension_setting',
42
+ 'adGroupExtensionSettings': 'ad_group_extension_setting',
43
+ 'remarketingActions': 'remarketing_action',
44
+ 'userLists': 'user_list',
45
+ };
46
+
47
+ /**
48
+ * Extract entity type from resource_name URL pattern
49
+ * @param {string} resourceName - e.g., "customers/123/campaigns/456"
50
+ * @returns {string|null} - e.g., "campaign" or null if not found
51
+ */
52
+ function inferEntityFromResourceName(resourceName) {
53
+ if (!resourceName || typeof resourceName !== 'string') return null;
54
+
55
+ // Parse: customers/{customer_id}/{resource_type}/{resource_id}
56
+ const parts = resourceName.split('/');
57
+ if (parts.length >= 3 && parts[0] === 'customers') {
58
+ const resourceType = parts[2]; // e.g., "campaigns", "adGroups"
59
+ return RESOURCE_PATH_TO_ENTITY[resourceType] || null;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Infer entity type from create operation structure
66
+ * @param {Object} resource - The resource being created
67
+ * @returns {string|null} - Entity type or null
68
+ */
69
+ function inferEntityFromCreateResource(resource) {
70
+ if (!resource || typeof resource !== 'object') return null;
71
+
72
+ const keys = Object.keys(resource);
73
+
74
+ // ad_group_criterion: has ad_group key and keyword/placement/negative
75
+ if (keys.includes('ad_group') && (keys.includes('keyword') || keys.includes('negative') || keys.includes('placement'))) {
76
+ return 'ad_group_criterion';
77
+ }
78
+
79
+ // campaign_criterion: has campaign key and keyword/negative (but not just campaign reference)
80
+ if (keys.includes('campaign') && (keys.includes('keyword') || keys.includes('negative')) && !keys.includes('advertising_channel_type')) {
81
+ return 'campaign_criterion';
82
+ }
83
+
84
+ // shared_criterion: has shared_set key
85
+ if (keys.includes('shared_set')) {
86
+ return 'shared_criterion';
87
+ }
88
+
89
+ // label: has name and text_label
90
+ if (keys.includes('name') && keys.includes('text_label')) {
91
+ return 'label';
92
+ }
93
+
94
+ // ad_group: has campaign key and name but no keyword (and isn't a criterion)
95
+ if (keys.includes('campaign') && keys.includes('name') && !keys.includes('keyword') && !keys.includes('negative')) {
96
+ return 'ad_group';
97
+ }
98
+
99
+ // campaign: has advertising_channel_type
100
+ if (keys.includes('advertising_channel_type')) {
101
+ return 'campaign';
102
+ }
103
+
104
+ // campaign_budget: has amount_micros
105
+ if (keys.includes('amount_micros') && !keys.includes('cpc_bid_micros')) {
106
+ return 'campaign_budget';
107
+ }
108
+
109
+ // asset: has type field (image_asset, text_asset, etc.)
110
+ if (keys.some(k => k.endsWith('_asset'))) {
111
+ return 'asset';
112
+ }
113
+
114
+ // conversion_action: has type and category
115
+ if (keys.includes('type') && keys.includes('category')) {
116
+ return 'conversion_action';
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Check if operation is already in Opteo format
124
+ * @param {Object} operation
125
+ * @returns {boolean}
126
+ */
127
+ function isOpteoFormat(operation) {
128
+ return !!(operation &&
129
+ typeof operation.entity === 'string' &&
130
+ operation.resource !== undefined);
131
+ }
132
+
133
+ /**
134
+ * Get operation type from standard format operation
135
+ * @param {Object} operation
136
+ * @returns {string|null} - 'create', 'update', 'remove', or null
137
+ */
138
+ function getStandardOperationType(operation) {
139
+ if (!operation || typeof operation !== 'object') return null;
140
+ if ('create' in operation) return 'create';
141
+ if ('update' in operation) return 'update';
142
+ if ('remove' in operation) return 'remove';
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Transform standard format operation to Opteo format
148
+ * @param {Object} operation - Standard format operation
149
+ * @param {number} index - Operation index for error messages
150
+ * @returns {Object} - Opteo format operation
151
+ * @throws {Error} - If entity cannot be inferred
152
+ */
153
+ function transformToOpteoFormat(operation, index) {
154
+ const opType = getStandardOperationType(operation);
155
+
156
+ if (!opType) {
157
+ throw new Error(
158
+ `Operation ${index}: Invalid format. Expected { create: {...} }, { update: {...} }, { remove: "..." }, ` +
159
+ `or Opteo format { entity: "...", operation: "...", resource: {...} }`
160
+ );
161
+ }
162
+
163
+ let entity = null;
164
+ let resource = null;
165
+
166
+ if (opType === 'remove') {
167
+ // Remove operations have resource_name as string value
168
+ // The Opteo library expects resource to be the string directly for remove ops
169
+ const resourceName = operation.remove;
170
+ if (typeof resourceName !== 'string') {
171
+ throw new Error(`Operation ${index}: 'remove' value must be a resource_name string`);
172
+ }
173
+ entity = inferEntityFromResourceName(resourceName);
174
+ resource = resourceName; // String, not object - Opteo expects { remove: "resource_name" }
175
+ } else {
176
+ // Create/Update operations have resource as object
177
+ resource = operation[opType];
178
+ if (!resource || typeof resource !== 'object') {
179
+ throw new Error(`Operation ${index}: '${opType}' value must be an object`);
180
+ }
181
+
182
+ // Try to infer entity from resource_name first (for updates)
183
+ if (resource.resource_name) {
184
+ entity = inferEntityFromResourceName(resource.resource_name);
185
+ }
186
+
187
+ // For creates without resource_name, infer from structure
188
+ if (!entity && opType === 'create') {
189
+ entity = inferEntityFromCreateResource(resource);
190
+ }
191
+ }
192
+
193
+ // Check for explicit entity hint in the operation
194
+ if (!entity && operation._entity) {
195
+ entity = operation._entity;
196
+ }
197
+
198
+ if (!entity) {
199
+ throw new Error(
200
+ `Operation ${index}: Could not infer entity type. Please use Opteo format: ` +
201
+ `{ entity: "campaign", operation: "${opType}", resource: {...} } ` +
202
+ `or add "_entity" field to your operation.`
203
+ );
204
+ }
205
+
206
+ // Strip resource_name from CREATE operations (API generates it automatically)
207
+ // Exception: entities that use temp IDs for atomic multi-resource creation
208
+ // See: https://developers.google.com/google-ads/api/docs/campaigns/create-campaigns
209
+ if (opType === 'create' && resource.resource_name) {
210
+ if (!ENTITIES_REQUIRING_RESOURCE_NAME_IN_CREATE.has(entity)) {
211
+ const { resource_name, ...resourceWithoutName } = resource;
212
+ resource = resourceWithoutName;
213
+ }
214
+ }
215
+
216
+ return {
217
+ entity,
218
+ operation: opType,
219
+ resource
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Transform operations array to Opteo format
225
+ * Passes through Opteo format operations, transforms standard format
226
+ * @param {Array} operations - Array of operations (mixed formats allowed)
227
+ * @returns {{ operations: Array, warnings: Array }} - Transformed operations and any warnings
228
+ */
229
+ export function normalizeOperations(operations) {
230
+ const normalizedOps = [];
231
+ const warnings = [];
232
+
233
+ for (let i = 0; i < operations.length; i++) {
234
+ const op = operations[i];
235
+
236
+ if (isOpteoFormat(op)) {
237
+ // Already in Opteo format - pass through, but normalize remove operations
238
+ // For remove ops, ensure resource is the string, not an object
239
+ if (op.operation === 'remove' && op.resource && typeof op.resource === 'object') {
240
+ normalizedOps.push({
241
+ ...op,
242
+ resource: op.resource.resource_name || op.resource
243
+ });
244
+ } else {
245
+ normalizedOps.push(op);
246
+ }
247
+ } else {
248
+ // Transform from standard format
249
+ const transformed = transformToOpteoFormat(op, i);
250
+ normalizedOps.push(transformed);
251
+ warnings.push(`Operation ${i}: Transformed from standard format (entity: ${transformed.entity})`);
252
+ }
253
+ }
254
+
255
+ return { operations: normalizedOps, warnings };
256
+ }
257
+
258
+ // Export helpers for testing
259
+ export {
260
+ inferEntityFromResourceName,
261
+ inferEntityFromCreateResource,
262
+ isOpteoFormat,
263
+ getStandardOperationType,
264
+ RESOURCE_PATH_TO_ENTITY
265
+ };