@channel47/google-ads-mcp 1.0.2 → 1.0.5
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
package/server/tools/mutate.js
CHANGED
|
@@ -60,10 +60,14 @@ export async function mutate(params) {
|
|
|
60
60
|
if (partial_failure && error.errors && Array.isArray(error.errors)) {
|
|
61
61
|
for (const err of error.errors) {
|
|
62
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;
|
|
63
65
|
partialFailureErrors.push({
|
|
64
66
|
message: err.message || JSON.stringify(err.error_code),
|
|
65
67
|
error_code: err.error_code,
|
|
66
|
-
operation_index: opIndex
|
|
68
|
+
operation_index: opIndex,
|
|
69
|
+
field_path: fieldPath,
|
|
70
|
+
trigger: err.trigger?.string_value || null
|
|
67
71
|
});
|
|
68
72
|
}
|
|
69
73
|
|
|
@@ -91,10 +95,13 @@ export async function mutate(params) {
|
|
|
91
95
|
|| [];
|
|
92
96
|
|
|
93
97
|
for (const error of errorDetails) {
|
|
98
|
+
const fieldPath = error.location?.field_path_elements?.map(e => e.field_name).join('.') || null;
|
|
94
99
|
partialFailureErrors.push({
|
|
95
100
|
message: error.message || error.error_message || JSON.stringify(error),
|
|
96
101
|
error_code: error.error_code || error.code,
|
|
97
|
-
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
|
|
98
105
|
});
|
|
99
106
|
}
|
|
100
107
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image asset processing utility
|
|
3
|
+
* Handles file path resolution, validation, and base64 encoding for ImageAsset uploads
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, statSync, accessSync, constants } from 'fs';
|
|
7
|
+
import { resolve, extname, isAbsolute } from 'path';
|
|
8
|
+
|
|
9
|
+
// Google Ads API limits
|
|
10
|
+
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
|
|
11
|
+
|
|
12
|
+
// Supported image formats with their Google Ads MIME type enum values
|
|
13
|
+
const SUPPORTED_FORMATS = {
|
|
14
|
+
'.jpg': 'IMAGE_JPEG',
|
|
15
|
+
'.jpeg': 'IMAGE_JPEG',
|
|
16
|
+
'.png': 'IMAGE_PNG'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Magic bytes for image format validation
|
|
20
|
+
const MAGIC_BYTES = {
|
|
21
|
+
jpeg: [0xFF, 0xD8, 0xFF],
|
|
22
|
+
png: [0x89, 0x50, 0x4E, 0x47]
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate file path for security concerns
|
|
27
|
+
* @param {string} filePath - Path to validate
|
|
28
|
+
* @returns {{ valid: boolean, error?: string, resolvedPath?: string }}
|
|
29
|
+
*/
|
|
30
|
+
export function validateFilePath(filePath) {
|
|
31
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
32
|
+
return { valid: false, error: 'File path must be a non-empty string' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Require absolute paths to prevent ambiguity
|
|
36
|
+
if (!isAbsolute(filePath)) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
error: `File path must be absolute. Received: "${filePath}"`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Resolve to canonical path (handles .. and symlinks)
|
|
44
|
+
const resolvedPath = resolve(filePath);
|
|
45
|
+
|
|
46
|
+
// Check for path traversal attempts (.. in original that resolves differently)
|
|
47
|
+
if (resolvedPath !== filePath && filePath.includes('..')) {
|
|
48
|
+
return {
|
|
49
|
+
valid: false,
|
|
50
|
+
error: 'Path traversal detected. Use canonical absolute paths.'
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { valid: true, resolvedPath };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate image file exists, is readable, and meets size requirements
|
|
59
|
+
* @param {string} filePath - Absolute path to image file
|
|
60
|
+
* @returns {{ valid: boolean, error?: string, size?: number }}
|
|
61
|
+
*/
|
|
62
|
+
export function validateFileAccess(filePath) {
|
|
63
|
+
try {
|
|
64
|
+
// Check file exists and is readable
|
|
65
|
+
accessSync(filePath, constants.R_OK);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.code === 'ENOENT') {
|
|
68
|
+
return { valid: false, error: `File not found: "${filePath}"` };
|
|
69
|
+
}
|
|
70
|
+
if (err.code === 'EACCES') {
|
|
71
|
+
return { valid: false, error: `Permission denied: "${filePath}"` };
|
|
72
|
+
}
|
|
73
|
+
return { valid: false, error: `Cannot access file: ${err.message}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check file size
|
|
77
|
+
try {
|
|
78
|
+
const stats = statSync(filePath);
|
|
79
|
+
|
|
80
|
+
if (!stats.isFile()) {
|
|
81
|
+
return { valid: false, error: `Not a file: "${filePath}"` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (stats.size === 0) {
|
|
85
|
+
return { valid: false, error: `File is empty: "${filePath}"` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (stats.size > MAX_FILE_SIZE_BYTES) {
|
|
89
|
+
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
|
|
90
|
+
return {
|
|
91
|
+
valid: false,
|
|
92
|
+
error: `File size ${sizeMB}MB exceeds Google Ads limit of 5MB`
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { valid: true, size: stats.size };
|
|
97
|
+
} catch (err) {
|
|
98
|
+
return { valid: false, error: `Cannot read file stats: ${err.message}` };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Detect MIME type from file extension and validate with magic bytes
|
|
104
|
+
* @param {string} filePath - Path to image file
|
|
105
|
+
* @param {Buffer} fileBuffer - File contents (first few bytes needed)
|
|
106
|
+
* @returns {{ valid: boolean, mimeType?: string, error?: string }}
|
|
107
|
+
*/
|
|
108
|
+
export function detectAndValidateFormat(filePath, fileBuffer) {
|
|
109
|
+
const ext = extname(filePath).toLowerCase();
|
|
110
|
+
|
|
111
|
+
// Check extension is supported
|
|
112
|
+
if (!SUPPORTED_FORMATS[ext]) {
|
|
113
|
+
return {
|
|
114
|
+
valid: false,
|
|
115
|
+
error: `Unsupported image format: "${ext}". Supported: JPEG, PNG`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Validate magic bytes match extension
|
|
120
|
+
const isJpeg = MAGIC_BYTES.jpeg.every((b, i) => fileBuffer[i] === b);
|
|
121
|
+
const isPng = MAGIC_BYTES.png.every((b, i) => fileBuffer[i] === b);
|
|
122
|
+
|
|
123
|
+
if ((ext === '.jpg' || ext === '.jpeg') && !isJpeg) {
|
|
124
|
+
return {
|
|
125
|
+
valid: false,
|
|
126
|
+
error: `File "${filePath}" has .jpg extension but is not a valid JPEG`
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (ext === '.png' && !isPng) {
|
|
131
|
+
return {
|
|
132
|
+
valid: false,
|
|
133
|
+
error: `File "${filePath}" has .png extension but is not a valid PNG`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Determine actual format from magic bytes
|
|
138
|
+
let mimeType;
|
|
139
|
+
if (isJpeg) {
|
|
140
|
+
mimeType = 'IMAGE_JPEG';
|
|
141
|
+
} else if (isPng) {
|
|
142
|
+
mimeType = 'IMAGE_PNG';
|
|
143
|
+
} else {
|
|
144
|
+
return {
|
|
145
|
+
valid: false,
|
|
146
|
+
error: `Could not detect image format for "${filePath}"`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { valid: true, mimeType };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Process an image file path into base64-encoded image_asset data
|
|
155
|
+
* @param {string} filePath - Absolute path to image file
|
|
156
|
+
* @returns {{ success: boolean, data?: Object, error?: string }}
|
|
157
|
+
*/
|
|
158
|
+
export function processImageFile(filePath) {
|
|
159
|
+
// Step 1: Validate path security
|
|
160
|
+
const pathValidation = validateFilePath(filePath);
|
|
161
|
+
if (!pathValidation.valid) {
|
|
162
|
+
return { success: false, error: pathValidation.error };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Step 2: Validate file access and size
|
|
166
|
+
const accessValidation = validateFileAccess(pathValidation.resolvedPath);
|
|
167
|
+
if (!accessValidation.valid) {
|
|
168
|
+
return { success: false, error: accessValidation.error };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Step 3: Read file
|
|
172
|
+
let fileBuffer;
|
|
173
|
+
try {
|
|
174
|
+
fileBuffer = readFileSync(pathValidation.resolvedPath);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return { success: false, error: `Failed to read file: ${err.message}` };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Step 4: Validate format
|
|
180
|
+
const formatValidation = detectAndValidateFormat(
|
|
181
|
+
pathValidation.resolvedPath,
|
|
182
|
+
fileBuffer
|
|
183
|
+
);
|
|
184
|
+
if (!formatValidation.valid) {
|
|
185
|
+
return { success: false, error: formatValidation.error };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Step 5: Encode to base64
|
|
189
|
+
// CRITICAL: Opteo library requires base64 string, NOT Buffer
|
|
190
|
+
const base64Data = fileBuffer.toString('base64');
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
success: true,
|
|
194
|
+
data: {
|
|
195
|
+
data: base64Data,
|
|
196
|
+
file_size: accessValidation.size,
|
|
197
|
+
mime_type: formatValidation.mimeType
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Transform a resource object by processing image_file_path if present
|
|
204
|
+
* @param {Object} resource - Resource object that may contain image_file_path
|
|
205
|
+
* @returns {{ resource: Object, processed: boolean, error?: string }}
|
|
206
|
+
*/
|
|
207
|
+
export function transformImageAssetResource(resource) {
|
|
208
|
+
if (!resource || typeof resource !== 'object') {
|
|
209
|
+
return { resource, processed: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check if this is an asset with image_file_path
|
|
213
|
+
if (!resource.image_file_path) {
|
|
214
|
+
return { resource, processed: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Validate this is an IMAGE type asset (or infer it)
|
|
218
|
+
if (resource.type && resource.type !== 'IMAGE') {
|
|
219
|
+
return {
|
|
220
|
+
resource,
|
|
221
|
+
processed: false,
|
|
222
|
+
error: `image_file_path is only valid for IMAGE assets, got type: ${resource.type}`
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Process the image file
|
|
227
|
+
const result = processImageFile(resource.image_file_path);
|
|
228
|
+
if (!result.success) {
|
|
229
|
+
return { resource, processed: false, error: result.error };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Build the transformed resource (remove image_file_path, add image_asset)
|
|
233
|
+
const { image_file_path, ...restResource } = resource;
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
resource: {
|
|
237
|
+
...restResource,
|
|
238
|
+
type: 'IMAGE', // Ensure type is set
|
|
239
|
+
image_asset: result.data
|
|
240
|
+
},
|
|
241
|
+
processed: true
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Export constants for testing
|
|
246
|
+
export { MAX_FILE_SIZE_BYTES, SUPPORTED_FORMATS, MAGIC_BYTES };
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
* Converts standard Google Ads API format to Opteo library format
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { transformImageAssetResource } from './image-asset.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Entities that legitimately use resource_name in CREATE operations.
|
|
10
|
+
* These use temporary resource IDs (-1, -2, etc.) for atomic multi-resource creation.
|
|
11
|
+
* See: https://developers.google.com/google-ads/api/docs/mutating/overview
|
|
12
|
+
*/
|
|
13
|
+
const ENTITIES_REQUIRING_RESOURCE_NAME_IN_CREATE = new Set([
|
|
14
|
+
'campaign_budget' // Used for temp IDs when creating budget + campaign atomically
|
|
15
|
+
]);
|
|
16
|
+
|
|
6
17
|
/**
|
|
7
18
|
* Resource name URL path segments to entity type mapping
|
|
8
19
|
* Based on Google Ads API resource name patterns
|
|
@@ -156,12 +167,13 @@ function transformToOpteoFormat(operation, index) {
|
|
|
156
167
|
|
|
157
168
|
if (opType === 'remove') {
|
|
158
169
|
// Remove operations have resource_name as string value
|
|
170
|
+
// The Opteo library expects resource to be the string directly for remove ops
|
|
159
171
|
const resourceName = operation.remove;
|
|
160
172
|
if (typeof resourceName !== 'string') {
|
|
161
173
|
throw new Error(`Operation ${index}: 'remove' value must be a resource_name string`);
|
|
162
174
|
}
|
|
163
175
|
entity = inferEntityFromResourceName(resourceName);
|
|
164
|
-
resource = {
|
|
176
|
+
resource = resourceName; // String, not object - Opteo expects { remove: "resource_name" }
|
|
165
177
|
} else {
|
|
166
178
|
// Create/Update operations have resource as object
|
|
167
179
|
resource = operation[opType];
|
|
@@ -193,6 +205,27 @@ function transformToOpteoFormat(operation, index) {
|
|
|
193
205
|
);
|
|
194
206
|
}
|
|
195
207
|
|
|
208
|
+
// Strip resource_name from CREATE operations (API generates it automatically)
|
|
209
|
+
// Exception: entities that use temp IDs for atomic multi-resource creation
|
|
210
|
+
// See: https://developers.google.com/google-ads/api/docs/campaigns/create-campaigns
|
|
211
|
+
if (opType === 'create' && resource.resource_name) {
|
|
212
|
+
if (!ENTITIES_REQUIRING_RESOURCE_NAME_IN_CREATE.has(entity)) {
|
|
213
|
+
const { resource_name, ...resourceWithoutName } = resource;
|
|
214
|
+
resource = resourceWithoutName;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Process image file paths in asset resources
|
|
219
|
+
if (opType === 'create' && entity === 'asset') {
|
|
220
|
+
const imageResult = transformImageAssetResource(resource);
|
|
221
|
+
if (imageResult.error) {
|
|
222
|
+
throw new Error(`Operation ${index}: ${imageResult.error}`);
|
|
223
|
+
}
|
|
224
|
+
if (imageResult.processed) {
|
|
225
|
+
resource = imageResult.resource;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
196
229
|
return {
|
|
197
230
|
entity,
|
|
198
231
|
operation: opType,
|
|
@@ -214,8 +247,32 @@ export function normalizeOperations(operations) {
|
|
|
214
247
|
const op = operations[i];
|
|
215
248
|
|
|
216
249
|
if (isOpteoFormat(op)) {
|
|
217
|
-
// Already in Opteo format - pass through
|
|
218
|
-
|
|
250
|
+
// Already in Opteo format - pass through, but normalize special cases
|
|
251
|
+
|
|
252
|
+
// Handle image_file_path in Opteo format asset operations
|
|
253
|
+
if (op.entity === 'asset' && op.operation === 'create' && op.resource?.image_file_path) {
|
|
254
|
+
const imageResult = transformImageAssetResource(op.resource);
|
|
255
|
+
if (imageResult.error) {
|
|
256
|
+
throw new Error(`Operation ${i}: ${imageResult.error}`);
|
|
257
|
+
}
|
|
258
|
+
if (imageResult.processed) {
|
|
259
|
+
normalizedOps.push({
|
|
260
|
+
...op,
|
|
261
|
+
resource: imageResult.resource
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// For remove ops, ensure resource is the string, not an object
|
|
268
|
+
if (op.operation === 'remove' && op.resource && typeof op.resource === 'object') {
|
|
269
|
+
normalizedOps.push({
|
|
270
|
+
...op,
|
|
271
|
+
resource: op.resource.resource_name || op.resource
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
normalizedOps.push(op);
|
|
275
|
+
}
|
|
219
276
|
} else {
|
|
220
277
|
// Transform from standard format
|
|
221
278
|
const transformed = transformToOpteoFormat(op, i);
|