@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 +1 -1
- package/server/index.js +14 -2
- package/server/tools/mutate.js +15 -1
- package/server/utils/operation-transform.js +237 -0
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -91,7 +91,19 @@ const TOOLS = [
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
name: 'mutate',
|
|
94
|
-
description:
|
|
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
|
|
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
|
}
|
package/server/tools/mutate.js
CHANGED
|
@@ -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(
|
|
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
|
+
};
|