@channel47/google-ads-mcp 1.0.1 → 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.1",
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,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
  });
@@ -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
+ };