@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@channel47/google-ads-mcp",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
4
4
  "description": "Google Ads MCP Server - Query and mutate Google Ads data using GAQL",
5
5
  "main": "server/index.js",
6
6
  "bin": {
@@ -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 = { resource_name: resourceName };
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
- normalizedOps.push(op);
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);