@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 +1 -1
- package/server/index.js +14 -2
- package/server/tools/mutate.js +101 -24
- 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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|