@getmcpads/google-ads-mcp-server 1.0.1 → 2.0.0

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/dist/cli.js CHANGED
@@ -51,24 +51,838 @@ function loadConfig() {
51
51
  enableWrites: isTruthy(process.env["GOOGLE_ADS_ENABLE_WRITES"]),
52
52
  logLevel: process.env["LOG_LEVEL"] ?? "info"
53
53
  };
54
- const result = configSchema.safeParse(raw);
55
- if (!result.success) {
56
- const missing = result.error.issues.map((i) => i.message).join(", ");
54
+ const result2 = configSchema.safeParse(raw);
55
+ if (!result2.success) {
56
+ const missing = result2.error.issues.map((i) => i.message).join(", ");
57
57
  logger.error("config", `Missing credentials: ${missing}`);
58
58
  throw new Error(`Missing Google Ads credentials: ${missing}`);
59
59
  }
60
- return result.data;
60
+ return result2.data;
61
61
  }
62
62
  function isTruthy(value) {
63
63
  if (!value) return false;
64
64
  return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
65
65
  }
66
66
 
67
+ // src/parameter-descriptions.ts
68
+ var PARAMETER_DESCRIPTIONS = {
69
+ "filters": "Filters combined according to this tool's query schema. Use provider field names and the declared operators.",
70
+ "searchType": "Search surface to report, such as web, image, video, news or Discover. Availability depends on the property.",
71
+ "searchTypes": "Search surfaces to compare. Each surface is queried separately; do not sum overlapping surfaces.",
72
+ "datePreset": "Named reporting period. Use the custom date range when you need exact start and end dates.",
73
+ "dateRange": "Explicit reporting start and end dates in YYYY-MM-DD format.",
74
+ "timeRange": "Explicit provider reporting period with since and until dates.",
75
+ "startDate": "First date of the reporting period, in YYYY-MM-DD format.",
76
+ "endDate": "Last date of the reporting period, in YYYY-MM-DD format, on or after startDate.",
77
+ "since": "Beginning of the requested reporting period; use the format accepted by the provider.",
78
+ "until": "End of the requested reporting period; use the format accepted by the provider.",
79
+ "orderBy": "Field used to order report rows.",
80
+ "orderField": "Provider field used to order report rows.",
81
+ "orderDirection": "Sort direction for the selected ordering field.",
82
+ "orderType": "Sort direction for the selected ordering field.",
83
+ "sortBy": "Metric or dimension used to rank the returned results.",
84
+ "sort": "Native provider ordering expressions.",
85
+ "limit": "Maximum number of returned items or rows. The declared bounds and default apply; use pagination for additional results.",
86
+ "topN": "Maximum number of highest-ranked results to return.",
87
+ "pageSize": "Maximum items requested per page. Follow the returned pagination information for remaining results.",
88
+ "page": "Page number to request, starting at 1.",
89
+ "offset": "Zero-based number of rows to skip before returning results.",
90
+ "bookmark": "Opaque Pinterest pagination bookmark returned by the previous response. Omit on the first request.",
91
+ "after": "Opaque cursor for the next page returned by Meta.",
92
+ "before": "Opaque cursor for the previous page returned by Meta.",
93
+ "includeUnchanged": "Include entries with no observed change in the comparison.",
94
+ "minImpressions": "Minimum reported impressions required for a row to enter the analysis.",
95
+ "objectives": "Analysis goals that determine the recommended large-site sampling strategy.",
96
+ "pageLimit": "Maximum number of pages considered for the sampling plan.",
97
+ "maxInspectionUrls": "Maximum URL Inspection candidates included in the plan; inspection calls consume provider quota.",
98
+ "minImpressionsForLowCtr": "Minimum impressions before a page is considered for low-CTR analysis.",
99
+ "staleSitemapDays": "Number of days after which a sitemap's last download is considered stale.",
100
+ "continueOnError": "Continue with other URLs when one inspection fails, returning individual failures.",
101
+ "dimension": "Reporting dimension used to group and compare results.",
102
+ "maxClusters": "Maximum query clusters to return.",
103
+ "topQueriesPerCluster": "Maximum example queries returned for each cluster.",
104
+ "minPages": "Minimum distinct pages ranking for a query before it is considered a cannibalization candidate.",
105
+ "adAccountId": "Owning advertising account ID. Use the exact ID returned by account discovery; do not substitute a campaign or business ID.",
106
+ "advertiserId": "TikTok advertiser account ID as a string.",
107
+ "customerId": "Google Ads customer ID, without hyphens.",
108
+ "propertyId": "Google Analytics property ID; use the property, not the account or measurement ID.",
109
+ "level": "Entity or aggregation level for the requested report.",
110
+ "dataLevel": "TikTok reporting aggregation level. Metrics and dimensions must be compatible with this level.",
111
+ "granularity": "Time bucket used to aggregate reporting rows.",
112
+ "columns": "Native Pinterest reporting column names. Discover supported columns before selecting metrics.",
113
+ "executionMode": "Choose the supported synchronous, asynchronous or automatic report execution strategy.",
114
+ "entityIds": "Exact IDs of the entities to include in the selected reporting level.",
115
+ "targetingTypes": "Native targeting categories used to break down delivery.",
116
+ "clickWindowDays": "Click-through attribution window, in days.",
117
+ "engagementWindowDays": "Engagement-through attribution window, in days.",
118
+ "viewWindowDays": "View-through attribution window, in days.",
119
+ "conversionReportTime": "Whether conversions are assigned to the ad action date or the conversion date.",
120
+ "attributionTypes": "Provider attribution categories included in the report.",
121
+ "reportingTimezone": "Provider reporting timezone selector. Keep it consistent when comparing periods.",
122
+ "campaignIds": "Restrict results to these campaign IDs.",
123
+ "adGroupIds": "Restrict results to these ad group IDs.",
124
+ "adgroupIds": "Restrict TikTok results to these ad group IDs.",
125
+ "adIds": "Restrict results to these ad IDs.",
126
+ "campaignId": "Exact campaign ID in the selected advertising account.",
127
+ "adGroupId": "Exact ad group ID in the selected advertising account.",
128
+ "adSetId": "Exact Meta ad set ID in the selected advertising account.",
129
+ "adId": "Exact ad ID in the selected advertising account.",
130
+ "entityId": "Exact ID of the entity selected by this operation. It must belong to the selected account.",
131
+ "onlyWithPeriodDelivery": "Keep only creatives with observed delivery in the requested date range.",
132
+ "breakdown": "Native reporting breakdown used to group results.",
133
+ "breakdowns": "Native reporting breakdowns. Check compatibility before combining them.",
134
+ "productGroupIds": "Restrict reporting to these product group IDs.",
135
+ "productItemIds": "Restrict reporting to these product item IDs.",
136
+ "includeProductSamples": "Include a limited sample of product records alongside inventory diagnostics.",
137
+ "sampleProductGroups": "Maximum product groups to sample for inventory diagnostics.",
138
+ "reportName": "Provider report name identifying the requested report recipe.",
139
+ "conversionProductAttributionType": "Pinterest product-conversion attribution category.",
140
+ "conversionProductBreakdown": "Pinterest product-conversion grouping level.",
141
+ "productSkuIds": "Restrict product-conversion reporting to these SKU IDs.",
142
+ "request": "Exact request schema name to inspect before composing a write payload.",
143
+ "entity": "Entity collection or level to inspect.",
144
+ "entityType": "Provider entity type to retrieve.",
145
+ "entityStatuses": "Native status values used to filter the entity collection.",
146
+ "statusFilter": "Native status values used to filter the returned entities.",
147
+ "targetingType": "Targeting dictionary to inspect, such as interests, locations or demographics.",
148
+ "interestId": "Native interest ID for the targeting lookup.",
149
+ "mode": "Operation or analysis mode. Only the values declared in this schema are supported.",
150
+ "matchTypes": "Keyword match types included in the lookup.",
151
+ "countryCode": "Country code used to localize keyword or targeting results.",
152
+ "keywords": "Keyword phrases to analyze; use the language and market of the intended audience.",
153
+ "term": "Search term used for the keyword lookup.",
154
+ "terms": "Search terms used for the keyword lookup.",
155
+ "audienceId": "Exact ID of the audience to inspect or modify.",
156
+ "customerListId": "Exact customer-list ID. Reading metadata does not upload customer data.",
157
+ "businessId": "Exact business account ID used for the lookup.",
158
+ "accountType": "Provider account category used to filter results.",
159
+ "ownershipType": "Filter audiences by provider ownership category.",
160
+ "excludeNca": "Whether to exclude Pinterest new-customer-acquisition audiences from this lookup.",
161
+ "audienceInsightType": "Pinterest audience-insights category to request.",
162
+ "surfaces": "Conversion-setup surfaces to inspect, according to the supported values.",
163
+ "includeDeletedTags": "Include deleted conversion tags in diagnostics.",
164
+ "lookbackPeriod": "Provider lookback period used for conversion diagnostics.",
165
+ "sourcePlatform": "Filter conversion data by source platform.",
166
+ "ingestionSource": "Filter conversion events by ingestion source.",
167
+ "catalogId": "Exact product catalog ID accessible to the selected account.",
168
+ "feedId": "Exact catalog feed ID.",
169
+ "processingResultId": "Exact catalog feed processing result ID to inspect.",
170
+ "productGroupId": "Exact product group ID.",
171
+ "productSetId": "Exact Meta product set ID belonging to the selected catalog.",
172
+ "exportType": "Provider export category to request.",
173
+ "action": "Supported operation within this tool's scoped provider surface.",
174
+ "includeDetails": "Include detailed provider records in addition to summary information.",
175
+ "leadFormId": "Exact lead form ID to inspect.",
176
+ "subscriptionId": "Exact subscription ID to inspect.",
177
+ "assetId": "Exact provider asset ID.",
178
+ "memberId": "Exact business member ID used for the read lookup.",
179
+ "partnerId": "Exact business partner ID used for the read lookup.",
180
+ "pinId": "Exact Pinterest pin ID.",
181
+ "pinIds": "Exact Pinterest pin IDs to include.",
182
+ "metrics": "Native or documented calculated metric names to request. Check compatibility with the selected dimensions.",
183
+ "metric": "Metric used for the analysis; use a name from the platform metric catalogue.",
184
+ "boardId": "Exact Pinterest board ID.",
185
+ "searchTerm": "Text used to filter or search the selected collection.",
186
+ "region": "Provider region selector used to localize trends.",
187
+ "productRegion": "Region used for product trend analysis.",
188
+ "trendType": "Provider trend category to return.",
189
+ "interests": "Native interest categories used to filter trends.",
190
+ "genders": "Native gender categories used to filter aggregate trends.",
191
+ "ageBuckets": "Native age groups used to filter aggregate trends.",
192
+ "includeKeywords": "Include related keyword details in the trend response.",
193
+ "includeDemographics": "Include available aggregate demographic breakdowns.",
194
+ "productCategories": "Native product category IDs or values used to filter trends.",
195
+ "verticals": "Native business verticals used to filter trends.",
196
+ "productLookbackDays": "Product-trend lookback window, in days.",
197
+ "productEngagementType": "Engagement signal used for product-trend analysis.",
198
+ "featuredInterest": "Featured interest category for the trends lookup.",
199
+ "reportType": "Native provider report type; determines supported metrics and dimensions.",
200
+ "promotionId": "Exact Pinterest product-group promotion ID.",
201
+ "type": "Provider object or operation type selected from this schema's allowed values.",
202
+ "productBreakdown": "Native product grouping used to join catalog items with reported insights.",
203
+ "catalogProductLimit": "Maximum catalog product records read for the join.",
204
+ "includeAdsets": "Include ad set configuration when checking brand safety.",
205
+ "includeBlockLists": "Include accessible brand-safety block-list metadata.",
206
+ "includeRawTargeting": "Include the native targeting specification for diagnostics.",
207
+ "cellEntityType": "Entity type represented by experiment cells.",
208
+ "includePagePosts": "Read related Facebook Page posts when the token has permission.",
209
+ "includeInstagramMedia": "Read related Instagram media when the token has permission.",
210
+ "includeInsights": "Include available insight metrics; additional provider permissions may be required.",
211
+ "edge": "Allowlisted Meta Graph edge to read on the selected node.",
212
+ "fields": "Native provider fields to return. Select only fields supported by the chosen object or report.",
213
+ "filtering": "Native provider filter expressions; credentials and account overrides are not allowed.",
214
+ "includeSummary": "Request the provider's available summary or total-count metadata.",
215
+ "actionBreakdowns": "Meta action-level breakdowns, subject to Insights compatibility rules.",
216
+ "actionAttributionWindows": "Meta attribution windows used to attribute action metrics.",
217
+ "timeIncrement": "Provider time-bucket size or supported aggregate value.",
218
+ "locationTypes": "Location dictionary categories to include in targeting search.",
219
+ "adFormat": "Native Meta ad preview format; must be compatible with this ad's creative.",
220
+ "handle": "Asynchronous catalog batch handle returned by the submission to inspect.",
221
+ "keepEmptyRows": "Keep provider rows whose selected metrics are all zero, where supported.",
222
+ "sampleLimit": "Maximum records included in the diagnostic sample.",
223
+ "isOpenFunnel": "Allow users to enter at any funnel step instead of requiring the first step.",
224
+ "visualizationType": "Supported funnel visualization mode.",
225
+ "breakdownLimit": "Maximum breakdown values to include in the funnel response.",
226
+ "nextActionLimit": "Maximum next actions returned for each funnel step.",
227
+ "fallbackToStepCounts": "If the advanced funnel is unavailable, return independent step counts with the limitation made explicit.",
228
+ "report": "Native GA4 report request object for this endpoint.",
229
+ "reports": "Native GA4 report request objects to execute in one batch.",
230
+ "dimensions": "Native reporting dimensions. Validate compatibility with the selected metrics.",
231
+ "compatibilityFilter": "Limit compatibility results to the requested GA4 compatibility category.",
232
+ "collection": "Allowlisted administrative resource collection to read.",
233
+ "settings": "Property configuration surfaces to inspect.",
234
+ "includeRecurring": "Include recurring audience-export configurations.",
235
+ "audienceExportName": "Full GA4 audience-export resource name returned by list_audience_exports.",
236
+ "queryLifetime": "Request lifetime reporting instead of the explicit reporting period where TikTok supports it.",
237
+ "declineThresholdPct": "Percentage decline used as a candidate signal for creative fatigue, requiring further evidence.",
238
+ "includeSavedAudiences": "Include accessible saved-audience metadata in the overlap analysis.",
239
+ "overlapThreshold": "Overlap threshold used to flag audience pairs, in the units declared by this schema.",
240
+ "serviceType": "TikTok report service category.",
241
+ "catalog": "Allowlisted targeting dictionary or catalog to inspect.",
242
+ "query": "Search text used to find matching entries in the selected catalogue.",
243
+ "parameters": "Native query parameters for this allowlisted read endpoint. Do not provide credentials or account overrides.",
244
+ "account_id": "Exact account ID selected for this platform in the GetMCPAds workspace.",
245
+ "platform": "Platform key identifying the connected advertising or analytics source.",
246
+ "id": "Exact saved object ID returned by the corresponding create or list tool.",
247
+ "revision": "Last known object revision, used to reject conflicting updates. Read the object before editing.",
248
+ "name": "Human-readable name for this object.",
249
+ "title": "Human-readable title for the resulting object or review.",
250
+ "clientLabel": "Display label identifying the client in the saved view.",
251
+ "config": "Complete saved-view configuration, including source, selected account, period and display options.",
252
+ "subject": "Entity or creative subject whose dated performance will be analyzed.",
253
+ "compare_previous": "Include the immediately preceding period of equal duration for comparison.",
254
+ "profile": "Creative brief profile containing the client's business context and analysis preferences.",
255
+ "primaryMetric": "Metric used to rank or evaluate the review; it must be available for the selected platform.",
256
+ "direction": "Whether higher or lower values of the primary metric represent improvement.",
257
+ "creative_ids": "Exact creative IDs to include in this review.",
258
+ "request_id": "Caller-generated request identifier. Reuse it for a retry of the same operation to avoid duplicate work.",
259
+ "expiresInDays": "Number of days before the public review sharing link expires.",
260
+ "client_id": "Exact saved business-client workspace ID.",
261
+ "review_id": "Exact review ID returned by the review creation or listing tool.",
262
+ "page_id": "Exact page identifier within the landing-page review.",
263
+ "entity_id": "Exact entity identifier in the selected review or platform context.",
264
+ "source_key": "Source identifier returned by the business review; keep the platform/account association intact.",
265
+ "metric_key": "Exact metric key in the review evidence.",
266
+ "follow_up_on": "Follow-up date for the decision, using the format declared in this schema.",
267
+ "hypothesis": "Specific hypothesis to record, supported by the review evidence.",
268
+ "successCriterion": "Measurable condition used to decide whether the recorded action succeeded.",
269
+ "outcome": "Observed outcome to record; distinguish evidence from an untested expectation.",
270
+ "status": "Status selected from the allowed values for this object or operation.",
271
+ "retry_source": "Source identifier to retry after reviewing its reported error.",
272
+ "site_origin": "Website origin to analyze, including https:// and hostname, without credentials or a path.",
273
+ "matchType": "Google Ads keyword match type for the forecast.",
274
+ "negativeKeywords": "Keyword phrases excluded from the keyword forecast.",
275
+ "negativeMatchType": "Match type applied to the negative keywords.",
276
+ "geoTargetIds": "Google Ads geo-target constant IDs for the intended locations.",
277
+ "languageId": "Google Ads language constant ID for the keyword request.",
278
+ "languageIds": "Google Ads language constant IDs for the intended audience.",
279
+ "network": "Google Ads search network selector used for planning.",
280
+ "biddingStrategy": "Supported bidding strategy for this temporary forecast. The request does not create a live campaign.",
281
+ "includeAdultKeywords": "Allow adult keyword ideas where supported by the provider and the selected market.",
282
+ "seedKeywords": "Seed keyword phrases from which Google generates keyword ideas.",
283
+ "includeAverageCpc": "Request average cost-per-click metrics when available.",
284
+ "includeDeviceBreakdown": "Include device-specific planning metrics when supported.",
285
+ "includeKeywordConcepts": "Include Google's keyword concept grouping metadata.",
286
+ "startYearMonth": "First year and month of the historical metrics period.",
287
+ "endYearMonth": "Last year and month of the historical metrics period.",
288
+ "includeDismissed": "Include recommendations already dismissed in the account.",
289
+ "operation": "Exact allowlisted operation to execute. Arbitrary provider operations are not accepted.",
290
+ "category": "Category selected from the provider dictionary or field catalogue.",
291
+ "selectable": "Filter the field catalogue by whether the field can appear in SELECT.",
292
+ "filterable": "Filter the field catalogue by whether the field can appear in WHERE.",
293
+ "sortable": "Filter the field catalogue by whether the field can appear in ORDER BY.",
294
+ "pageToken": "Opaque continuation token returned by the preceding provider response.",
295
+ "locationNames": "Human-readable location names for Google to resolve into geo-target constants.",
296
+ "locale": "Locale used for human-readable lookup results.",
297
+ "resource": "Native Google Ads resource from which the GAQL query selects rows.",
298
+ "cursor": "Opaque pagination cursor returned by the preceding response; omit for the first page.",
299
+ "kind": "Entity or request kind whose schema or data is being requested.",
300
+ "startTime": "Beginning of the reporting period as an ISO 8601 timestamp with an explicit timezone.",
301
+ "endTime": "End of the reporting period as an ISO 8601 timestamp with an explicit timezone, after startTime.",
302
+ "reportDimension": "Native dimension used to break down the Snapchat report."
303
+ };
304
+
305
+ // src/tool-quality.ts
306
+ import { z as z2 } from "zod";
307
+ var RESULT_SCHEMA = {
308
+ type: "object",
309
+ properties: {
310
+ result: {
311
+ description: "Original tool result: parsed JSON when the text is JSON, otherwise the text or multiple MCP content blocks. Provider fields depend on the selected query.",
312
+ type: ["object", "array", "string", "number", "boolean", "null"]
313
+ }
314
+ },
315
+ required: ["result"],
316
+ additionalProperties: true
317
+ };
318
+ function toolAnnotations(write) {
319
+ return { readOnlyHint: !write, destructiveHint: write, idempotentHint: !write, openWorldHint: true };
320
+ }
321
+ function structuredResult(value) {
322
+ if (value.isError) return value;
323
+ let result2 = value.content;
324
+ const first = value.content[0];
325
+ if (value.content.length === 1 && first?.type === "text" && typeof first.text === "string") {
326
+ result2 = first.text;
327
+ try {
328
+ result2 = JSON.parse(first.text);
329
+ } catch {
330
+ }
331
+ }
332
+ return { ...value, structuredContent: { ...value.structuredContent, result: result2 } };
333
+ }
334
+ var resultShape = { result: z2.union([z2.object({}).passthrough(), z2.array(z2.unknown()), z2.string(), z2.number(), z2.boolean(), z2.null()]).describe(RESULT_SCHEMA.properties.result.description) };
335
+ function installToolQuality(server) {
336
+ server.tool = ((name, description, inputSchema, handler) => {
337
+ const write = Object.hasOwn(inputSchema, "confirm");
338
+ inputSchema = Object.fromEntries(Object.entries(inputSchema).map(([key, schema]) => [key, schema.description || PARAMETER_DESCRIPTIONS[key] ? schema.describe(schema.description || PARAMETER_DESCRIPTIONS[key]) : schema]));
339
+ return server.registerTool(name, {
340
+ title: name.replace(/_/g, " "),
341
+ description,
342
+ inputSchema,
343
+ outputSchema: resultShape,
344
+ annotations: toolAnnotations(write)
345
+ }, async (...args) => structuredResult(await handler(...args)));
346
+ });
347
+ }
348
+
67
349
  // src/server.ts
68
350
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
69
351
 
352
+ // src/platforms/google-ads/extended-writes.ts
353
+ import { webcrypto } from "crypto";
354
+ import { z as z3 } from "zod";
355
+ var BASE = "https://googleads.googleapis.com/v25";
356
+ var resources = {
357
+ conversionActionOperation: "conversionActions",
358
+ campaignBudgetOperation: "campaignBudgets",
359
+ campaignOperation: "campaigns",
360
+ adGroupOperation: "adGroups",
361
+ adGroupAdOperation: "adGroupAds",
362
+ adOperation: "ads",
363
+ assetOperation: "assets",
364
+ assetGroupOperation: "assetGroups",
365
+ assetGroupAssetOperation: "assetGroupAssets",
366
+ campaignAssetOperation: "campaignAssets",
367
+ campaignCriterionOperation: "campaignCriteria",
368
+ adGroupCriterionOperation: "adGroupCriteria",
369
+ assetGroupListingGroupFilterOperation: "assetGroupListingGroupFilters"
370
+ };
371
+ var json = z3.record(z3.unknown());
372
+ var operation = z3.object({
373
+ kind: z3.enum(
374
+ Object.keys(resources)
375
+ ),
376
+ create: json.optional(),
377
+ update: json.optional(),
378
+ remove: z3.string().optional(),
379
+ updateMask: z3.string().min(1).optional()
380
+ }).strict();
381
+ var result = (v) => ({
382
+ content: [{ type: "text", text: JSON.stringify(v, null, 2) }]
383
+ });
384
+ function canonical(v) {
385
+ if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
386
+ if (v && typeof v === "object")
387
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`;
388
+ return JSON.stringify(v);
389
+ }
390
+ async function digest(v) {
391
+ return Array.from(
392
+ new Uint8Array(
393
+ await webcrypto.subtle.digest(
394
+ "SHA-256",
395
+ new TextEncoder().encode(canonical(v))
396
+ )
397
+ )
398
+ ).map((n) => n.toString(16).padStart(2, "0")).join("");
399
+ }
400
+ function previewData(v) {
401
+ if (Array.isArray(v)) return v.map(previewData);
402
+ if (v && typeof v === "object")
403
+ return Object.fromEntries(
404
+ Object.entries(v).map(([k, x]) => [
405
+ k,
406
+ k === "data" && typeof x === "string" ? `[base64 media: ${x.length} characters]` : previewData(x)
407
+ ])
408
+ );
409
+ return v;
410
+ }
411
+ function normalizeGooglePlan(customerId, input) {
412
+ if (!/^\d{10}$/.test(customerId))
413
+ throw new Error("Use a ten-digit customer ID.");
414
+ const prefix = `customers/${customerId}/`;
415
+ function check(v) {
416
+ if (typeof v === "string" && v.startsWith("customers/") && !v.startsWith(prefix))
417
+ throw new Error(
418
+ "Every customer resource must belong to the selected customerId."
419
+ );
420
+ if (Array.isArray(v)) v.forEach(check);
421
+ else if (v && typeof v === "object")
422
+ for (const [k, x] of Object.entries(v)) {
423
+ if (/access.?token|refresh.?token|developer.?token|authorization|client.?secret/i.test(
424
+ k
425
+ ))
426
+ throw new Error("Do not put credentials in a plan.");
427
+ if (k === "data" && typeof x === "string") {
428
+ let valid = x.length > 0 && x.length <= 6990508 && /^[A-Za-z0-9+/]*={0,2}$/.test(x);
429
+ try {
430
+ valid = valid && atob(x).length <= 5 * 1024 * 1024;
431
+ } catch {
432
+ valid = false;
433
+ }
434
+ if (!valid) throw new Error("Image data must be base64, at most 5 MiB decoded.");
435
+ }
436
+ check(x);
437
+ }
438
+ }
439
+ return input.map((raw) => {
440
+ const op = operation.parse(raw);
441
+ if ([op.create, op.update, op.remove].filter((x) => x !== void 0).length !== 1)
442
+ throw new Error(
443
+ "Each operation needs exactly one of create, update or remove."
444
+ );
445
+ if (op.update && !op.updateMask)
446
+ throw new Error("Updates require an explicit updateMask.");
447
+ if (op.kind === "adOperation" && op.update && ("name" in op.update || op.updateMask?.split(",").some((field) => field.trim() === "name")))
448
+ throw new Error("Google ad.name is immutable. Edit supported creative fields, or explicitly create a new paused ad with the desired name; never silently replace the original.");
449
+ if (!op.update && op.updateMask)
450
+ throw new Error("updateMask is only valid on updates.");
451
+ if (op.updateMask && /(^|,)\s*(\*|resource_name)\s*(,|$)/.test(op.updateMask))
452
+ throw new Error(
453
+ "Use a precise updateMask; never wildcard or resource_name."
454
+ );
455
+ const body = structuredClone(op.create ?? op.update);
456
+ const expected = prefix + resources[op.kind] + "/";
457
+ const resource = op.remove ?? body?.resourceName;
458
+ if ((op.update || op.remove) && !resource)
459
+ throw new Error("Update/remove requires a resource name.");
460
+ if (resource && (!resource.startsWith(expected) || !/^-?\d+(~[A-Za-z0-9_-]+)*$/.test(resource.slice(expected.length))))
461
+ throw new Error(`Expected resource in ${expected}.`);
462
+ if (op.create && [
463
+ "campaignOperation",
464
+ "adGroupOperation",
465
+ "adGroupAdOperation",
466
+ "assetGroupOperation"
467
+ ].includes(op.kind)) {
468
+ if (body.status && body.status !== "PAUSED")
469
+ throw new Error("New campaigns, groups and ads must be PAUSED.");
470
+ body.status = "PAUSED";
471
+ }
472
+ if (op.create && op.kind === "campaignOperation") {
473
+ if (body.advertisingChannelType === "VIDEO")
474
+ throw new Error(
475
+ "Google Ads API cannot create VIDEO campaigns. Use an explicitly requested DEMAND_GEN video campaign instead."
476
+ );
477
+ if (![
478
+ "CONTAINS_EU_POLITICAL_ADVERTISING",
479
+ "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING"
480
+ ].includes(body.containsEuPoliticalAdvertising))
481
+ throw new Error(
482
+ "Explicit EU political advertising declaration required."
483
+ );
484
+ }
485
+ const value = op.remove ? { remove: op.remove } : op.create ? { create: body } : { update: body, updateMask: op.updateMask };
486
+ check(value);
487
+ return { [op.kind]: value };
488
+ });
489
+ }
490
+ function registerGoogleVideoReads(c, config) {
491
+ c.tool("google_ads_get_video_upload", "Read the processing state and YouTube video ID of one Google Ads upload in the selected customer. Use only the returned videoId after state=PROCESSED. This is a read; it does not upload again.", {
492
+ customerId: z3.string().regex(/^[\d-]+$/),
493
+ currency: z3.string().regex(/^[A-Z]{3}$/).describe("Actual customer currency, verified before reading the upload."),
494
+ uploadId: z3.string().regex(/^\d+$/).describe("Numeric upload ID returned by google_ads_upload_video."),
495
+ loginCustomerId: z3.string().regex(/^[\d-]+$/).optional().describe("Optional MCC manager ID; defaults to the configured manager."),
496
+ testAccountOnly: z3.boolean().optional().describe("Require customer.test_account=true; enable for sandbox checks.")
497
+ }, async (raw) => {
498
+ const a = z3.object({ customerId: z3.string().regex(/^[\d-]+$/), currency: z3.string().regex(/^[A-Z]{3}$/), uploadId: z3.string().regex(/^\d+$/), loginCustomerId: z3.string().regex(/^[\d-]+$/).optional(), testAccountOnly: z3.boolean().optional() }).strict().parse(raw);
499
+ const cid = a.customerId.replace(/-/g, "");
500
+ const { api } = await googleSession(config, cid, a.currency, a.testAccountOnly === true, (a.loginCustomerId ?? config.loginCustomerId)?.replace(/-/g, ""));
501
+ const resourceName = `customers/${cid}/youTubeVideoUploads/${a.uploadId}`;
502
+ const response = await api("googleAds:search", { query: `SELECT you_tube_video_upload.resource_name, you_tube_video_upload.state, you_tube_video_upload.video_id FROM you_tube_video_upload WHERE you_tube_video_upload.resource_name = '${resourceName}' LIMIT 1` });
503
+ const video = response.results?.[0]?.youTubeVideoUpload;
504
+ return result({ uploadId: a.uploadId, state: video?.state ?? "NOT_FOUND", ...video?.state === "PROCESSED" && video.videoId ? { videoId: video.videoId } : {} });
505
+ });
506
+ }
507
+ function registerGoogleExtendedWrites(c, config) {
508
+ const original = c;
509
+ const descriptions = {
510
+ customerId: "Selected Google Ads customer ID, not the manager account.",
511
+ currency: "Actual account currency as a three-letter ISO code; verified natively before writes.",
512
+ loginCustomerId: "Optional MCC ID; defaults to the selected account's inferred manager.",
513
+ testAccountOnly: "Require customer.test_account=true before writing. Always use true for sandbox acceptance.",
514
+ confirm: "Apply only when true and expectedPlanHash matches the exact preview.",
515
+ validateOnly: "Ask Google to validate without applying. Takes priority over confirm.",
516
+ expectedPlanHash: "SHA-256 planHash from the preview of this exact payload, required for application.",
517
+ uploadId: "Numeric ID of a video uploaded through Google Ads in this customer.",
518
+ title: "Nonempty title for this new video upload.",
519
+ mimeType: "Actual MIME type of the supplied video bytes."
520
+ };
521
+ c = {
522
+ tool: (n, d, shape, h) => original.tool(
523
+ n,
524
+ d,
525
+ Object.fromEntries(
526
+ Object.entries(shape).map(([k, v]) => [
527
+ k,
528
+ v.description ? v : v.describe(descriptions[k] ?? k)
529
+ ])
530
+ ),
531
+ h
532
+ )
533
+ };
534
+ registerGoogleVideoWrites(c, config);
535
+ c.tool(
536
+ "google_ads_apply_plan",
537
+ "Preview, validate and atomically apply a Google Ads v25 build/change plan: campaigns, budgets, ad groups, RSA/Display/Demand Gen ads, image/text/YouTube assets, PMax asset groups and links, keywords, geographic/language targeting listing groups and conversion action configuration (no conversion event uploads). Native REST camelCase fields; amounts are integer micros of the explicitly stated currency. Supports temporary negative resource IDs for ordered creation in one transaction. No VIDEO campaign creation (Google API restriction), billing, account access changes, customer lists or conversion uploads. New campaigns/groups/ads are always PAUSED. Call first without confirm to inspect the plan and get planHash; validateOnly asks Google without applying; execution requires confirm and the matching expectedPlanHash. Read back results with google_ads_run_gaql. Never retry a creation after an uncertain network result before checking its name/IDs.",
538
+ {
539
+ customerId: z3.string().regex(/^[\d-]+$/),
540
+ currency: z3.string().regex(/^[A-Z]{3}$/).describe(
541
+ "Actual account currency, mandatory even when only linking assets."
542
+ ),
543
+ operations: z3.array(operation).min(1).max(100).describe(
544
+ "Ordered operations. kind is a native MutateOperation field; create/update contains native REST fields; remove is the full owned resource name. Images use imageAsset.data base64 bytes; videos use youtubeVideoAsset.youtubeVideoId (already uploaded on YouTube)."
545
+ ),
546
+ loginCustomerId: z3.string().regex(/^[\d-]+$/).optional(),
547
+ testAccountOnly: z3.boolean().optional().describe(
548
+ "Require Google's customer.test_account=true before any mutation. Use true for acceptance tests."
549
+ ),
550
+ validateOnly: z3.boolean().optional(),
551
+ confirm: z3.boolean().optional(),
552
+ expectedPlanHash: z3.string().regex(/^[a-f0-9]{64}$/).optional()
553
+ },
554
+ async (a) => {
555
+ const cid = a.customerId.replace(/-/g, "");
556
+ const operations = normalizeGooglePlan(cid, a.operations);
557
+ if (JSON.stringify(operations).length > 12e6)
558
+ throw new Error(
559
+ "Plan too large; upload assets in separate confirmed plans and reference their resource names."
560
+ );
561
+ const manager = (a.loginCustomerId ?? config.loginCustomerId)?.replace(
562
+ /-/g,
563
+ ""
564
+ );
565
+ const planHash = await digest({
566
+ customerId: cid,
567
+ currency: a.currency,
568
+ manager: manager ?? null,
569
+ testAccountOnly: a.testAccountOnly === true,
570
+ operations
571
+ });
572
+ if (!a.confirm && !a.validateOnly)
573
+ return result({
574
+ applied: false,
575
+ planHash,
576
+ customerId: cid,
577
+ currency: a.currency,
578
+ operationCount: operations.length,
579
+ operations: previewData(operations)
580
+ });
581
+ if (a.confirm && !a.validateOnly && a.expectedPlanHash !== planHash)
582
+ throw new Error(
583
+ "Preview this exact plan first and provide its matching expectedPlanHash."
584
+ );
585
+ const { api, customer } = await googleSession(
586
+ config,
587
+ cid,
588
+ a.currency,
589
+ a.testAccountOnly,
590
+ manager
591
+ );
592
+ const response = await api("googleAds:mutate", {
593
+ mutateOperations: operations,
594
+ partialFailure: false,
595
+ validateOnly: a.validateOnly === true
596
+ });
597
+ return result({
598
+ applied: a.validateOnly !== true,
599
+ validatedByGoogle: true,
600
+ planHash,
601
+ operationCount: operations.length,
602
+ customerId: cid,
603
+ currency: a.currency,
604
+ testAccount: customer.testAccount === true,
605
+ result: response
606
+ });
607
+ }
608
+ );
609
+ }
610
+ async function googleSession(config, cid, currency, testAccountOnly, manager) {
611
+ const auth = await fetch("https://oauth2.googleapis.com/token", {
612
+ method: "POST",
613
+ headers: { "content-type": "application/x-www-form-urlencoded" },
614
+ body: new URLSearchParams({
615
+ grant_type: "refresh_token",
616
+ client_id: config.clientId,
617
+ client_secret: config.clientSecret,
618
+ refresh_token: config.refreshToken
619
+ }),
620
+ redirect: "error"
621
+ });
622
+ const token = await auth.json();
623
+ if (!auth.ok || !token.access_token)
624
+ throw new Error(
625
+ "Google authorization could not be refreshed. Reconnect the Google Ads source."
626
+ );
627
+ const headers = {
628
+ authorization: `Bearer ${token.access_token}`,
629
+ "developer-token": config.developerToken,
630
+ "content-type": "application/json"
631
+ };
632
+ if (manager) headers["login-customer-id"] = manager;
633
+ async function api(path, body) {
634
+ const r = await fetch(`${BASE}/customers/${cid}/${path}`, {
635
+ method: "POST",
636
+ headers,
637
+ body: JSON.stringify(body),
638
+ redirect: "error"
639
+ });
640
+ const x = await r.json();
641
+ if (!r.ok) {
642
+ const errors = x.error?.details?.flatMap((d) => d.errors ?? []).map((e) => ({
643
+ code: e.errorCode,
644
+ message: e.message,
645
+ location: e.location
646
+ }));
647
+ throw new Error(
648
+ `Google Ads: ${JSON.stringify(errors?.length ? errors : x.error?.message ?? r.status).slice(0, 4e3)}`
649
+ );
650
+ }
651
+ return x;
652
+ }
653
+ const read = await api("googleAds:search", {
654
+ query: "SELECT customer.id, customer.currency_code, customer.test_account FROM customer"
655
+ });
656
+ const customer = read.results?.[0]?.customer;
657
+ if (customer?.id !== cid || customer?.currencyCode !== currency)
658
+ throw new Error("Customer identity or currency does not match the plan.");
659
+ if (testAccountOnly && customer.testAccount !== true)
660
+ throw new Error("This plan requires a Google test account.");
661
+ return { headers, api, customer };
662
+ }
663
+ function registerGoogleVideoWrites(c, config) {
664
+ const common = {
665
+ customerId: z3.string().regex(/^[\d-]+$/),
666
+ currency: z3.string().regex(/^[A-Z]{3}$/),
667
+ loginCustomerId: z3.string().regex(/^[\d-]+$/).optional(),
668
+ testAccountOnly: z3.boolean().optional(),
669
+ confirm: z3.boolean().optional(),
670
+ expectedPlanHash: z3.string().regex(/^[a-f0-9]{64}$/).optional()
671
+ };
672
+ c.tool(
673
+ "google_ads_upload_video",
674
+ "Upload MP4/WebM/QuickTime bytes (max 16 MiB) to the Google-managed YouTube channel for the selected ad account. Visibility is UNLISTED: viewable by anyone with the link, not private. Default preview omits bytes and returns planHash; confirm requires expectedPlanHash. This API has no validateOnly. After upload, query you_tube_video_upload.state and video_id with google_ads_run_gaql; only use the video ID after PROCESSED, via google_ads_apply_plan assetOperation.youtubeVideoAsset. No automatic retry after an uncertain upload.",
675
+ {
676
+ ...common,
677
+ bytesBase64: z3.string().min(4).max(22369624).describe(
678
+ "Raw base64 video bytes, no data URL. Prefer an existing YouTube asset for large videos."
679
+ ),
680
+ mimeType: z3.enum(["video/mp4", "video/webm", "video/quicktime"]),
681
+ title: z3.string().trim().min(1).max(100),
682
+ description: z3.string().trim().min(1).max(5e3).describe(
683
+ "Nonempty video description; Google rejects an empty value during upload."
684
+ ),
685
+ visibility: z3.literal("UNLISTED").describe(
686
+ "Explicit acknowledgment of the visibility of this YouTube upload."
687
+ )
688
+ },
689
+ async (a) => {
690
+ const cid = a.customerId.replace(/-/g, "");
691
+ if (!/^\d{10}$/.test(cid))
692
+ throw new Error("Use a ten-digit customer ID.");
693
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(a.bytesBase64))
694
+ throw new Error("Invalid base64 video.");
695
+ let binary;
696
+ try {
697
+ binary = atob(a.bytesBase64);
698
+ } catch {
699
+ throw new Error("Invalid base64 video.");
700
+ }
701
+ if (binary.length > 16 * 1024 * 1024)
702
+ throw new Error("Video exceeds 16 MiB.");
703
+ const manager = (a.loginCustomerId ?? config.loginCustomerId)?.replace(
704
+ /-/g,
705
+ ""
706
+ );
707
+ const planHash = await digest({
708
+ customerId: cid,
709
+ currency: a.currency,
710
+ manager: manager ?? null,
711
+ testAccountOnly: a.testAccountOnly === true,
712
+ title: a.title,
713
+ description: a.description ?? "",
714
+ mimeType: a.mimeType,
715
+ visibility: a.visibility,
716
+ bytes: a.bytesBase64
717
+ });
718
+ if (!a.confirm)
719
+ return result({
720
+ applied: false,
721
+ planHash,
722
+ customerId: cid,
723
+ title: a.title,
724
+ description: a.description,
725
+ mimeType: a.mimeType,
726
+ visibility: a.visibility,
727
+ byteLength: binary.length
728
+ });
729
+ if (a.expectedPlanHash !== planHash)
730
+ throw new Error(
731
+ "Preview this exact upload first and supply expectedPlanHash."
732
+ );
733
+ const { headers } = await googleSession(
734
+ config,
735
+ cid,
736
+ a.currency,
737
+ a.testAccountOnly,
738
+ manager
739
+ );
740
+ const start = await fetch(
741
+ `https://googleads.googleapis.com/resumable/upload/v25/customers/${cid}/youTubeVideoUploads:create`,
742
+ {
743
+ method: "POST",
744
+ headers: {
745
+ ...headers,
746
+ "X-Goog-Upload-Protocol": "resumable",
747
+ "X-Goog-Upload-Command": "start",
748
+ "X-Goog-Upload-Header-Content-Length": String(binary.length)
749
+ },
750
+ body: JSON.stringify({
751
+ customer_id: cid,
752
+ you_tube_video_upload: {
753
+ video_title: a.title,
754
+ video_description: a.description ?? "",
755
+ video_privacy: "UNLISTED"
756
+ }
757
+ }),
758
+ redirect: "error"
759
+ }
760
+ );
761
+ if (!start.ok)
762
+ throw new Error(
763
+ `Google video upload initiation failed (${start.status}). No automatic retry.`
764
+ );
765
+ const target = start.headers.get("x-goog-upload-url");
766
+ if (!target || new URL(target).origin !== "https://googleads.googleapis.com")
767
+ throw new Error(
768
+ "Unexpected Google upload URL; no bytes or credentials sent."
769
+ );
770
+ const uploaded = await fetch(target, {
771
+ method: "PUT",
772
+ headers: {
773
+ ...headers,
774
+ "content-type": a.mimeType,
775
+ "X-Goog-Upload-Offset": "0",
776
+ "X-Goog-Upload-Command": "upload, finalize"
777
+ },
778
+ body: Uint8Array.from(binary, (c2) => c2.charCodeAt(0)),
779
+ redirect: "error"
780
+ });
781
+ if (!uploaded.ok) {
782
+ const error = await uploaded.json().catch(() => ({}));
783
+ throw new Error(
784
+ `Google video upload failed (${uploaded.status}): ${String(error.error?.message ?? "unknown error").slice(0, 700)}. Inspect you_tube_video_upload before retrying.`
785
+ );
786
+ }
787
+ const response = await uploaded.json();
788
+ if (!String(response.resourceName).startsWith(
789
+ `customers/${cid}/youTubeVideoUploads/`
790
+ ))
791
+ throw new Error(
792
+ "Google did not return an owned video upload resource. Inspect uploads before retrying."
793
+ );
794
+ return result({
795
+ applied: true,
796
+ planHash,
797
+ resourceName: response.resourceName,
798
+ uploadId: String(response.resourceName).split("/").pop(),
799
+ visibility: "UNLISTED",
800
+ nextAction: "Query you_tube_video_upload.state and video_id; wait for PROCESSED before creating the video asset."
801
+ });
802
+ }
803
+ );
804
+ c.tool(
805
+ "google_ads_remove_video_upload",
806
+ "Remove a video upload owned by the selected ad account. Deletes the uploaded YouTube video and can break ads referencing it. Preview and matching expectedPlanHash required. Google v25 marks title/description immutable; this tool does not pretend to edit them.",
807
+ { ...common, uploadId: z3.string().regex(/^\d+$/) },
808
+ async (a) => {
809
+ const cid = a.customerId.replace(/-/g, "");
810
+ if (!/^\d{10}$/.test(cid))
811
+ throw new Error("Use a ten-digit customer ID.");
812
+ const manager = (a.loginCustomerId ?? config.loginCustomerId)?.replace(
813
+ /-/g,
814
+ ""
815
+ );
816
+ const resourceName = `customers/${cid}/youTubeVideoUploads/${a.uploadId}`;
817
+ const planHash = await digest({
818
+ customerId: cid,
819
+ currency: a.currency,
820
+ manager: manager ?? null,
821
+ testAccountOnly: a.testAccountOnly === true,
822
+ remove: resourceName
823
+ });
824
+ if (!a.confirm)
825
+ return result({ applied: false, planHash, remove: resourceName });
826
+ if (a.expectedPlanHash !== planHash)
827
+ throw new Error(
828
+ "Preview this exact removal first and supply expectedPlanHash."
829
+ );
830
+ const { api } = await googleSession(
831
+ config,
832
+ cid,
833
+ a.currency,
834
+ a.testAccountOnly,
835
+ manager
836
+ );
837
+ return result({
838
+ applied: true,
839
+ planHash,
840
+ result: await api("youTubeVideoUploads:remove", {
841
+ resourceNames: [resourceName]
842
+ })
843
+ });
844
+ }
845
+ );
846
+ }
847
+
848
+ // src/platforms/google-ads/pmax-metrics.ts
849
+ var PMAX_METRIC_FIELDS = [
850
+ "metrics.impressions",
851
+ "metrics.clicks",
852
+ "metrics.cost_micros",
853
+ "metrics.conversions",
854
+ "metrics.conversions_value",
855
+ "metrics.all_conversions",
856
+ "metrics.all_conversions_from_interactions_rate",
857
+ "metrics.all_conversions_from_interactions_value_per_interaction",
858
+ "metrics.all_conversions_value",
859
+ "metrics.all_conversions_value_per_cost",
860
+ "metrics.average_cost",
861
+ "metrics.average_cpc",
862
+ "metrics.average_cpe",
863
+ "metrics.average_cpm",
864
+ "metrics.conversions_from_interactions_rate",
865
+ "metrics.conversions_from_interactions_value_per_interaction",
866
+ "metrics.conversions_value_per_cost",
867
+ "metrics.cost_per_all_conversions",
868
+ "metrics.cost_per_conversion",
869
+ "metrics.cross_device_conversions",
870
+ "metrics.cross_device_conversions_value",
871
+ "metrics.ctr",
872
+ "metrics.engagement_rate",
873
+ "metrics.engagements",
874
+ "metrics.interaction_rate",
875
+ "metrics.interactions",
876
+ "metrics.trueview_average_cpv",
877
+ "metrics.value_per_all_conversions",
878
+ "metrics.value_per_conversion",
879
+ "metrics.video_trueview_view_rate",
880
+ "metrics.video_trueview_views",
881
+ "metrics.view_through_conversions"
882
+ ];
883
+
70
884
  // src/platforms/google-ads/tools.ts
71
- import { z as z5 } from "zod";
885
+ import { z as z7 } from "zod";
72
886
 
73
887
  // src/platforms/google-ads/types.ts
74
888
  var GoogleAdsApiException = class extends Error {
@@ -3706,9 +4520,9 @@ var BIDDING_TARGET_DIMENSIONS = [
3706
4520
  {
3707
4521
  key: "campaignStartDate",
3708
4522
  name: "Campaign Start Date",
3709
- description: "The start date of the campaign in YYYY-MM-DD format.",
4523
+ description: "The start date and time of the campaign in the account time zone (YYYY-MM-DD HH:mm:ss).",
3710
4524
  category: "entity",
3711
- apiField: "campaign.start_date",
4525
+ apiField: "campaign.start_date_time",
3712
4526
  isSegment: false,
3713
4527
  isResourceAttribute: true,
3714
4528
  compatibleResources: ["campaign"]
@@ -3716,9 +4530,9 @@ var BIDDING_TARGET_DIMENSIONS = [
3716
4530
  {
3717
4531
  key: "campaignEndDate",
3718
4532
  name: "Campaign End Date",
3719
- description: "The end date of the campaign in YYYY-MM-DD format (if set).",
4533
+ description: "The end date and time of the campaign in the account time zone (YYYY-MM-DD HH:mm:ss), if set.",
3720
4534
  category: "entity",
3721
- apiField: "campaign.end_date",
4535
+ apiField: "campaign.end_date_time",
3722
4536
  isSegment: false,
3723
4537
  isResourceAttribute: true,
3724
4538
  compatibleResources: ["campaign"]
@@ -4151,7 +4965,7 @@ function buildDateWhereClause(startDate, endDate, datePreset) {
4151
4965
  }
4152
4966
  return { clause: null, warnings: [] };
4153
4967
  }
4154
- function planQuery(request) {
4968
+ function planQuery(request2) {
4155
4969
  const {
4156
4970
  resource,
4157
4971
  metrics,
@@ -4163,7 +4977,7 @@ function planQuery(request) {
4163
4977
  orderBy,
4164
4978
  orderDirection,
4165
4979
  limit
4166
- } = request;
4980
+ } = request2;
4167
4981
  const errors = [];
4168
4982
  const warnings = [];
4169
4983
  const calculatedMetricKeys = metrics.filter((k) => isCalculatedMetric(k));
@@ -4516,13 +5330,6 @@ var PlatformApiError = class extends Error {
4516
5330
  this.retryAfter = retryAfter;
4517
5331
  this.name = "PlatformApiError";
4518
5332
  }
4519
- platform;
4520
- code;
4521
- isRateLimit;
4522
- isAuth;
4523
- isPermission;
4524
- suggestion;
4525
- retryAfter;
4526
5333
  toMcpError() {
4527
5334
  return {
4528
5335
  error: this.message,
@@ -4657,7 +5464,7 @@ var KeywordPlannerRateLimiter = class {
4657
5464
  };
4658
5465
 
4659
5466
  // src/platforms/google-ads/read-rpc.ts
4660
- import { z as z2 } from "zod";
5467
+ import { z as z4 } from "zod";
4661
5468
  var READ_ONLY_OPERATION_NAMES = [
4662
5469
  "generateAudienceCompositionInsights",
4663
5470
  "generateAudienceDefinition",
@@ -4773,13 +5580,13 @@ function validateGoogleAdsReadOnlyRequest(value, path = "request", depth = 0, st
4773
5580
  }
4774
5581
  throw new Error(`${path} must contain JSON-compatible values.`);
4775
5582
  }
4776
- function buildGoogleAdsReadOnlyPath(operation, customerId) {
4777
- const config = GOOGLE_ADS_READ_ONLY_OPERATIONS[operation];
4778
- if (!config) throw new Error(`Unsupported read-only operation: ${operation}.`);
5583
+ function buildGoogleAdsReadOnlyPath(operation2, customerId) {
5584
+ const config = GOOGLE_ADS_READ_ONLY_OPERATIONS[operation2];
5585
+ if (!config) throw new Error(`Unsupported read-only operation: ${operation2}.`);
4779
5586
  if (config.scope === "customer") {
4780
5587
  const cleanCustomerId = customerId?.replace(/-/g, "");
4781
5588
  if (!cleanCustomerId || !/^\d+$/.test(cleanCustomerId)) {
4782
- throw new Error(`${operation} requires a numeric customerId.`);
5589
+ throw new Error(`${operation2} requires a numeric customerId.`);
4783
5590
  }
4784
5591
  return { path: `customers/${cleanCustomerId}${config.path}`, config };
4785
5592
  }
@@ -4790,20 +5597,20 @@ function registerGoogleAdsReadOnlyRpcTool(server, client, ok3) {
4790
5597
  "google_ads_run_readonly_rpc",
4791
5598
  "Advanced read-only escape hatch for allowlisted Google Ads services outside GAQL: Audience Insights, Reach Planner, benchmarks, creator/trending insights, targeting suggestions, Smart Campaign suggestions, identity verification, invoices, and payments accounts. The operation is an enum; arbitrary paths and all mutations/uploads are impossible.",
4792
5599
  {
4793
- operation: z2.enum(READ_ONLY_OPERATION_NAMES),
4794
- customerId: z2.string().regex(/^\d[\d-]*\d$|^\d$/).optional().describe("Required for customer-scoped operations; omit for global planning catalogs"),
4795
- request: z2.record(z2.unknown()).optional().default({}).describe("Official REST JSON request body for POST operations, or query parameters for GET operations")
5600
+ operation: z4.enum(READ_ONLY_OPERATION_NAMES),
5601
+ customerId: z4.string().regex(/^\d[\d-]*\d$|^\d$/).optional().describe("Required for customer-scoped operations; omit for global planning catalogs"),
5602
+ request: z4.record(z4.unknown()).optional().default({}).describe("Official REST JSON request body for POST operations, or query parameters for GET operations")
4796
5603
  },
4797
- async ({ operation, customerId, request }) => {
5604
+ async ({ operation: operation2, customerId, request: request2 }) => {
4798
5605
  try {
4799
- validateGoogleAdsReadOnlyRequest(request);
4800
- const encodedLength = JSON.stringify(request).length;
5606
+ validateGoogleAdsReadOnlyRequest(request2);
5607
+ const encodedLength = JSON.stringify(request2).length;
4801
5608
  if (encodedLength > 1e5) throw new Error("Encoded request exceeds 100,000 characters.");
4802
- const { path, config } = buildGoogleAdsReadOnlyPath(operation, customerId);
5609
+ const { path, config } = buildGoogleAdsReadOnlyPath(operation2, customerId);
4803
5610
  let response;
4804
5611
  if (config.method === "GET") {
4805
5612
  const query = {};
4806
- for (const [key, value] of Object.entries(request)) {
5613
+ for (const [key, value] of Object.entries(request2)) {
4807
5614
  if (!["string", "number", "boolean"].includes(typeof value)) {
4808
5615
  throw new Error(`GET query parameter ${key} must be a string, number, or boolean.`);
4809
5616
  }
@@ -4811,17 +5618,17 @@ function registerGoogleAdsReadOnlyRpcTool(server, client, ok3) {
4811
5618
  }
4812
5619
  response = await client.runReadOnlyService(path, "GET", void 0, query);
4813
5620
  } else {
4814
- response = await client.runReadOnlyService(path, "POST", request);
5621
+ response = await client.runReadOnlyService(path, "POST", request2);
4815
5622
  }
4816
5623
  return ok3({
4817
5624
  dataKind: "google_ads_readonly_rpc",
4818
- operation,
5625
+ operation: operation2,
4819
5626
  purpose: config.purpose,
4820
5627
  scope: config.scope,
4821
5628
  method: config.method,
4822
5629
  response,
4823
5630
  readOnly: true,
4824
- warnings: operation === "generateShareablePreviews" ? ["The returned preview URL is shareable and can expose ad creative until it expires; disclose it only to intended recipients."] : [],
5631
+ warnings: operation2 === "generateShareablePreviews" ? ["The returned preview URL is shareable and can expose ad creative until it expires; disclose it only to intended recipients."] : [],
4825
5632
  limitations: [
4826
5633
  "The request body follows Google's native REST schema and is intentionally not translated into this server's metric aliases.",
4827
5634
  "Availability depends on developer-token access level, account eligibility, OAuth permissions, and operation-specific quotas."
@@ -4836,8 +5643,20 @@ function registerGoogleAdsReadOnlyRpcTool(server, client, ok3) {
4836
5643
  );
4837
5644
  }
4838
5645
 
5646
+ // src/core/redirects.ts
5647
+ function refuseRedirect(response, api) {
5648
+ if (response.status < 300 || response.status >= 400) return;
5649
+ throw new Error(`${api} returned an unexpected HTTP ${response.status} redirect. Redirects are never followed. Check the configured endpoint and authorization.`);
5650
+ }
5651
+
4839
5652
  // src/platforms/google-ads/client.ts
4840
5653
  var DEFAULT_GOOGLE_ADS_REQUEST_TIMEOUT_MS = 3e4;
5654
+ function withManagerHint(message, url, announcedManager) {
5655
+ if (announcedManager) return message;
5656
+ if (!/permission|PERMISSION_DENIED/i.test(message)) return message;
5657
+ const customer = url.match(/customers\/(\d+)/)?.[1];
5658
+ return `${message} This account may sit under a manager (MCC). Google refuses such requests unless the call names the manager in login-customer-id. Set GOOGLE_ADS_LOGIN_CUSTOMER_ID to the manager's ID` + (customer ? `, or check with google_ads_get_account_hierarchy which manager holds ${customer}` : "") + `.`;
5659
+ }
4841
5660
  function googleAdsRequestTimeoutMs() {
4842
5661
  const parsed = Number(process.env["GOOGLE_ADS_REQUEST_TIMEOUT_MS"]);
4843
5662
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GOOGLE_ADS_REQUEST_TIMEOUT_MS;
@@ -4850,6 +5669,14 @@ var GoogleAdsClient = class {
4850
5669
  loginCustomerId;
4851
5670
  accessToken = "";
4852
5671
  tokenExpiresAt = 0;
5672
+ /**
5673
+ * Le gestionnaire résolu pour un compte, ou `undefined` s'il n'en a pas.
5674
+ *
5675
+ * La distinction compte : `undefined` mémorisé veut dire « on a cherché et il
5676
+ * n'y en a pas », ce qui évite de refaire la découverte à chaque appel sur un
5677
+ * compte accessible en direct.
5678
+ */
5679
+ managerCache = /* @__PURE__ */ new Map();
4853
5680
  rateLimiter = new RateLimiter();
4854
5681
  keywordPlannerRateLimiter = new KeywordPlannerRateLimiter();
4855
5682
  constructor(config) {
@@ -4906,18 +5733,66 @@ var GoogleAdsClient = class {
4906
5733
  // ============================================
4907
5734
  // PRIVATE METHODS
4908
5735
  // ============================================
4909
- getHeaders() {
5736
+ getHeaders(loginCustomerId) {
4910
5737
  const headers = {
4911
5738
  Authorization: `Bearer ${this.accessToken}`,
4912
5739
  "developer-token": this.developerToken,
4913
5740
  "Content-Type": "application/json"
4914
5741
  };
4915
- if (this.loginCustomerId) {
4916
- headers["login-customer-id"] = this.loginCustomerId;
5742
+ const manager = loginCustomerId ?? this.loginCustomerId;
5743
+ if (manager) {
5744
+ headers["login-customer-id"] = stripCustomerId(manager);
4917
5745
  }
4918
5746
  return headers;
4919
5747
  }
4920
- async request(url, options = {}, beforeAttempt, deadlineAtMs) {
5748
+ /**
5749
+ * The manager account to announce when querying a given customer.
5750
+ *
5751
+ * Google rejects any request against an account managed by an MCC unless the
5752
+ * call names that MCC in `login-customer-id`. The rejection is a bare
5753
+ * `USER_PERMISSION_DENIED` that names neither the account nor the manager, so
5754
+ * it reads as missing access rather than as a missing header, and that is
5755
+ * where people lose an afternoon.
5756
+ *
5757
+ * `GOOGLE_ADS_LOGIN_CUSTOMER_ID` solves it when you know the answer in
5758
+ * advance. This resolves it when you do not: if the customer is not directly
5759
+ * accessible, the accessible managers are asked which accounts they hold, and
5760
+ * the one holding this customer is used.
5761
+ *
5762
+ * Resolved once and cached for the life of the client. The hierarchy of an
5763
+ * advertising account does not change between two tool calls, and paying two
5764
+ * extra requests on every query to discover that it has not would be worse
5765
+ * than the problem.
5766
+ */
5767
+ async resolveLoginCustomerId(customerId) {
5768
+ if (this.loginCustomerId) return this.loginCustomerId;
5769
+ const wanted = stripCustomerId(customerId);
5770
+ if (this.managerCache.has(wanted)) return this.managerCache.get(wanted);
5771
+ try {
5772
+ const accessible = (await this.listAccessibleCustomers()).map((name) => stripCustomerId(name.replace(/^customers\//, "")));
5773
+ if (accessible.includes(wanted)) {
5774
+ this.managerCache.set(wanted, void 0);
5775
+ return void 0;
5776
+ }
5777
+ for (const candidate of accessible) {
5778
+ const children = await this.getClientAccounts(candidate);
5779
+ for (const child of children) {
5780
+ if (stripCustomerId(child.id) === wanted) {
5781
+ this.managerCache.set(wanted, candidate);
5782
+ return candidate;
5783
+ }
5784
+ }
5785
+ }
5786
+ } catch (error) {
5787
+ logger.debug(
5788
+ "google-ads",
5789
+ `Could not resolve a manager for ${wanted}: ${error instanceof Error ? error.message : error}`
5790
+ );
5791
+ }
5792
+ this.managerCache.set(wanted, void 0);
5793
+ return void 0;
5794
+ }
5795
+ async request(url, options = {}, beforeAttempt, deadlineAtMs, loginCustomerId) {
4921
5796
  await this.ensureValidToken();
4922
5797
  return this.rateLimiter.execute(async () => {
4923
5798
  await beforeAttempt?.();
@@ -4942,10 +5817,11 @@ var GoogleAdsClient = class {
4942
5817
  // whatever host the redirect names.
4943
5818
  redirect: "error",
4944
5819
  headers: {
4945
- ...this.getHeaders(),
5820
+ ...this.getHeaders(loginCustomerId),
4946
5821
  ...options.headers
4947
5822
  }
4948
5823
  });
5824
+ refuseRedirect(response, "Google Ads API");
4949
5825
  if (!response.ok) {
4950
5826
  const errorBody = await response.json().catch(() => ({}));
4951
5827
  const errorDetails = errorBody?.error;
@@ -4954,7 +5830,7 @@ var GoogleAdsClient = class {
4954
5830
  const requestId = errorDetails?.details?.[0]?.requestId ?? errorBody?.[0]?.error?.details?.[0]?.requestId;
4955
5831
  const detailedMessage = gaErrors?.[0]?.message || errorDetails?.message || `Request failed: ${response.statusText}`;
4956
5832
  throw new GoogleAdsApiException(
4957
- detailedMessage,
5833
+ withManagerHint(detailedMessage, url, loginCustomerId),
4958
5834
  response.status,
4959
5835
  errorDetails?.status,
4960
5836
  requestId,
@@ -5015,12 +5891,16 @@ var GoogleAdsClient = class {
5015
5891
  async searchStream(customerId, gaqlQuery) {
5016
5892
  const cleanCustomerId = stripCustomerId(customerId);
5017
5893
  const url = `${GOOGLE_ADS_API_BASE_URL}/customers/${cleanCustomerId}/googleAds:searchStream`;
5894
+ const manager = await this.resolveLoginCustomerId(cleanCustomerId);
5018
5895
  const response = await this.request(
5019
5896
  url,
5020
5897
  {
5021
5898
  method: "POST",
5022
5899
  body: JSON.stringify({ query: gaqlQuery })
5023
- }
5900
+ },
5901
+ void 0,
5902
+ void 0,
5903
+ manager
5024
5904
  );
5025
5905
  const allRows = [];
5026
5906
  for (const batch of response) {
@@ -5037,12 +5917,19 @@ var GoogleAdsClient = class {
5037
5917
  async search(customerId, gaqlQuery, pageToken) {
5038
5918
  const cleanCustomerId = stripCustomerId(customerId);
5039
5919
  const url = `${GOOGLE_ADS_API_BASE_URL}/customers/${cleanCustomerId}/googleAds:search`;
5920
+ const manager = await this.resolveLoginCustomerId(cleanCustomerId);
5040
5921
  const body = { query: gaqlQuery };
5041
5922
  if (pageToken) body.pageToken = pageToken;
5042
- return this.request(url, {
5043
- method: "POST",
5044
- body: JSON.stringify(body)
5045
- });
5923
+ return this.request(
5924
+ url,
5925
+ {
5926
+ method: "POST",
5927
+ body: JSON.stringify(body)
5928
+ },
5929
+ void 0,
5930
+ void 0,
5931
+ manager
5932
+ );
5046
5933
  }
5047
5934
  // ============================================
5048
5935
  // KEYWORD PLANNER (PLANLESS, READ-ONLY RPCS)
@@ -5195,8 +6082,8 @@ var GoogleAdsClient = class {
5195
6082
  return {
5196
6083
  id: String(row.customer?.id || cleanCustomerId),
5197
6084
  descriptiveName: row.customer?.descriptiveName || "Unknown Account",
5198
- currencyCode: row.customer?.currencyCode || "USD",
5199
- timeZone: row.customer?.timeZone || "America/New_York",
6085
+ currencyCode: row.customer?.currencyCode || "",
6086
+ timeZone: row.customer?.timeZone || "",
5200
6087
  manager: row.customer?.manager || false,
5201
6088
  testAccount: row.customer?.testAccount || false,
5202
6089
  resourceName: row.customer?.resourceName || `customers/${cleanCustomerId}`
@@ -5264,9 +6151,9 @@ var GoogleAdsClient = class {
5264
6151
  /**
5265
6152
  * Execute a query request with automatic query planning
5266
6153
  */
5267
- async executeQuery(request) {
6154
+ async executeQuery(request2) {
5268
6155
  const startTime = Date.now();
5269
- const plan = planQuery(request);
6156
+ const plan = planQuery(request2);
5270
6157
  const errors = [...plan.errors];
5271
6158
  const warnings = [...plan.warnings];
5272
6159
  const gaqlQueries = [];
@@ -5294,7 +6181,7 @@ var GoogleAdsClient = class {
5294
6181
  gaqlQueries.push(query.gaql);
5295
6182
  rawRequests.push({ gaql: query.gaql, resource: query.resource });
5296
6183
  try {
5297
- const apiRows = (await this.search(request.customerId, query.gaql)).results ?? [];
6184
+ const apiRows = (await this.search(request2.customerId, query.gaql)).results ?? [];
5298
6185
  rawResponses.push(apiRows.slice(0, 5));
5299
6186
  const flatRows = apiRows.map((row) => flattenGoogleAdsRow(row));
5300
6187
  queryResults.push(flatRows);
@@ -5505,7 +6392,7 @@ function camelToSnakeResource(key) {
5505
6392
  }
5506
6393
 
5507
6394
  // src/platforms/google-ads/keyword-planner.ts
5508
- import { z as z3 } from "zod";
6395
+ import { z as z5 } from "zod";
5509
6396
  var FORECAST_WITH_BREAKDOWN_TIME_BUDGET_MS = 45e3;
5510
6397
  var MONTHS = [
5511
6398
  "JANUARY",
@@ -5524,17 +6411,17 @@ var MONTHS = [
5524
6411
  var MONTH_NUMBER = new Map(
5525
6412
  MONTHS.map((month, index) => [month, index + 1])
5526
6413
  );
5527
- var customerIdSchema = z3.string().regex(/^\d[\d-]*\d$|^\d$/, "Expected a numeric Google Ads customer ID, with or without dashes");
5528
- var numericIdSchema = z3.string().regex(/^\d+$/, "Expected a numeric Google Ads criterion ID");
5529
- var keywordTextSchema = z3.string().trim().min(1).max(80).refine(
6414
+ var customerIdSchema = z5.string().regex(/^\d[\d-]*\d$|^\d$/, "Expected a numeric Google Ads customer ID, with or without dashes");
6415
+ var numericIdSchema = z5.string().regex(/^\d+$/, "Expected a numeric Google Ads criterion ID");
6416
+ var keywordTextSchema = z5.string().trim().min(1).max(80).refine(
5530
6417
  (value) => value.split(/\s+/).length <= 10,
5531
6418
  "Google Ads keywords can contain at most 10 words"
5532
6419
  );
5533
- var yearMonthSchema = z3.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Expected YYYY-MM");
5534
- var isoDateSchema = z3.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
5535
- var networkSchema = z3.enum(["GOOGLE_SEARCH", "GOOGLE_SEARCH_AND_PARTNERS"]);
5536
- var matchTypeSchema = z3.enum(["EXACT", "PHRASE", "BROAD"]);
5537
- var currencyAmountSchema = z3.number().finite().min(1e-6).max(1e9);
6420
+ var yearMonthSchema = z5.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Expected YYYY-MM");
6421
+ var isoDateSchema = z5.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
6422
+ var networkSchema = z5.enum(["GOOGLE_SEARCH", "GOOGLE_SEARCH_AND_PARTNERS"]);
6423
+ var matchTypeSchema = z5.enum(["EXACT", "PHRASE", "BROAD"]);
6424
+ var currencyAmountSchema = z5.number().finite().min(1e-6).max(1e9);
5538
6425
  function compactObject(value) {
5539
6426
  return Object.fromEntries(
5540
6427
  Object.entries(value).filter(([, fieldValue]) => fieldValue !== void 0)
@@ -5806,22 +6693,22 @@ function normalizeKeywordHistoricalMetrics(metrics, includeMonthlySearchVolumes
5806
6693
  trends: computeKeywordPlannerTrends(metrics?.monthlySearchVolumes)
5807
6694
  };
5808
6695
  }
5809
- function normalizeHistoricalResult(result, includeMonthlySearchVolumes) {
6696
+ function normalizeHistoricalResult(result2, includeMonthlySearchVolumes) {
5810
6697
  return {
5811
- keyword: result.text ?? null,
5812
- closeVariants: result.closeVariants ?? [],
6698
+ keyword: result2.text ?? null,
6699
+ closeVariants: result2.closeVariants ?? [],
5813
6700
  metrics: normalizeKeywordHistoricalMetrics(
5814
- result.keywordMetrics,
6701
+ result2.keywordMetrics,
5815
6702
  includeMonthlySearchVolumes
5816
6703
  )
5817
6704
  };
5818
6705
  }
5819
- function normalizeIdeaResult(result) {
6706
+ function normalizeIdeaResult(result2) {
5820
6707
  return {
5821
- keyword: result.text ?? null,
5822
- closeVariants: result.closeVariants ?? [],
5823
- metrics: normalizeKeywordHistoricalMetrics(result.keywordIdeaMetrics),
5824
- concepts: result.keywordAnnotations?.concepts ?? []
6708
+ keyword: result2.text ?? null,
6709
+ closeVariants: result2.closeVariants ?? [],
6710
+ metrics: normalizeKeywordHistoricalMetrics(result2.keywordIdeaMetrics),
6711
+ concepts: result2.keywordAnnotations?.concepts ?? []
5825
6712
  };
5826
6713
  }
5827
6714
  function normalizeAggregateMetrics(results) {
@@ -5998,15 +6885,15 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
5998
6885
  "Get Keyword Planner search-volume history for supplied keywords. Returns average monthly searches, monthly volumes (up to 48 months), latest volume, computed 3-month and YoY changes, competition, CPC/bid ranges, close variants, and optional device totals. This is a read-only planless RPC.",
5999
6886
  {
6000
6887
  customerId: customerIdSchema.describe("Google Ads serving customer ID; use the client account, not its MCC manager"),
6001
- keywords: z3.array(keywordTextSchema).min(1).max(1e4).describe("Keywords to analyze; Google may combine near-exact close variants"),
6002
- geoTargetIds: z3.array(numericIdSchema).max(10).optional().default([]).describe("Geo target criterion IDs, e.g. 2250 for France; empty means all geographies"),
6888
+ keywords: z5.array(keywordTextSchema).min(1).max(1e4).describe("Keywords to analyze; Google may combine near-exact close variants"),
6889
+ geoTargetIds: z5.array(numericIdSchema).max(10).optional().default([]).describe("Geo target criterion IDs, e.g. 2250 for France; empty means all geographies"),
6003
6890
  languageId: numericIdSchema.optional().describe("Optional language criterion ID, e.g. 1002 for French; omit for all languages"),
6004
6891
  network: networkSchema.optional().default("GOOGLE_SEARCH"),
6005
- includeAdultKeywords: z3.boolean().optional().default(false),
6006
- includeAverageCpc: z3.boolean().optional().default(true).describe("Request legacy average CPC in addition to top-of-page bid ranges"),
6007
- includeDeviceBreakdown: z3.boolean().optional().default(false).describe("Return aggregate searches by device across all requested keywords"),
6008
- includeMonthlySearchVolumes: z3.boolean().optional().default(true).describe("Include every monthly point in the MCP output. Set false to trim an allowed response; Google's upstream series and the 50,000-point guard are unchanged"),
6009
- historyMonths: z3.number().int().min(3).max(48).optional().default(24).describe("History length when no explicit YYYY-MM range is supplied; 24 enables YoY calculation"),
6892
+ includeAdultKeywords: z5.boolean().optional().default(false),
6893
+ includeAverageCpc: z5.boolean().optional().default(true).describe("Request legacy average CPC in addition to top-of-page bid ranges"),
6894
+ includeDeviceBreakdown: z5.boolean().optional().default(false).describe("Return aggregate searches by device across all requested keywords"),
6895
+ includeMonthlySearchVolumes: z5.boolean().optional().default(true).describe("Include every monthly point in the MCP output. Set false to trim an allowed response; Google's upstream series and the 50,000-point guard are unchanged"),
6896
+ historyMonths: z5.number().int().min(3).max(48).optional().default(24).describe("History length when no explicit YYYY-MM range is supplied; 24 enables YoY calculation"),
6010
6897
  startYearMonth: yearMonthSchema.optional().describe("Optional inclusive historical range start YYYY-MM"),
6011
6898
  endYearMonth: yearMonthSchema.optional().describe("Optional inclusive historical range end YYYY-MM")
6012
6899
  },
@@ -6026,7 +6913,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6026
6913
  }) => {
6027
6914
  try {
6028
6915
  const startedAt = Date.now();
6029
- const { request, historyRange } = buildKeywordHistoricalMetricsRequest({
6916
+ const { request: request2, historyRange } = buildKeywordHistoricalMetricsRequest({
6030
6917
  keywords,
6031
6918
  geoTargetIds,
6032
6919
  languageId,
@@ -6042,9 +6929,9 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6042
6929
  keywords.length,
6043
6930
  historyRange.monthCount
6044
6931
  );
6045
- const response = await client.generateKeywordHistoricalMetrics(customerId, request);
6046
- const results = (response.results ?? []).map((result) => normalizeHistoricalResult(
6047
- result,
6932
+ const response = await client.generateKeywordHistoricalMetrics(customerId, request2);
6933
+ const results = (response.results ?? []).map((result2) => normalizeHistoricalResult(
6934
+ result2,
6048
6935
  includeMonthlySearchVolumes
6049
6936
  ));
6050
6937
  const warnings = [];
@@ -6098,21 +6985,21 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6098
6985
  "Discover Keyword Planner ideas from up to 20 seed keywords, a URL, keywords plus URL, or a whole site. Returns historical volume, monthly trends, computed 3-month/YoY changes, competition, bids, close variants, optional concepts, and pagination. Read-only; no plan is saved.",
6099
6986
  {
6100
6987
  customerId: customerIdSchema.describe("Google Ads serving customer ID; use the client account, not its MCC manager"),
6101
- seedKeywords: z3.array(keywordTextSchema).min(1).max(20).optional(),
6102
- url: z3.string().trim().min(1).optional().describe("Specific page URL to crawl; combine with seedKeywords if desired"),
6103
- site: z3.string().trim().min(1).optional().describe("Whole-domain seed; exclusive with seedKeywords/url"),
6104
- geoTargetIds: z3.array(numericIdSchema).max(10).optional().default([]),
6988
+ seedKeywords: z5.array(keywordTextSchema).min(1).max(20).optional(),
6989
+ url: z5.string().trim().min(1).optional().describe("Specific page URL to crawl; combine with seedKeywords if desired"),
6990
+ site: z5.string().trim().min(1).optional().describe("Whole-domain seed; exclusive with seedKeywords/url"),
6991
+ geoTargetIds: z5.array(numericIdSchema).max(10).optional().default([]),
6105
6992
  languageId: numericIdSchema.optional(),
6106
6993
  network: networkSchema.optional().default("GOOGLE_SEARCH"),
6107
- includeAdultKeywords: z3.boolean().optional().default(false),
6108
- includeAverageCpc: z3.boolean().optional().default(true),
6109
- includeDeviceBreakdown: z3.boolean().optional().default(false),
6110
- includeKeywordConcepts: z3.boolean().optional().default(true),
6111
- historyMonths: z3.number().int().min(3).max(48).optional().default(13).describe("13 months is enough to compute latest-month YoY while limiting response size"),
6994
+ includeAdultKeywords: z5.boolean().optional().default(false),
6995
+ includeAverageCpc: z5.boolean().optional().default(true),
6996
+ includeDeviceBreakdown: z5.boolean().optional().default(false),
6997
+ includeKeywordConcepts: z5.boolean().optional().default(true),
6998
+ historyMonths: z5.number().int().min(3).max(48).optional().default(13).describe("13 months is enough to compute latest-month YoY while limiting response size"),
6112
6999
  startYearMonth: yearMonthSchema.optional(),
6113
7000
  endYearMonth: yearMonthSchema.optional(),
6114
- pageSize: z3.number().int().min(1).max(1e4).optional().default(100).describe("Google supports up to 10,000; keep pages small for interactive MCP use and use larger pages only for controlled exports"),
6115
- pageToken: z3.string().optional().describe("nextPageToken from a prior identical request")
7001
+ pageSize: z5.number().int().min(1).max(1e4).optional().default(100).describe("Google supports up to 10,000; keep pages small for interactive MCP use and use larger pages only for controlled exports"),
7002
+ pageToken: z5.string().optional().describe("nextPageToken from a prior identical request")
6116
7003
  },
6117
7004
  async ({
6118
7005
  customerId,
@@ -6134,7 +7021,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6134
7021
  }) => {
6135
7022
  try {
6136
7023
  const startedAt = Date.now();
6137
- const { request, historyRange, seedType } = buildKeywordIdeasRequest({
7024
+ const { request: request2, historyRange, seedType } = buildKeywordIdeasRequest({
6138
7025
  seedKeywords,
6139
7026
  url,
6140
7027
  site,
@@ -6155,7 +7042,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6155
7042
  pageSize,
6156
7043
  historyRange.monthCount
6157
7044
  );
6158
- const response = await client.generateKeywordIdeas(customerId, request);
7045
+ const response = await client.generateKeywordIdeas(customerId, request2);
6159
7046
  const ideas = (response.results ?? []).map(normalizeIdeaResult);
6160
7047
  const nextPageToken = response.nextPageToken ?? null;
6161
7048
  return ok3({
@@ -6204,23 +7091,23 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6204
7091
  "Forecast impressions, clicks, CTR, CPC, cost, conversions, and CPA for a temporary keyword campaign. Supports targeting, negatives, match type, three bidding strategies, explicit future dates, and an optional independent per-keyword breakdown. This read-only planless RPC does not create a campaign or saved plan.",
6205
7092
  {
6206
7093
  customerId: customerIdSchema.describe("Google Ads serving customer ID; use a relevant client account for better estimates"),
6207
- keywords: z3.array(keywordTextSchema).min(1).max(1e3),
7094
+ keywords: z5.array(keywordTextSchema).min(1).max(1e3),
6208
7095
  matchType: matchTypeSchema.optional().default("BROAD"),
6209
- negativeKeywords: z3.array(keywordTextSchema).max(1e3).optional().default([]),
7096
+ negativeKeywords: z5.array(keywordTextSchema).max(1e3).optional().default([]),
6210
7097
  negativeMatchType: matchTypeSchema.optional().default("BROAD"),
6211
- geoTargetIds: z3.array(numericIdSchema).max(20).optional().default([]),
6212
- languageIds: z3.array(numericIdSchema).max(10).optional().default([]),
7098
+ geoTargetIds: z5.array(numericIdSchema).max(20).optional().default([]),
7099
+ languageIds: z5.array(numericIdSchema).max(10).optional().default([]),
6213
7100
  network: networkSchema.optional().default("GOOGLE_SEARCH"),
6214
- biddingStrategy: z3.enum(["MANUAL_CPC", "MAXIMIZE_CLICKS", "MAXIMIZE_CONVERSIONS"]).optional().default("MANUAL_CPC"),
7101
+ biddingStrategy: z5.enum(["MANUAL_CPC", "MAXIMIZE_CLICKS", "MAXIMIZE_CONVERSIONS"]).optional().default("MANUAL_CPC"),
6215
7102
  maxCpcBid: currencyAmountSchema.optional().describe("Bid in standard account-currency units; required for MANUAL_CPC"),
6216
7103
  dailyBudget: currencyAmountSchema.optional().describe("Daily amount in standard currency units; required for maximize strategies and optional for MANUAL_CPC"),
6217
7104
  maxCpcBidCeiling: currencyAmountSchema.optional().describe("Optional standard-currency CPC ceiling for MAXIMIZE_CLICKS"),
6218
- conversionRate: z3.number().finite().min(0).max(1).optional().describe("Expected conversion rate as a decimal, e.g. 0.02 for 2%"),
6219
- currencyCode: z3.string().regex(/^[A-Z]{3}$/).optional().describe("Optional ISO 4217 conversion currency; account currency is used by default"),
7105
+ conversionRate: z5.number().finite().min(0).max(1).optional().describe("Expected conversion rate as a decimal, e.g. 0.02 for 2%"),
7106
+ currencyCode: z5.string().regex(/^[A-Z]{3}$/).optional().describe("Optional ISO 4217 conversion currency; account currency is used by default"),
6220
7107
  startDate: isoDateSchema.optional().describe("Optional inclusive future forecast start YYYY-MM-DD; supply with endDate"),
6221
7108
  endDate: isoDateSchema.optional().describe("Optional inclusive forecast end YYYY-MM-DD, no more than one year ahead; supply with startDate"),
6222
- includeKeywordBreakdown: z3.boolean().optional().default(false).describe("Make independent one-keyword forecast calls in addition to the combined campaign forecast"),
6223
- keywordBreakdownLimit: z3.number().int().min(1).max(20).optional().default(10).describe("Safety cap for additional rate-limited Keyword Planner requests")
7109
+ includeKeywordBreakdown: z5.boolean().optional().default(false).describe("Make independent one-keyword forecast calls in addition to the combined campaign forecast"),
7110
+ keywordBreakdownLimit: z5.number().int().min(1).max(20).optional().default(10).describe("Safety cap for additional rate-limited Keyword Planner requests")
6224
7111
  },
6225
7112
  async ({
6226
7113
  customerId,
@@ -6261,11 +7148,11 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6261
7148
  startDate,
6262
7149
  endDate
6263
7150
  };
6264
- const { request, periodDays } = buildKeywordForecastRequest(baseInput);
7151
+ const { request: request2, periodDays } = buildKeywordForecastRequest(baseInput);
6265
7152
  const deadlineAtMs = includeKeywordBreakdown ? startedAt + FORECAST_WITH_BREAKDOWN_TIME_BUDGET_MS : void 0;
6266
7153
  const response = await client.generateKeywordForecastMetrics(
6267
7154
  customerId,
6268
- request,
7155
+ request2,
6269
7156
  deadlineAtMs
6270
7157
  );
6271
7158
  const campaignForecast = normalizeKeywordForecastMetrics(
@@ -6355,7 +7242,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6355
7242
  }
6356
7243
 
6357
7244
  // src/platforms/google-ads/discovery-tools.ts
6358
- import { z as z4 } from "zod";
7245
+ import { z as z6 } from "zod";
6359
7246
  var FIELD_COLUMNS = [
6360
7247
  "name",
6361
7248
  "category",
@@ -6371,12 +7258,12 @@ var FIELD_COLUMNS = [
6371
7258
  "metrics",
6372
7259
  "segments"
6373
7260
  ].join(", ");
6374
- var customerIdSchema2 = z4.string().regex(
7261
+ var customerIdSchema2 = z6.string().regex(
6375
7262
  /^\d[\d-]*\d$|^\d$/,
6376
7263
  "Expected a numeric Google Ads customer ID, with or without dashes"
6377
7264
  );
6378
- var numericIdSchema2 = z4.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
6379
- var fieldCategorySchema = z4.enum([
7265
+ var numericIdSchema2 = z6.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
7266
+ var fieldCategorySchema = z6.enum([
6380
7267
  "RESOURCE",
6381
7268
  "ATTRIBUTE",
6382
7269
  "SEGMENT",
@@ -6431,14 +7318,14 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6431
7318
  "google_ads_search_fields",
6432
7319
  "Search Google's live GoogleAdsField catalog. Discovers every queryable GAQL resource, attribute, segment, metric, enum value, and selectable-with compatibility relationship. Read-only and useful before a raw GAQL query.",
6433
7320
  {
6434
- query: z4.string().trim().min(6).max(5e4).optional().describe("Optional raw GoogleAdsField SELECT query. When provided it replaces the structured filters."),
6435
- nameContains: z4.string().trim().min(1).max(250).optional().describe("Case-sensitive field-name substring, e.g. conversion or asset_group"),
7321
+ query: z6.string().trim().min(6).max(5e4).optional().describe("Optional raw GoogleAdsField SELECT query. When provided it replaces the structured filters."),
7322
+ nameContains: z6.string().trim().min(1).max(250).optional().describe("Case-sensitive field-name substring, e.g. conversion or asset_group"),
6436
7323
  category: fieldCategorySchema.optional(),
6437
- selectable: z4.boolean().optional(),
6438
- filterable: z4.boolean().optional(),
6439
- sortable: z4.boolean().optional(),
6440
- pageSize: z4.number().int().min(1).max(1e4).optional().default(500),
6441
- pageToken: z4.string().trim().min(1).max(1e4).optional()
7324
+ selectable: z6.boolean().optional(),
7325
+ filterable: z6.boolean().optional(),
7326
+ sortable: z6.boolean().optional(),
7327
+ pageSize: z6.number().int().min(1).max(1e4).optional().default(500),
7328
+ pageToken: z6.string().trim().min(1).max(1e4).optional()
6442
7329
  },
6443
7330
  async ({ query, nameContains, category, selectable, filterable, sortable, pageSize, pageToken }) => {
6444
7331
  try {
@@ -6481,22 +7368,22 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6481
7368
  "google_ads_suggest_geo_targets",
6482
7369
  "Resolve up to 25 location names or geo target IDs to Google Ads geoTargetConstants. Returns criterion IDs, canonical names, target types, status, parents, locale, and approximate reach. Read-only.",
6483
7370
  {
6484
- locationNames: z4.array(z4.string().trim().min(1).max(250)).min(1).max(25).optional(),
6485
- geoTargetIds: z4.array(numericIdSchema2).min(1).max(25).optional(),
6486
- locale: z4.string().trim().regex(/^[A-Za-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/, "Expected a locale such as en, fr, or pt-BR").optional().default("en"),
6487
- countryCode: z4.string().trim().regex(/^[A-Za-z]{2}$/, "Expected an ISO-3166 alpha-2 country code").optional().transform((value) => value?.toUpperCase())
7371
+ locationNames: z6.array(z6.string().trim().min(1).max(250)).min(1).max(25).optional(),
7372
+ geoTargetIds: z6.array(numericIdSchema2).min(1).max(25).optional(),
7373
+ locale: z6.string().trim().regex(/^[A-Za-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/, "Expected a locale such as en, fr, or pt-BR").optional().default("en"),
7374
+ countryCode: z6.string().trim().regex(/^[A-Za-z]{2}$/, "Expected an ISO-3166 alpha-2 country code").optional().transform((value) => value?.toUpperCase())
6488
7375
  },
6489
7376
  async ({ locationNames, geoTargetIds, locale, countryCode }) => {
6490
7377
  try {
6491
7378
  if (Boolean(locationNames?.length) === Boolean(geoTargetIds?.length)) {
6492
7379
  throw new Error("Provide exactly one of locationNames or geoTargetIds.");
6493
7380
  }
6494
- const request = {
7381
+ const request2 = {
6495
7382
  locale,
6496
7383
  ...countryCode ? { countryCode } : {},
6497
7384
  ...locationNames ? { locationNames: { names: [...new Set(locationNames)] } } : { geoTargets: { geoTargetConstants: [...new Set(geoTargetIds)].map((id) => `geoTargetConstants/${id}`) } }
6498
7385
  };
6499
- const response = await client.suggestGeoTargetConstants(request);
7386
+ const response = await client.suggestGeoTargetConstants(request2);
6500
7387
  const suggestions = (response.geoTargetConstantSuggestions ?? []).map((suggestion) => ({
6501
7388
  searchTerm: suggestion.searchTerm ?? null,
6502
7389
  locale: suggestion.locale ?? locale,
@@ -6510,7 +7397,7 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6510
7397
  dataKind: "geo_target_suggestions",
6511
7398
  suggestions,
6512
7399
  count: suggestions.length,
6513
- request,
7400
+ request: request2,
6514
7401
  readOnly: true,
6515
7402
  warnings: [],
6516
7403
  limitations: ["Reach is approximate and rounded by Google."],
@@ -6526,19 +7413,19 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6526
7413
  "Organize supplied keywords into existing Google Ads ad groups. Returns suggested ad group/campaign pairings, normalized keyword text, and suggested match type without creating or editing keywords. Read-only Keyword Planner RPC.",
6527
7414
  {
6528
7415
  customerId: customerIdSchema2.describe("Serving customer ID containing the existing ad groups"),
6529
- keywords: z4.array(z4.string().trim().min(1).max(80)).min(1).max(1e3),
6530
- adGroupIds: z4.array(numericIdSchema2).min(1).max(200).describe("Existing ad group IDs in the same customer account")
7416
+ keywords: z6.array(z6.string().trim().min(1).max(80)).min(1).max(1e3),
7417
+ adGroupIds: z6.array(numericIdSchema2).min(1).max(200).describe("Existing ad group IDs in the same customer account")
6531
7418
  },
6532
7419
  async ({ customerId, keywords, adGroupIds }) => {
6533
7420
  try {
6534
7421
  const cleanCustomerId = customerId.replace(/-/g, "");
6535
- const request = {
7422
+ const request2 = {
6536
7423
  keywords: [...new Set(keywords)],
6537
7424
  adGroups: [...new Set(adGroupIds)].map(
6538
7425
  (id) => `customers/${cleanCustomerId}/adGroups/${id}`
6539
7426
  )
6540
7427
  };
6541
- const response = await client.generateAdGroupThemes(cleanCustomerId, request);
7428
+ const response = await client.generateAdGroupThemes(cleanCustomerId, request2);
6542
7429
  const suggestions = response.adGroupKeywordSuggestions ?? [];
6543
7430
  const unusableAdGroups = response.unusableAdGroups ?? [];
6544
7431
  return ok3({
@@ -6547,8 +7434,8 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6547
7434
  suggestionCount: suggestions.length,
6548
7435
  unusableAdGroups,
6549
7436
  unusableAdGroupCount: unusableAdGroups.length,
6550
- requestedKeywordCount: request.keywords.length,
6551
- requestedAdGroupCount: request.adGroups.length,
7437
+ requestedKeywordCount: request2.keywords.length,
7438
+ requestedAdGroupCount: request2.adGroups.length,
6552
7439
  readOnly: true,
6553
7440
  warnings: unusableAdGroups.length ? ["Google could not use one or more supplied ad groups; inspect unusableAdGroups for their campaign context."] : [],
6554
7441
  limitations: ["This RPC suggests organization into existing ad groups; it does not create themes, ad groups, or keywords."],
@@ -6562,11 +7449,11 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6562
7449
  }
6563
7450
 
6564
7451
  // src/platforms/google-ads/tools.ts
6565
- var customerIdSchema3 = z5.string().describe("Google Ads customer ID (without dashes, e.g., 1234567890)");
6566
- var loginCustomerIdSchema = z5.string().optional().describe("MCC Manager account ID (required for sub-accounts managed by an MCC)");
6567
- var isoDateSchema2 = z5.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected date format YYYY-MM-DD");
6568
- var numericIdSchema3 = z5.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
6569
- var gaqlEnumSchema = z5.string().regex(/^[A-Z0-9_]+$/, "Expected an uppercase Google Ads enum value");
7452
+ var customerIdSchema3 = z7.string().describe("Google Ads customer ID (without dashes, e.g., 1234567890)");
7453
+ var loginCustomerIdSchema = z7.string().optional().describe("MCC Manager account ID (required for sub-accounts managed by an MCC)");
7454
+ var isoDateSchema2 = z7.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected date format YYYY-MM-DD");
7455
+ var numericIdSchema3 = z7.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
7456
+ var gaqlEnumSchema = z7.string().regex(/^[A-Z0-9_]+$/, "Expected an uppercase Google Ads enum value");
6570
7457
  var ZERO_METRIC_LIMIT_WARNING = "All returned rows have zero metric values and rowCount equals the requested limit. The response may be truncated before active entities; retry with a higher limit or pass orderBy on a non-zero metric.";
6571
7458
  function isAgentResponseRecord(value) {
6572
7459
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -6729,6 +7616,35 @@ function normalizeChangeEventRange(startDate, endDate) {
6729
7616
  warnings
6730
7617
  };
6731
7618
  }
7619
+ function recordOf(value) {
7620
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
7621
+ }
7622
+ function assetIdFromResourceName(value) {
7623
+ if (typeof value !== "string") return void 0;
7624
+ return value.match(/\/assets\/(\d+)$/)?.[1];
7625
+ }
7626
+ function youtubeDerivedUrls(videoId) {
7627
+ if (typeof videoId !== "string" || !/^[A-Za-z0-9_-]{5,20}$/.test(videoId)) return void 0;
7628
+ return {
7629
+ watch_url: `https://www.youtube.com/watch?v=${videoId}`,
7630
+ embed_url: `https://www.youtube.com/embed/${videoId}`,
7631
+ thumbnail_url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
7632
+ thumbnail_fallback_url: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
7633
+ };
7634
+ }
7635
+ function collectAssetIds(value, into) {
7636
+ const id = assetIdFromResourceName(value);
7637
+ if (id) {
7638
+ into.add(id);
7639
+ return;
7640
+ }
7641
+ if (Array.isArray(value)) {
7642
+ for (const item of value) collectAssetIds(item, into);
7643
+ return;
7644
+ }
7645
+ const record = recordOf(value);
7646
+ if (record) for (const item of Object.values(record)) collectAssetIds(item, into);
7647
+ }
6732
7648
  async function runGaqlWithFallback(client, customerId, attempts) {
6733
7649
  const warnings = [];
6734
7650
  let lastError;
@@ -6816,7 +7732,7 @@ function registerGoogleAdsTools(server, config) {
6816
7732
  Example: SELECT campaign.name, metrics.impressions FROM campaign WHERE campaign.status = 'ENABLED' AND segments.date DURING LAST_30_DAYS ORDER BY metrics.impressions DESC LIMIT 100`,
6817
7733
  {
6818
7734
  customerId: customerIdSchema3,
6819
- query: z5.string().min(10).describe("GAQL query string (SELECT ... FROM ... WHERE ...)")
7735
+ query: z7.string().min(10).describe("GAQL query string (SELECT ... FROM ... WHERE ...)")
6820
7736
  },
6821
7737
  async ({ customerId, query }) => {
6822
7738
  try {
@@ -6833,12 +7749,12 @@ Example: SELECT campaign.name, metrics.impressions FROM campaign WHERE campaign.
6833
7749
  Use google-ads://metrics for available metrics, google-ads://dimensions for dimensions.`,
6834
7750
  {
6835
7751
  customerId: customerIdSchema3,
6836
- resource: z5.string().optional().default("campaign").describe("GAQL FROM clause resource type (campaign, ad_group, ad_group_ad, keyword_view, shopping_performance_view, asset_group, geographic_view, video, search_term_view, landing_page_view, etc.)"),
6837
- metrics: z5.array(z5.string()).min(1).describe("Metric keys (e.g., impressions, clicks, cost_micros, conversions)"),
6838
- dimensions: z5.array(z5.string()).optional().describe("Dimension keys (e.g., date, campaignName, device)"),
6839
- startDate: z5.string().optional().describe("Start date YYYY-MM-DD"),
6840
- endDate: z5.string().optional().describe("End date YYYY-MM-DD"),
6841
- datePreset: z5.enum([
7752
+ resource: z7.string().optional().default("campaign").describe("GAQL FROM clause resource type (campaign, ad_group, ad_group_ad, keyword_view, shopping_performance_view, asset_group, geographic_view, video, search_term_view, landing_page_view, etc.)"),
7753
+ metrics: z7.array(z7.string()).min(1).describe("Metric keys (e.g., impressions, clicks, cost_micros, conversions)"),
7754
+ dimensions: z7.array(z7.string()).optional().describe("Dimension keys (e.g., date, campaignName, device)"),
7755
+ startDate: z7.string().optional().describe("Start date YYYY-MM-DD"),
7756
+ endDate: z7.string().optional().describe("End date YYYY-MM-DD"),
7757
+ datePreset: z7.enum([
6842
7758
  "TODAY",
6843
7759
  "YESTERDAY",
6844
7760
  "LAST_7_DAYS",
@@ -6850,9 +7766,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6850
7766
  "THIS_QUARTER",
6851
7767
  "LAST_QUARTER"
6852
7768
  ]).optional().describe("Predefined date range"),
6853
- orderBy: z5.string().optional().describe("Optional GAQL field to order by, e.g. metrics.impressions or campaign.name"),
6854
- orderDirection: z5.enum(["ASC", "DESC"]).optional().default("DESC"),
6855
- limit: z5.number().int().min(1).max(1e4).optional().default(500)
7769
+ orderBy: z7.string().optional().describe("Optional GAQL field to order by, e.g. metrics.impressions or campaign.name"),
7770
+ orderDirection: z7.enum(["ASC", "DESC"]).optional().default("DESC"),
7771
+ limit: z7.number().int().min(1).max(1e4).optional().default(500)
6856
7772
  },
6857
7773
  async ({ customerId, resource, metrics, dimensions, startDate, endDate, datePreset, orderBy, orderDirection, limit }) => {
6858
7774
  try {
@@ -6879,7 +7795,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6879
7795
  gaqlPreview: generateQueryPreview(plan)
6880
7796
  });
6881
7797
  }
6882
- const result = await client.executeQuery({
7798
+ const result2 = await client.executeQuery({
6883
7799
  customerId,
6884
7800
  resource,
6885
7801
  metrics,
@@ -6892,18 +7808,18 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6892
7808
  orderDirection,
6893
7809
  limit
6894
7810
  });
6895
- const debugWarnings = Array.isArray(result.debug.warnings) ? result.debug.warnings : [];
6896
- const debugErrors = Array.isArray(result.debug.errors) ? result.debug.errors : [];
6897
- const zeroMetricLimitWarnings = result.data.length === limit && allReturnedMetricValuesAreZero(result.data, metrics) ? [ZERO_METRIC_LIMIT_WARNING] : [];
7811
+ const debugWarnings = Array.isArray(result2.debug.warnings) ? result2.debug.warnings : [];
7812
+ const debugErrors = Array.isArray(result2.debug.errors) ? result2.debug.errors : [];
7813
+ const zeroMetricLimitWarnings = result2.data.length === limit && allReturnedMetricValuesAreZero(result2.data, metrics) ? [ZERO_METRIC_LIMIT_WARNING] : [];
6898
7814
  const warnings = [...dateInput.warnings, ...debugWarnings, ...zeroMetricLimitWarnings];
6899
7815
  return ok({
6900
7816
  status: debugErrors.length > 0 ? "error" : "ok",
6901
- data: result.data,
6902
- rowCount: result.data.length,
7817
+ data: result2.data,
7818
+ rowCount: result2.data.length,
6903
7819
  errors: debugErrors,
6904
7820
  warnings,
6905
7821
  debug: {
6906
- ...result.debug,
7822
+ ...result2.debug,
6907
7823
  warnings,
6908
7824
  executionTimeMs: Date.now() - startTime,
6909
7825
  gaqlPreview: generateQueryPreview(plan)
@@ -6919,8 +7835,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6919
7835
  "List campaigns for a Google Ads account with status, budget, channel type, and bidding strategy.",
6920
7836
  {
6921
7837
  customerId: customerIdSchema3,
6922
- statusFilter: z5.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
6923
- limit: z5.number().int().min(1).max(1e3).optional().default(100)
7838
+ statusFilter: z7.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
7839
+ limit: z7.number().int().min(1).max(1e3).optional().default(100)
6924
7840
  },
6925
7841
  async ({ customerId, statusFilter, limit }) => {
6926
7842
  try {
@@ -6939,9 +7855,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6939
7855
  "List ad groups for a Google Ads account, optionally filtered by campaign.",
6940
7856
  {
6941
7857
  customerId: customerIdSchema3,
6942
- campaignId: z5.string().optional().describe("Filter by campaign ID"),
6943
- statusFilter: z5.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
6944
- limit: z5.number().int().min(1).max(1e3).optional().default(100)
7858
+ campaignId: z7.string().optional().describe("Filter by campaign ID"),
7859
+ statusFilter: z7.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
7860
+ limit: z7.number().int().min(1).max(1e3).optional().default(100)
6945
7861
  },
6946
7862
  async ({ customerId, campaignId, statusFilter, limit }) => {
6947
7863
  try {
@@ -6963,9 +7879,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6963
7879
  "Get keyword-level performance data from keyword_view resource. Shows quality score, impressions, clicks, cost.",
6964
7880
  {
6965
7881
  customerId: customerIdSchema3,
6966
- startDate: z5.string().describe("Start date YYYY-MM-DD"),
6967
- endDate: z5.string().describe("End date YYYY-MM-DD"),
6968
- limit: z5.number().int().min(1).max(1e3).optional().default(100)
7882
+ startDate: z7.string().describe("Start date YYYY-MM-DD"),
7883
+ endDate: z7.string().describe("End date YYYY-MM-DD"),
7884
+ limit: z7.number().int().min(1).max(1e3).optional().default(100)
6969
7885
  },
6970
7886
  async ({ customerId, startDate, endDate, limit }) => {
6971
7887
  try {
@@ -6981,14 +7897,14 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6981
7897
  "google_ads_validate_query",
6982
7898
  "Validate metric/dimension/resource compatibility BEFORE executing a query. Checks segment restrictions and resource availability.",
6983
7899
  {
6984
- metrics: z5.array(z5.string()).min(1).describe("Metric keys to validate"),
6985
- dimensions: z5.array(z5.string()).optional().describe("Dimension keys to validate"),
6986
- resource: z5.string().optional().default("campaign")
7900
+ metrics: z7.array(z7.string()).min(1).describe("Metric keys to validate"),
7901
+ dimensions: z7.array(z7.string()).optional().describe("Dimension keys to validate"),
7902
+ resource: z7.string().optional().default("campaign")
6987
7903
  },
6988
7904
  async ({ metrics, dimensions, resource }) => {
6989
7905
  try {
6990
- const result = validateQuerySelection(metrics, dimensions ?? [], resource, dimensions ?? []);
6991
- return ok(result);
7906
+ const result2 = validateQuerySelection(metrics, dimensions ?? [], resource, dimensions ?? []);
7907
+ return ok(result2);
6992
7908
  } catch (e) {
6993
7909
  return formatMcpToolError(e);
6994
7910
  }
@@ -7018,7 +7934,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7018
7934
  warnings.push("GOOGLE_ADS_LOGIN_CUSTOMER_ID is configured but was not returned by listAccessibleCustomers; verify MCC access if child account queries fail.");
7019
7935
  }
7020
7936
  if (!loginCustomerId && accessibleCustomers.some((customer) => customer.manager)) {
7021
- warnings.push("Manager accounts are accessible but GOOGLE_ADS_LOGIN_CUSTOMER_ID is not set. Set it to the MCC ID when querying managed child accounts.");
7937
+ warnings.push(
7938
+ "Manager accounts are accessible and GOOGLE_ADS_LOGIN_CUSTOMER_ID is not set. The server resolves the manager of each account it queries, so this is not a fault on its own. Setting the variable skips that discovery and is worth it when every account sits under the same MCC."
7939
+ );
7022
7940
  }
7023
7941
  return ok({
7024
7942
  status: "ok",
@@ -7049,7 +7967,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7049
7967
  "List accessible customers and, where possible, manager/client relationships from GAQL customer_client. Falls back to accessible customers if hierarchy queries are unavailable.",
7050
7968
  {
7051
7969
  managerCustomerId: loginCustomerIdSchema,
7052
- includeInactive: z5.boolean().optional().default(false).describe("Include non-ENABLED customer_client links")
7970
+ includeInactive: z7.boolean().optional().default(false).describe("Include non-ENABLED customer_client links")
7053
7971
  },
7054
7972
  async ({ managerCustomerId, includeInactive }) => {
7055
7973
  try {
@@ -7124,8 +8042,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7124
8042
  "List conversion actions with status, type, category, primary/include-in-conversions flags, owner customer, and last activity dates when supported.",
7125
8043
  {
7126
8044
  customerId: customerIdSchema3,
7127
- statusFilter: z5.enum(["ENABLED", "HIDDEN", "REMOVED"]).optional(),
7128
- limit: z5.number().int().min(1).max(1e3).optional().default(1e3)
8045
+ statusFilter: z7.enum(["ENABLED", "HIDDEN", "REMOVED"]).optional(),
8046
+ limit: z7.number().int().min(1).max(1e3).optional().default(1e3)
7129
8047
  },
7130
8048
  async ({ customerId, statusFilter, limit }) => {
7131
8049
  try {
@@ -7164,11 +8082,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7164
8082
  ORDER BY conversion_action.name ASC
7165
8083
  LIMIT ${limit}
7166
8084
  `);
7167
- const result = await runGaqlWithFallback(client, customerId, [
8085
+ const result2 = await runGaqlWithFallback(client, customerId, [
7168
8086
  { label: "conversion_action_with_last_activity", gaql: richGaql, failureWarning: "Conversion action query with last activity metrics failed" },
7169
8087
  { label: "conversion_action_minimal", gaql: fallbackGaql }
7170
8088
  ]);
7171
- return ok({ conversionActions: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8089
+ return ok({ conversionActions: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7172
8090
  } catch (e) {
7173
8091
  return formatMcpToolError(e);
7174
8092
  }
@@ -7183,10 +8101,10 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7183
8101
  endDate: isoDateSchema2.optional().describe("End date YYYY-MM-DD. Defaults to today."),
7184
8102
  resourceType: gaqlEnumSchema.optional().describe("Optional ChangeEventResourceType filter, e.g. CAMPAIGN or AD_GROUP_AD"),
7185
8103
  operation: gaqlEnumSchema.optional().describe("Optional ResourceChangeOperation filter, e.g. CREATE, UPDATE, REMOVE"),
7186
- userEmail: z5.string().email().optional().describe("Optional user email filter"),
7187
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8104
+ userEmail: z7.string().email().optional().describe("Optional user email filter"),
8105
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
7188
8106
  },
7189
- async ({ customerId, startDate, endDate, resourceType, operation, userEmail, limit }) => {
8107
+ async ({ customerId, startDate, endDate, resourceType, operation: operation2, userEmail, limit }) => {
7190
8108
  try {
7191
8109
  const range = normalizeChangeEventRange(startDate, endDate);
7192
8110
  const where = [
@@ -7194,7 +8112,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7194
8112
  `change_event.change_date_time < '${range.endExclusiveDate}'`
7195
8113
  ];
7196
8114
  if (resourceType) where.push(`change_event.change_resource_type = '${resourceType}'`);
7197
- if (operation) where.push(`change_event.resource_change_operation = '${operation}'`);
8115
+ if (operation2) where.push(`change_event.resource_change_operation = '${operation2}'`);
7198
8116
  if (userEmail) where.push(`change_event.user_email = '${quoteGaqlString(userEmail)}'`);
7199
8117
  const gaql = oneLineGaql(`
7200
8118
  SELECT
@@ -7232,8 +8150,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7232
8150
  {
7233
8151
  customerId: customerIdSchema3,
7234
8152
  typeFilter: gaqlEnumSchema.optional().describe("Optional RecommendationType enum filter"),
7235
- includeDismissed: z5.boolean().optional().default(false),
7236
- limit: z5.number().int().min(1).max(1e3).optional().default(1e3)
8153
+ includeDismissed: z7.boolean().optional().default(false),
8154
+ limit: z7.number().int().min(1).max(1e3).optional().default(1e3)
7237
8155
  },
7238
8156
  async ({ customerId, typeFilter, includeDismissed, limit }) => {
7239
8157
  try {
@@ -7273,11 +8191,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7273
8191
  ORDER BY recommendation.type ASC
7274
8192
  LIMIT ${limit}
7275
8193
  `);
7276
- const result = await runGaqlWithFallback(client, customerId, [
8194
+ const result2 = await runGaqlWithFallback(client, customerId, [
7277
8195
  { label: "recommendations_with_impact", gaql: richGaql, failureWarning: "Recommendation query with attributed resources/impact failed" },
7278
8196
  { label: "recommendations_minimal", gaql: fallbackGaql }
7279
8197
  ]);
7280
- return ok({ recommendations: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8198
+ return ok({ recommendations: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7281
8199
  } catch (e) {
7282
8200
  return formatMcpToolError(e);
7283
8201
  }
@@ -7288,8 +8206,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7288
8206
  "List campaign budgets with amount, status, delivery method, and recommended budget fields when supported.",
7289
8207
  {
7290
8208
  customerId: customerIdSchema3,
7291
- statusFilter: z5.enum(["ENABLED", "REMOVED", "UNKNOWN", "UNSPECIFIED"]).optional(),
7292
- limit: z5.number().int().min(1).max(1e3).optional().default(1e3)
8209
+ statusFilter: z7.enum(["ENABLED", "REMOVED", "UNKNOWN", "UNSPECIFIED"]).optional(),
8210
+ limit: z7.number().int().min(1).max(1e3).optional().default(1e3)
7293
8211
  },
7294
8212
  async ({ customerId, statusFilter, limit }) => {
7295
8213
  try {
@@ -7333,11 +8251,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7333
8251
  ORDER BY campaign_budget.name ASC
7334
8252
  LIMIT ${limit}
7335
8253
  `);
7336
- const result = await runGaqlWithFallback(client, customerId, [
8254
+ const result2 = await runGaqlWithFallback(client, customerId, [
7337
8255
  { label: "campaign_budget_with_recommendations", gaql: richGaql, failureWarning: "Campaign budget query with recommended budget fields failed" },
7338
8256
  { label: "campaign_budget_minimal", gaql: fallbackGaql }
7339
8257
  ]);
7340
- return ok({ budgets: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8258
+ return ok({ budgets: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7341
8259
  } catch (e) {
7342
8260
  return formatMcpToolError(e);
7343
8261
  }
@@ -7351,7 +8269,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7351
8269
  startDate: isoDateSchema2.optional().describe("Optional start date YYYY-MM-DD for metrics"),
7352
8270
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD for metrics"),
7353
8271
  typeFilter: gaqlEnumSchema.optional().describe("Optional BiddingStrategyType enum filter"),
7354
- limit: z5.number().int().min(1).max(1e3).optional().default(1e3)
8272
+ limit: z7.number().int().min(1).max(1e3).optional().default(1e3)
7355
8273
  },
7356
8274
  async ({ customerId, startDate, endDate, typeFilter, limit }) => {
7357
8275
  try {
@@ -7398,11 +8316,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7398
8316
  ORDER BY bidding_strategy.name ASC
7399
8317
  LIMIT ${limit}
7400
8318
  `);
7401
- const result = await runGaqlWithFallback(client, customerId, [
8319
+ const result2 = await runGaqlWithFallback(client, customerId, [
7402
8320
  { label: "bidding_strategy_requested", gaql, failureWarning: "Bidding strategy query with requested fields failed" },
7403
8321
  { label: "bidding_strategy_structure", gaql: fallbackGaql }
7404
8322
  ]);
7405
- return ok({ biddingStrategies: result.rows, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8323
+ return ok({ biddingStrategies: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7406
8324
  } catch (e) {
7407
8325
  return formatMcpToolError(e);
7408
8326
  }
@@ -7413,13 +8331,13 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7413
8331
  "Fetch search term performance from search_term_view or campaign_search_term_insight depending on reportType.",
7414
8332
  {
7415
8333
  customerId: customerIdSchema3,
7416
- reportType: z5.enum(["search_term_view", "campaign_search_term_insight"]).optional().default("search_term_view"),
8334
+ reportType: z7.enum(["search_term_view", "campaign_search_term_insight"]).optional().default("search_term_view"),
7417
8335
  startDate: isoDateSchema2.describe("Start date YYYY-MM-DD"),
7418
8336
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
7419
8337
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7420
8338
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter. Applies directly to search_term_view and via segments.ad_group for insight reports."),
7421
- searchTermContains: z5.string().min(1).optional().describe("Optional substring filter for search_term_view"),
7422
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8339
+ searchTermContains: z7.string().min(1).optional().describe("Optional substring filter for search_term_view"),
8340
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
7423
8341
  },
7424
8342
  async ({ customerId, reportType, startDate, endDate, campaignId, adGroupId, searchTermContains, limit }) => {
7425
8343
  try {
@@ -7492,7 +8410,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7492
8410
  startDate: isoDateSchema2.describe("Start date YYYY-MM-DD"),
7493
8411
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
7494
8412
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7495
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8413
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
7496
8414
  },
7497
8415
  async ({ customerId, startDate, endDate, campaignId, limit }) => {
7498
8416
  try {
@@ -7536,11 +8454,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7536
8454
  ORDER BY metrics.clicks DESC
7537
8455
  LIMIT ${limit}
7538
8456
  `);
7539
- const result = await runGaqlWithFallback(client, customerId, [
8457
+ const result2 = await runGaqlWithFallback(client, customerId, [
7540
8458
  { label: "landing_page_with_quality_metrics", gaql: richGaql, failureWarning: "Landing page query with quality metrics failed" },
7541
8459
  { label: "landing_page_minimal", gaql: fallbackGaql }
7542
8460
  ]);
7543
- return ok({ landingPages: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8461
+ return ok({ landingPages: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7544
8462
  } catch (e) {
7545
8463
  return formatMcpToolError(e);
7546
8464
  }
@@ -7553,27 +8471,30 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7553
8471
  customerId: customerIdSchema3,
7554
8472
  startDate: isoDateSchema2.optional().describe("Optional start date YYYY-MM-DD for performance metrics"),
7555
8473
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD for performance metrics"),
8474
+ metrics: z7.array(z7.enum(PMAX_METRIC_FIELDS)).min(1).max(32).optional().describe("Additional asset-compatible GAQL metric fields. Used with a date range; delivery metrics are always included. Omit for the default delivery/conversion fields."),
7556
8475
  campaignId: numericIdSchema3.optional().describe("Optional Performance Max campaign ID filter"),
7557
8476
  assetGroupId: numericIdSchema3.optional().describe("Optional asset group ID filter"),
8477
+ assetGroupIds: z7.array(numericIdSchema3).min(1).max(10).optional().describe("Restrict reporting to these asset groups."),
8478
+ assetId: numericIdSchema3.optional().describe("Restrict reporting to one asset."),
8479
+ daily: z7.boolean().optional().default(false).describe("Segment dated performance by segments.date."),
7558
8480
  fieldType: gaqlEnumSchema.optional().describe("Optional AssetFieldType enum filter, e.g. HEADLINE, LONG_HEADLINE, MARKETING_IMAGE"),
7559
- statusFilter: z5.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
7560
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8481
+ statusFilter: z7.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
8482
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
7561
8483
  },
7562
- async ({ customerId, startDate, endDate, campaignId, assetGroupId, fieldType, statusFilter, limit }) => {
8484
+ async ({ customerId, startDate, endDate, metrics, campaignId, assetGroupId, assetGroupIds, assetId, daily, fieldType, statusFilter, limit }) => {
7563
8485
  try {
7564
8486
  const warnings = [];
7565
8487
  const where = ["campaign.advertising_channel_type = 'PERFORMANCE_MAX'", ...optionalDateRange(startDate, endDate)];
7566
8488
  if (campaignId) where.push(`campaign.id = ${campaignId}`);
7567
8489
  if (assetGroupId) where.push(`asset_group.id = ${assetGroupId}`);
8490
+ if (assetGroupIds) where.push(`asset_group.id IN (${assetGroupIds.join(",")})`);
8491
+ if (assetId) where.push(`asset.id = ${assetId}`);
8492
+ if (daily && (!startDate || !endDate)) throw new Error("Daily reporting requires both startDate and endDate.");
7568
8493
  if (fieldType) where.push(`asset_group_asset.field_type = '${fieldType}'`);
7569
8494
  if (statusFilter) where.push(`asset_group_asset.status = '${statusFilter}'`);
7570
8495
  if (!startDate && !endDate) warnings.push("No date range provided; performance metrics were omitted and only asset structure was returned.");
7571
- const metricsFields = startDate || endDate ? `,
7572
- metrics.impressions,
7573
- metrics.clicks,
7574
- metrics.cost_micros,
7575
- metrics.conversions,
7576
- metrics.conversions_value` : "";
8496
+ const selectedMetrics = [.../* @__PURE__ */ new Set(["metrics.impressions", "metrics.cost_micros", ...metrics ?? PMAX_METRIC_FIELDS.slice(0, 5)])];
8497
+ const metricsFields = startDate || endDate ? `, ${selectedMetrics.join(", ")}${daily ? ", segments.date" : ""}` : "";
7577
8498
  const richGaql = oneLineGaql(`
7578
8499
  SELECT
7579
8500
  campaign.id,
@@ -7620,11 +8541,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7620
8541
  ORDER BY campaign.name ASC, asset_group.name ASC
7621
8542
  LIMIT ${limit}
7622
8543
  `);
7623
- const result = await runGaqlWithFallback(client, customerId, [
8544
+ const result2 = await runGaqlWithFallback(client, customerId, [
7624
8545
  { label: "pmax_assets_with_asset_details", gaql: richGaql, failureWarning: "PMax asset query with asset details failed" },
7625
8546
  { label: "pmax_assets_minimal", gaql: fallbackGaql }
7626
8547
  ]);
7627
- return ok({ pmaxAssets: result.rows, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8548
+ return ok({ pmaxAssets: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7628
8549
  } catch (e) {
7629
8550
  return formatMcpToolError(e);
7630
8551
  }
@@ -7635,7 +8556,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7635
8556
  "Read-only planning/forecast query for campaign, ad group, or portfolio bidding simulations. Returns simulation metadata and projected point lists when available, with a metadata fallback.",
7636
8557
  {
7637
8558
  customerId: customerIdSchema3,
7638
- level: z5.enum(["campaign", "ad_group", "bidding_strategy"]).optional().default("campaign").describe("Simulation resource level to query"),
8559
+ level: z7.enum(["campaign", "ad_group", "bidding_strategy"]).optional().default("campaign").describe("Simulation resource level to query"),
7639
8560
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter. Applies directly to campaign simulations and as campaign context for ad group simulations."),
7640
8561
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter for ad_group simulations"),
7641
8562
  biddingStrategyId: numericIdSchema3.optional().describe("Optional portfolio bidding strategy ID filter for bidding_strategy simulations"),
@@ -7643,7 +8564,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7643
8564
  simulationEndDate: isoDateSchema2.optional().describe("Optional maximum simulation end date YYYY-MM-DD"),
7644
8565
  typeFilter: gaqlEnumSchema.optional().describe("Optional SimulationType enum, e.g. BUDGET, TARGET_CPA, TARGET_ROAS, CPC_BID"),
7645
8566
  modificationMethod: gaqlEnumSchema.optional().describe("Optional SimulationModificationMethod enum, e.g. UNIFORM, SCALING, DEFAULT"),
7646
- limit: z5.number().int().min(1).max(1e3).optional().default(1e3)
8567
+ limit: z7.number().int().min(1).max(1e3).optional().default(1e3)
7647
8568
  },
7648
8569
  async ({ customerId, level, campaignId, adGroupId, biddingStrategyId, simulationStartDate, simulationEndDate, typeFilter, modificationMethod, limit }) => {
7649
8570
  try {
@@ -7772,11 +8693,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7772
8693
  ORDER BY ${resource}.start_date DESC
7773
8694
  LIMIT ${limit}
7774
8695
  `);
7775
- const result = await runGaqlWithFallback(client, customerId, [
8696
+ const result2 = await runGaqlWithFallback(client, customerId, [
7776
8697
  { label: `${resource}_with_points`, gaql: richGaql, failureWarning: "Simulation query with projected point lists failed" },
7777
8698
  { label: `${resource}_metadata`, gaql: fallbackGaql }
7778
8699
  ]);
7779
- return ok({ simulations: result.rows, level, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8700
+ return ok({ simulations: result2.rows, level, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7780
8701
  } catch (e) {
7781
8702
  return formatMcpToolError(e);
7782
8703
  }
@@ -7791,9 +8712,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7791
8712
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
7792
8713
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7793
8714
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter"),
7794
- searchTermContains: z5.string().min(1).optional().describe("Optional search term substring filter"),
7795
- serpType: z5.enum(["ADS_AND_ORGANIC", "ADS_ONLY", "ORGANIC_ONLY", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional search engine results page type segment filter"),
7796
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8715
+ searchTermContains: z7.string().min(1).optional().describe("Optional search term substring filter"),
8716
+ serpType: z7.enum(["ADS_AND_ORGANIC", "ADS_ONLY", "ORGANIC_ONLY", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional search engine results page type segment filter"),
8717
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
7797
8718
  },
7798
8719
  async ({ customerId, startDate, endDate, campaignId, adGroupId, searchTermContains, serpType, limit }) => {
7799
8720
  try {
@@ -7853,11 +8774,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7853
8774
  ORDER BY metrics.clicks DESC
7854
8775
  LIMIT ${limit}
7855
8776
  `);
7856
- const result = await runGaqlWithFallback(client, customerId, [
8777
+ const result2 = await runGaqlWithFallback(client, customerId, [
7857
8778
  { label: "paid_organic_search_term_view", gaql: richGaql, failureWarning: "Paid/organic search term query failed; organic reporting may require eligible linked organic search data or compatible fields" },
7858
8779
  { label: "search_term_view_paid_only", gaql: fallbackGaql }
7859
8780
  ]);
7860
- return ok({ searchTerms: result.rows, source: result.queryLabel, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8781
+ return ok({ searchTerms: result2.rows, source: result2.queryLabel, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7861
8782
  } catch (e) {
7862
8783
  return formatMcpToolError(e);
7863
8784
  }
@@ -7868,17 +8789,17 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7868
8789
  "Read-only Merchant Center product catalog/eligibility report via shopping_product. Supports account, campaign, and ad group scopes with performance metrics when the account has Shopping/PMax e-commerce data.",
7869
8790
  {
7870
8791
  customerId: customerIdSchema3,
7871
- scope: z5.enum(["account", "campaign", "ad_group"]).optional().default("account").describe("shopping_product scope. ad_group scope requires campaignId and adGroupId."),
8792
+ scope: z7.enum(["account", "campaign", "ad_group"]).optional().default("account").describe("shopping_product scope. ad_group scope requires campaignId and adGroupId."),
7872
8793
  startDate: isoDateSchema2.optional().describe("Optional start date YYYY-MM-DD. Used only as a WHERE filter; shopping_product cannot segment by date."),
7873
8794
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD. Used only as a WHERE filter; shopping_product cannot segment by date."),
7874
8795
  campaignId: numericIdSchema3.optional().describe("Campaign ID for campaign or ad_group scope"),
7875
8796
  adGroupId: numericIdSchema3.optional().describe("Ad group ID for ad_group scope"),
7876
8797
  merchantCenterId: numericIdSchema3.optional().describe("Optional Merchant Center ID filter"),
7877
- itemId: z5.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
7878
- titleContains: z5.string().min(1).optional().describe("Optional product title substring filter"),
7879
- feedLabel: z5.string().min(1).optional().describe("Optional feed label filter"),
7880
- statusFilter: z5.enum(["ELIGIBLE", "ELIGIBLE_LIMITED", "NOT_ELIGIBLE", "UNKNOWN", "UNSPECIFIED"]).optional(),
7881
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8798
+ itemId: z7.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
8799
+ titleContains: z7.string().min(1).optional().describe("Optional product title substring filter"),
8800
+ feedLabel: z7.string().min(1).optional().describe("Optional feed label filter"),
8801
+ statusFilter: z7.enum(["ELIGIBLE", "ELIGIBLE_LIMITED", "NOT_ELIGIBLE", "UNKNOWN", "UNSPECIFIED"]).optional(),
8802
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
7882
8803
  },
7883
8804
  async ({ customerId, scope, startDate, endDate, campaignId, adGroupId, merchantCenterId, itemId, titleContains, feedLabel, statusFilter, limit }) => {
7884
8805
  try {
@@ -7965,11 +8886,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7965
8886
  ORDER BY shopping_product.title ASC
7966
8887
  LIMIT ${limit}
7967
8888
  `);
7968
- const result = await runGaqlWithFallback(client, customerId, [
8889
+ const result2 = await runGaqlWithFallback(client, customerId, [
7969
8890
  { label: "shopping_product_with_metrics_and_issues", gaql: richGaql, failureWarning: "Shopping product query with performance/cart/issue fields failed" },
7970
8891
  { label: "shopping_product_catalog", gaql: fallbackGaql }
7971
8892
  ]);
7972
- return ok({ shoppingProducts: result.rows, scope, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8893
+ return ok({ shoppingProducts: result2.rows, scope, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7973
8894
  } catch (e) {
7974
8895
  return formatMcpToolError(e);
7975
8896
  }
@@ -7985,11 +8906,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7985
8906
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7986
8907
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter"),
7987
8908
  merchantCenterId: numericIdSchema3.optional().describe("Optional Merchant Center ID filter"),
7988
- itemId: z5.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
7989
- titleContains: z5.string().min(1).optional().describe("Optional product title substring filter"),
7990
- brand: z5.string().min(1).optional().describe("Optional product brand filter"),
7991
- feedLabel: z5.string().min(1).optional().describe("Optional product feed label filter"),
7992
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8909
+ itemId: z7.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
8910
+ titleContains: z7.string().min(1).optional().describe("Optional product title substring filter"),
8911
+ brand: z7.string().min(1).optional().describe("Optional product brand filter"),
8912
+ feedLabel: z7.string().min(1).optional().describe("Optional product feed label filter"),
8913
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
7993
8914
  },
7994
8915
  async ({ customerId, startDate, endDate, campaignId, adGroupId, merchantCenterId, itemId, titleContains, brand, feedLabel, limit }) => {
7995
8916
  try {
@@ -8049,11 +8970,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8049
8970
  ORDER BY metrics.cost_micros DESC
8050
8971
  LIMIT ${limit}
8051
8972
  `);
8052
- const result = await runGaqlWithFallback(client, customerId, [
8973
+ const result2 = await runGaqlWithFallback(client, customerId, [
8053
8974
  { label: "shopping_performance_with_cart_metrics", gaql: richGaql, failureWarning: "Shopping performance query with cart/profit metrics failed" },
8054
8975
  { label: "shopping_performance_core", gaql: fallbackGaql }
8055
8976
  ]);
8056
- return ok({ shoppingPerformance: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8977
+ return ok({ shoppingPerformance: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
8057
8978
  } catch (e) {
8058
8979
  return formatMcpToolError(e);
8059
8980
  }
@@ -8067,9 +8988,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8067
8988
  startDate: isoDateSchema2.describe("Start date YYYY-MM-DD"),
8068
8989
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
8069
8990
  campaignId: numericIdSchema3.optional().describe("Optional Performance Max campaign ID filter"),
8070
- placementType: z5.enum(["WEBSITE", "MOBILE_APPLICATION", "YOUTUBE_VIDEO", "YOUTUBE_CHANNEL", "GOOGLE_PRODUCTS", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional PlacementType enum filter"),
8071
- placementContains: z5.string().min(1).optional().describe("Optional substring filter for the placement string"),
8072
- limit: z5.number().int().min(1).max(1e4).optional().default(1e3)
8991
+ placementType: z7.enum(["WEBSITE", "MOBILE_APPLICATION", "YOUTUBE_VIDEO", "YOUTUBE_CHANNEL", "GOOGLE_PRODUCTS", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional PlacementType enum filter"),
8992
+ placementContains: z7.string().min(1).optional().describe("Optional substring filter for the placement string"),
8993
+ limit: z7.number().int().min(1).max(1e4).optional().default(1e3)
8073
8994
  },
8074
8995
  async ({ customerId, startDate, endDate, campaignId, placementType, placementContains, limit }) => {
8075
8996
  try {
@@ -8105,16 +9026,16 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8105
9026
  ORDER BY metrics.impressions DESC
8106
9027
  LIMIT ${limit}
8107
9028
  `);
8108
- const result = await runGaqlWithFallback(client, customerId, [
9029
+ const result2 = await runGaqlWithFallback(client, customerId, [
8109
9030
  { label: "pmax_placements_with_target_url_and_campaign", gaql: richGaql, failureWarning: "PMax placement query with target URL/campaign context failed" },
8110
9031
  { label: "pmax_placements_core", gaql: fallbackGaql }
8111
9032
  ]);
8112
9033
  return ok({
8113
- placements: result.rows,
8114
- pmaxPlacements: result.rows,
8115
- count: result.rows.length,
8116
- gaql: result.gaql,
8117
- warnings: [...warnings, ...result.warnings]
9034
+ placements: result2.rows,
9035
+ pmaxPlacements: result2.rows,
9036
+ count: result2.rows.length,
9037
+ gaql: result2.gaql,
9038
+ warnings: [...warnings, ...result2.warnings]
8118
9039
  });
8119
9040
  } catch (e) {
8120
9041
  return formatMcpToolError(e);
@@ -8130,9 +9051,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8130
9051
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD for asset group metrics/top combinations"),
8131
9052
  campaignId: numericIdSchema3.optional().describe("Optional Performance Max campaign ID filter"),
8132
9053
  assetGroupId: numericIdSchema3.optional().describe("Optional asset group ID filter"),
8133
- statusFilter: z5.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
8134
- includeTopCombinations: z5.boolean().optional().default(true).describe("Also query asset_group_top_combination_view when available"),
8135
- limit: z5.number().int().min(1).max(1e3).optional().default(1e3)
9054
+ statusFilter: z7.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
9055
+ includeTopCombinations: z7.boolean().optional().default(true).describe("Also query asset_group_top_combination_view when available"),
9056
+ limit: z7.number().int().min(1).max(1e3).optional().default(1e3)
8136
9057
  },
8137
9058
  async ({ customerId, startDate, endDate, campaignId, assetGroupId, statusFilter, includeTopCombinations, limit }) => {
8138
9059
  try {
@@ -8232,6 +9153,207 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8232
9153
  }
8233
9154
  }
8234
9155
  );
9156
+ server.tool(
9157
+ "google_ads_list_image_assets",
9158
+ "List the account's image asset library (FROM asset) with stable, publicly served full-size URLs on tpc.googlesyndication.com, dimensions, file size, and mime type. This is the whole stock and carries no notion of delivery: an asset uploaded in 2022 and never served appears exactly like one running today. Newest first, so a limit returns recent assets rather than an arbitrary slice, and the count in the response is the account total. For a visual-only gallery of assets with delivery over dates, use the hosted GetMCPAds service (Performance Max coverage). The local server returns media URLs and native report data. Shopping product imagery lives in Merchant Center, not in this library.",
9159
+ {
9160
+ customerId: customerIdSchema3,
9161
+ beforeAssetId: numericIdSchema3.optional().describe("Continue the descending library before this last-seen asset ID. Use the same filters."),
9162
+ assetIds: z7.array(numericIdSchema3).min(1).max(8).optional().describe("Resolve these exact native asset IDs, without scanning the library."),
9163
+ nameFilter: z7.string().optional().describe("Only assets whose file name contains this text"),
9164
+ minWidth: z7.number().int().min(1).optional().describe("Only images at least this wide, in pixels"),
9165
+ minHeight: z7.number().int().min(1).optional().describe("Only images at least this tall, in pixels"),
9166
+ limit: z7.number().int().min(1).max(1e4).optional().default(500)
9167
+ },
9168
+ async ({ customerId, beforeAssetId, assetIds, nameFilter, minWidth, minHeight, limit }) => {
9169
+ try {
9170
+ const where = ["asset.type = 'IMAGE'"];
9171
+ if (beforeAssetId) where.push(`asset.id < ${beforeAssetId}`);
9172
+ if (assetIds?.length) where.push(`asset.id IN (${assetIds.join(",")})`);
9173
+ if (nameFilter) where.push(`asset.name LIKE '%${quoteGaqlString(nameFilter)}%'`);
9174
+ if (minWidth) where.push(`asset.image_asset.full_size.width_pixels >= ${minWidth}`);
9175
+ if (minHeight) where.push(`asset.image_asset.full_size.height_pixels >= ${minHeight}`);
9176
+ const { rows, gaql, warnings } = await runGaqlWithFallback(client, customerId, [{
9177
+ label: "image asset library",
9178
+ gaql: oneLineGaql(`
9179
+ SELECT
9180
+ asset.id, asset.name, asset.type, asset.source,
9181
+ asset.image_asset.full_size.url,
9182
+ asset.image_asset.full_size.width_pixels,
9183
+ asset.image_asset.full_size.height_pixels,
9184
+ asset.image_asset.file_size,
9185
+ asset.image_asset.mime_type
9186
+ FROM asset${buildWhere(where)} ORDER BY asset.id DESC LIMIT ${limit}`)
9187
+ }]);
9188
+ return ok({
9189
+ imageAssets: rows,
9190
+ count: rows.length,
9191
+ gaql,
9192
+ warnings,
9193
+ limitations: [
9194
+ "This is the account library, not a delivery report: it says nothing about what ran or spent in any period. Rows come newest first, so a limit returns the most recent assets. Use dated delivery reports to identify assets that actually served; the hosted GetMCPAds service provides interactive galleries.",
9195
+ "full_size.url points at tpc.googlesyndication.com/simgad and is publicly served without authentication or visible expiry: safe to display, hotlink, or download while the asset exists.",
9196
+ "Shopping/PLA product visuals come from Merchant Center feeds and never appear in this asset library."
9197
+ ],
9198
+ nextActions: rows.length >= limit ? ["The LIMIT was reached; narrow with nameFilter or dimensions, or raise limit."] : []
9199
+ });
9200
+ } catch (e) {
9201
+ return formatMcpToolError(e);
9202
+ }
9203
+ }
9204
+ );
9205
+ server.tool(
9206
+ "google_ads_list_video_assets",
9207
+ "List the account's YouTube video assets (FROM asset) with derived watch, embed, and public thumbnail URLs. This is the whole stock and carries no notion of delivery: an asset uploaded years ago and never served appears exactly like one running today. Newest first, so a limit returns recent assets rather than an arbitrary slice, and the count in the response is the account total. For a visual-only gallery of assets with delivery over dates, use the hosted GetMCPAds service (Performance Max coverage). The local server returns media URLs and native report data. Google Ads exposes no downloadable video file: PMax and Demand Gen videos are hosted on YouTube, so display them via embed or thumbnails.",
9208
+ {
9209
+ customerId: customerIdSchema3,
9210
+ beforeAssetId: numericIdSchema3.optional().describe("Continue the descending library before this last-seen asset ID. Use the same filters."),
9211
+ assetIds: z7.array(numericIdSchema3).min(1).max(8).optional().describe("Resolve these exact native asset IDs, without scanning the library."),
9212
+ nameFilter: z7.string().optional().describe("Only assets whose name contains this text"),
9213
+ limit: z7.number().int().min(1).max(1e4).optional().default(500)
9214
+ },
9215
+ async ({ customerId, beforeAssetId, assetIds, nameFilter, limit }) => {
9216
+ try {
9217
+ const where = ["asset.type = 'YOUTUBE_VIDEO'"];
9218
+ if (beforeAssetId) where.push(`asset.id < ${beforeAssetId}`);
9219
+ if (assetIds?.length) where.push(`asset.id IN (${assetIds.join(",")})`);
9220
+ if (nameFilter) where.push(`asset.name LIKE '%${quoteGaqlString(nameFilter)}%'`);
9221
+ const { rows, gaql, warnings } = await runGaqlWithFallback(client, customerId, [{
9222
+ label: "video asset library",
9223
+ gaql: oneLineGaql(`
9224
+ SELECT
9225
+ asset.id, asset.name, asset.type, asset.source,
9226
+ asset.youtube_video_asset.youtube_video_id,
9227
+ asset.youtube_video_asset.youtube_video_title
9228
+ FROM asset${buildWhere(where)} ORDER BY asset.id DESC LIMIT ${limit}`)
9229
+ }]);
9230
+ const videoAssets = rows.map((row) => {
9231
+ const asset = recordOf(recordOf(row)?.asset);
9232
+ const youtube = recordOf(asset?.youtubeVideoAsset);
9233
+ return { ...row, derivedUrls: youtubeDerivedUrls(youtube?.youtubeVideoId) };
9234
+ });
9235
+ return ok({
9236
+ videoAssets,
9237
+ count: videoAssets.length,
9238
+ gaql,
9239
+ warnings,
9240
+ limitations: [
9241
+ "This is the account library, not a delivery report: it says nothing about what ran or spent in any period. Rows come newest first, so a limit returns the most recent assets. Use dated delivery reports to identify assets that actually served; the hosted GetMCPAds service provides interactive galleries.",
9242
+ "No raw video file is available through the Google Ads API and YouTube's terms forbid downloading: use derivedUrls.embed_url (iframe) or watch_url, and the publicly served, stable i.ytimg.com thumbnails (maxresdefault with hqdefault as the always-present fallback)."
9243
+ ],
9244
+ nextActions: videoAssets.length >= limit ? ["The LIMIT was reached; narrow with nameFilter or raise limit."] : []
9245
+ });
9246
+ } catch (e) {
9247
+ return formatMcpToolError(e);
9248
+ }
9249
+ }
9250
+ );
9251
+ server.tool(
9252
+ "google_ads_get_demand_gen_assets",
9253
+ "List Demand Gen ads (video responsive, multi-asset, carousel) with their referenced creatives resolved: stable image URLs and YouTube video IDs with embed and thumbnail URLs. Complements google_ads_get_pmax_assets, which covers Performance Max.",
9254
+ {
9255
+ customerId: customerIdSchema3,
9256
+ campaignId: numericIdSchema3.optional().describe("Optional Demand Gen campaign ID filter"),
9257
+ limit: z7.number().int().min(1).max(1e4).optional().default(500)
9258
+ },
9259
+ async ({ customerId, campaignId, limit }) => {
9260
+ try {
9261
+ const where = ["campaign.advertising_channel_type = 'DEMAND_GEN'"];
9262
+ if (campaignId) where.push(`campaign.id = ${campaignId}`);
9263
+ const demandGenFields = `
9264
+ ad_group_ad.ad.demand_gen_video_responsive_ad.videos,
9265
+ ad_group_ad.ad.demand_gen_video_responsive_ad.logo_images,
9266
+ ad_group_ad.ad.demand_gen_multi_asset_ad.marketing_images,
9267
+ ad_group_ad.ad.demand_gen_multi_asset_ad.square_marketing_images,
9268
+ ad_group_ad.ad.demand_gen_multi_asset_ad.portrait_marketing_images,
9269
+ ad_group_ad.ad.demand_gen_multi_asset_ad.logo_images,
9270
+ ad_group_ad.ad.demand_gen_carousel_ad.carousel_cards`;
9271
+ const baseFields = `
9272
+ campaign.id, campaign.name, ad_group.id, ad_group.name,
9273
+ ad_group_ad.status, ad_group_ad.ad.id, ad_group_ad.ad.name, ad_group_ad.ad.type`;
9274
+ const ads = await runGaqlWithFallback(client, customerId, [
9275
+ {
9276
+ label: "demand gen ads with creative references",
9277
+ gaql: oneLineGaql(`SELECT ${baseFields}, ${demandGenFields} FROM ad_group_ad${buildWhere(where)} LIMIT ${limit}`),
9278
+ failureWarning: "Full Demand Gen creative field selection failed; falling back to ad identifiers only"
9279
+ },
9280
+ {
9281
+ label: "demand gen ads without creative fields",
9282
+ gaql: oneLineGaql(`SELECT ${baseFields} FROM ad_group_ad${buildWhere(where)} LIMIT ${limit}`)
9283
+ }
9284
+ ]);
9285
+ const referencedIds = /* @__PURE__ */ new Set();
9286
+ for (const row of ads.rows) {
9287
+ const ad = recordOf(recordOf(recordOf(row)?.adGroupAd)?.ad);
9288
+ if (!ad) continue;
9289
+ collectAssetIds(ad.demandGenVideoResponsiveAd, referencedIds);
9290
+ collectAssetIds(ad.demandGenMultiAssetAd, referencedIds);
9291
+ collectAssetIds(ad.demandGenCarouselAd, referencedIds);
9292
+ }
9293
+ const warnings = [...ads.warnings];
9294
+ const assetsById = /* @__PURE__ */ new Map();
9295
+ const idList = [...referencedIds];
9296
+ const chunkSize = 500;
9297
+ for (let index = 0; index < idList.length; index += chunkSize) {
9298
+ const chunk = idList.slice(index, index + chunkSize);
9299
+ try {
9300
+ const resolved = await client.searchStream(customerId, oneLineGaql(`
9301
+ SELECT
9302
+ asset.id, asset.name, asset.type,
9303
+ asset.image_asset.full_size.url,
9304
+ asset.image_asset.full_size.width_pixels,
9305
+ asset.image_asset.full_size.height_pixels,
9306
+ asset.youtube_video_asset.youtube_video_id,
9307
+ asset.youtube_video_asset.youtube_video_title
9308
+ FROM asset WHERE asset.id IN (${chunk.join(", ")})`));
9309
+ for (const row of resolved) {
9310
+ const asset = recordOf(recordOf(row)?.asset);
9311
+ const rawId = asset?.id;
9312
+ const id = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : void 0;
9313
+ if (!asset || !id) continue;
9314
+ const youtube = recordOf(asset.youtubeVideoAsset);
9315
+ assetsById.set(id, {
9316
+ ...asset,
9317
+ derivedUrls: youtubeDerivedUrls(youtube?.youtubeVideoId)
9318
+ });
9319
+ }
9320
+ } catch (error) {
9321
+ warnings.push(`Asset resolution failed for ${chunk.length} referenced assets: ${getErrorMessage(error)}`);
9322
+ }
9323
+ }
9324
+ const resolvedAds = ads.rows.map((row) => {
9325
+ const record = recordOf(row) ?? {};
9326
+ const ad = recordOf(recordOf(record.adGroupAd)?.ad);
9327
+ const ids = /* @__PURE__ */ new Set();
9328
+ if (ad) {
9329
+ collectAssetIds(ad.demandGenVideoResponsiveAd, ids);
9330
+ collectAssetIds(ad.demandGenMultiAssetAd, ids);
9331
+ collectAssetIds(ad.demandGenCarouselAd, ids);
9332
+ }
9333
+ return {
9334
+ ...record,
9335
+ resolvedAssets: [...ids].map((id) => assetsById.get(id) ?? { id, unresolved: true })
9336
+ };
9337
+ });
9338
+ return ok({
9339
+ ads: resolvedAds,
9340
+ count: resolvedAds.length,
9341
+ referencedAssetCount: referencedIds.size,
9342
+ resolvedAssetCount: assetsById.size,
9343
+ gaql: ads.gaql,
9344
+ warnings,
9345
+ limitations: [
9346
+ "Demand Gen videos are YouTube assets: no raw file is available (YouTube terms), display them via resolvedAssets.derivedUrls.embed_url or the stable public i.ytimg.com thumbnails.",
9347
+ "Image full_size.url values on tpc.googlesyndication.com are publicly served with no visible expiry.",
9348
+ "Catalog-driven visuals come from Merchant Center feeds and are not part of ad creative references."
9349
+ ],
9350
+ nextActions: resolvedAds.length >= limit ? ["The LIMIT was reached; filter by campaignId or raise limit."] : []
9351
+ });
9352
+ } catch (e) {
9353
+ return formatMcpToolError(e);
9354
+ }
9355
+ }
9356
+ );
8235
9357
  registerGoogleAdsKeywordPlannerTools(server, client, ok);
8236
9358
  registerGoogleAdsDiscoveryTools(server, client, ok);
8237
9359
  registerGoogleAdsReadOnlyRpcTool(server, client, ok);
@@ -8277,7 +9399,11 @@ var GOOGLE_ADS_TOOL_MANIFEST = {
8277
9399
  { name: "google_ads_run_readonly_rpc", purpose: "Call an allowlisted non-GAQL read service for Audience Insights, Reach Planner, benchmarks, suggestions, identity, invoices, or payments metadata." },
8278
9400
  { name: "google_ads_get_insights", purpose: "Generate validated performance GAQL using the metric and dimension catalogs." },
8279
9401
  { name: "google_ads_validate_query", purpose: "Validate metric/dimension/resource compatibility before querying." },
8280
- { name: "google_ads_run_gaql", purpose: "Run raw read-only GAQL SELECT queries for advanced reporting." }
9402
+ { name: "google_ads_run_gaql", purpose: "Run raw read-only GAQL SELECT queries for advanced reporting." },
9403
+ { name: "google_ads_list_image_assets", purpose: "List the account's image asset library with stable public full-size URLs, dimensions, and file metadata." },
9404
+ { name: "google_ads_list_video_assets", purpose: "List YouTube video assets with derived watch, embed, and public thumbnail URLs." },
9405
+ { name: "google_ads_get_demand_gen_assets", purpose: "List Demand Gen ads with their referenced image and YouTube creatives resolved." },
9406
+ { "name": "google_ads_get_video_upload", "purpose": "Read the processing state and YouTube video ID of one Google Ads upload in the selected customer" }
8281
9407
  ],
8282
9408
  resources: [
8283
9409
  "google-ads://manifest",
@@ -8433,7 +9559,7 @@ function registerGoogleAdsResources(server) {
8433
9559
  mimeType: "application/json",
8434
9560
  text: JSON.stringify({
8435
9561
  description: "Google Ads uses GAQL (SQL-like). The FROM clause determines the resource type. Some metrics are restricted to specific resources, and some segments are incompatible with certain metrics (especially impression_share).",
8436
- resourceTypes: ["campaign", "ad_group", "ad_group_ad", "keyword_view", "search_term_view", "paid_organic_search_term_view", "shopping_performance_view", "shopping_product", "asset_group", "asset_group_asset", "asset_group_top_combination_view", "performance_max_placement_view", "campaign_simulation", "ad_group_simulation", "bidding_strategy_simulation"],
9562
+ resourceTypes: ["campaign", "ad_group", "ad_group_ad", "keyword_view", "search_term_view", "paid_organic_search_term_view", "shopping_performance_view", "shopping_product", "asset", "asset_group", "asset_group_asset", "asset_group_top_combination_view", "performance_max_placement_view", "campaign_simulation", "ad_group_simulation", "bidding_strategy_simulation"],
8437
9563
  queryFormat: "SELECT ... FROM resource WHERE ... ORDER BY ... LIMIT N"
8438
9564
  }, null, 2)
8439
9565
  }]
@@ -8441,7 +9567,25 @@ function registerGoogleAdsResources(server) {
8441
9567
  }
8442
9568
 
8443
9569
  // src/platforms/google-ads/writes.ts
8444
- import { z as z6 } from "zod";
9570
+ import { randomUUID } from "crypto";
9571
+ import { z as z8 } from "zod";
9572
+
9573
+ // src/core/money.ts
9574
+ function guard(amount, currency) {
9575
+ if (!Number.isFinite(amount)) throw new Error(`Amount must be a finite number, received ${amount}.`);
9576
+ if (amount <= 0) throw new Error(`Amount must be greater than zero, received ${amount}.`);
9577
+ if (amount > 1e6) {
9578
+ throw new Error(
9579
+ `Amount ${amount} ${currency} is above the safety ceiling of 1,000,000. If this is intentional, make the change in the platform's own interface.`
9580
+ );
9581
+ }
9582
+ return amount;
9583
+ }
9584
+ function toMicros(amount) {
9585
+ return Math.round(guard(amount, "units") * 1e6);
9586
+ }
9587
+
9588
+ // src/platforms/google-ads/writes.ts
8445
9589
  function ok2(data) {
8446
9590
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
8447
9591
  }
@@ -8456,246 +9600,326 @@ function preview(action, details) {
8456
9600
  message: "Preview only, nothing was changed. Repeat the same call with confirm: true to apply this change to the live account."
8457
9601
  });
8458
9602
  }
8459
- var confirmSchema = z6.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
8460
- var loginCustomerIdSchema2 = z6.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise.");
8461
- function toMicros(amount) {
8462
- if (!Number.isFinite(amount) || amount <= 0) {
8463
- throw new Error(`Expected a positive amount, received "${amount}".`);
8464
- }
8465
- return Math.round(amount * 1e6);
8466
- }
8467
- var stripDashes = (id) => String(id).replace(/-/g, "");
8468
- function registerGoogleAdsWrites(server, config) {
8469
- const client = new GoogleAdsClient(config);
8470
- const resourceName = (cid, collection, id) => `customers/${cid}/${collection}/${id}`;
8471
- const statusTool = (name, label, param, collection) => server.tool(
8472
- name,
8473
- `Pause, re-enable or remove a Google Ads ${label}. Previews by default: without confirm: true, the tool describes the change without applying it.`,
8474
- {
8475
- customerId: z6.string().describe("Google Ads customer ID, with or without dashes."),
8476
- [param]: z6.string().describe(`${label} ID.`),
8477
- status: z6.enum(["PAUSED", "ENABLED", "REMOVED"]).describe("New status."),
8478
- loginCustomerId: loginCustomerIdSchema2,
8479
- confirm: confirmSchema
8480
- },
8481
- async (a) => {
8482
- const cid = stripDashes(a.customerId);
8483
- const id = String(a[param]);
8484
- if (!a.confirm) {
8485
- return preview(name, { customer: cid, target: id, newStatus: a.status });
8486
- }
8487
- const result = await client.mutate(cid, collection, [
8488
- { update: { resourceName: resourceName(cid, collection, id), status: a.status }, updateMask: "status" }
8489
- ], a.loginCustomerId);
8490
- return ok2({ applied: true, action: name, result });
9603
+ var confirmSchema = z8.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
9604
+ async function request(url, init, contexte) {
9605
+ const response = await fetch(url, { ...init, redirect: "error" });
9606
+ const body = await response.text();
9607
+ let parsed;
9608
+ try {
9609
+ parsed = JSON.parse(body);
9610
+ } catch {
9611
+ parsed = body;
9612
+ }
9613
+ if (!response.ok) {
9614
+ if (url.startsWith(ADS_BASE + "/")) {
9615
+ const error = parsed?.error;
9616
+ const details = error?.details?.flatMap((d) => d.errors ?? []).map((e) => ({ code: e.errorCode, message: e.message, location: e.location }));
9617
+ throw new Error(`${contexte}: ${JSON.stringify(details?.length ? details : error?.message ?? parsed).slice(0, 3e3)}`);
8491
9618
  }
8492
- );
8493
- statusTool("google_ads_update_campaign_status", "campaign", "campaignId", "campaigns");
8494
- statusTool("google_ads_update_adgroup_status", "ad group", "adGroupId", "adGroups");
8495
- server.tool(
8496
- "google_ads_rename_campaign",
8497
- "Rename a Google Ads campaign. The name is the only thing that changes: delivery, budget and targeting are untouched. Previews by default.",
9619
+ throw new Error(`${contexte} : ${typeof parsed === "string" ? parsed.slice(0, 300) : JSON.stringify(parsed).slice(0, 300)}`);
9620
+ }
9621
+ return parsed;
9622
+ }
9623
+ var ADS_BASE = "https://googleads.googleapis.com/v25";
9624
+ function registerGoogleAdsWrites(c, config) {
9625
+ registerGoogleExtendedWrites(c, config);
9626
+ async function accessToken() {
9627
+ const r = await request(
9628
+ "https://oauth2.googleapis.com/token",
9629
+ {
9630
+ method: "POST",
9631
+ headers: { "content-type": "application/x-www-form-urlencoded" },
9632
+ body: new URLSearchParams({
9633
+ grant_type: "refresh_token",
9634
+ refresh_token: config.refreshToken,
9635
+ client_id: config.clientId,
9636
+ client_secret: config.clientSecret
9637
+ })
9638
+ },
9639
+ "Google token refresh"
9640
+ );
9641
+ return r.access_token;
9642
+ }
9643
+ const headers = async (loginCustomerId) => {
9644
+ const h = {
9645
+ authorization: `Bearer ${await accessToken()}`,
9646
+ "developer-token": config.developerToken,
9647
+ "content-type": "application/json"
9648
+ };
9649
+ const manager = loginCustomerId ?? config.loginCustomerId;
9650
+ if (manager) h["login-customer-id"] = manager.replace(/-/g, "");
9651
+ return h;
9652
+ };
9653
+ c.tool(
9654
+ "google_ads_update_campaign_status",
9655
+ "Pause, re-enable or remove a Google Ads campaign. Previews by default: without confirm: true, the tool describes the change without applying it.",
8498
9656
  {
8499
- customerId: z6.string().describe("Google Ads customer ID, with or without dashes."),
8500
- campaignId: z6.string().describe("Campaign ID."),
8501
- name: z6.string().min(1).max(255).describe("New campaign name."),
8502
- loginCustomerId: loginCustomerIdSchema2,
9657
+ customerId: z8.string().describe("Google Ads customer ID, with or without dashes."),
9658
+ campaignId: z8.string().describe("Campaign ID."),
9659
+ status: z8.enum(["PAUSED", "ENABLED", "REMOVED"]).describe("New status."),
9660
+ loginCustomerId: z8.string().optional().describe("Manager account, if the customer sits under an MCC."),
8503
9661
  confirm: confirmSchema
8504
9662
  },
8505
9663
  async (a) => {
8506
- const cid = stripDashes(a.customerId);
8507
- if (!a.confirm) {
8508
- return preview("google_ads_rename_campaign", { customer: cid, campaign: a.campaignId, newName: a.name });
9664
+ const { customerId, campaignId, status, loginCustomerId, confirm } = a;
9665
+ const cid = String(customerId).replace(/-/g, "");
9666
+ if (!confirm) {
9667
+ return preview("google_ads_update_campaign_status", { customer: cid, campaign: campaignId, newStatus: status });
8509
9668
  }
8510
- const result = await client.mutate(cid, "campaigns", [
8511
- { update: { resourceName: resourceName(cid, "campaigns", a.campaignId), name: a.name }, updateMask: "name" }
8512
- ], a.loginCustomerId);
8513
- return ok2({ applied: true, action: "google_ads_rename_campaign", result });
9669
+ const result2 = await request(
9670
+ `${ADS_BASE}/customers/${cid}/campaigns:mutate`,
9671
+ {
9672
+ method: "POST",
9673
+ headers: await headers(loginCustomerId),
9674
+ body: JSON.stringify({
9675
+ operations: [
9676
+ status === "REMOVED" ? { remove: `customers/${cid}/campaigns/${campaignId}` } : { update: { resourceName: `customers/${cid}/campaigns/${campaignId}`, status }, updateMask: "status" }
9677
+ ]
9678
+ })
9679
+ },
9680
+ "Google Ads status update"
9681
+ );
9682
+ return ok2({ applied: true, action: "google_ads_update_campaign_status", result: result2 });
8514
9683
  }
8515
9684
  );
8516
- server.tool(
9685
+ c.tool(
8517
9686
  "google_ads_create_campaign",
8518
9687
  "Create a Google Ads campaign. It is always created PAUSED and there is no option to create it active: someone has to look at it before it spends. Creates the campaign budget too. Previews by default.",
8519
9688
  {
8520
- customerId: z6.string().describe("Google Ads customer ID, with or without dashes."),
8521
- name: z6.string().min(1).max(255).describe("Campaign name."),
8522
- channelType: z6.enum(["SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"]).describe("Advertising channel."),
8523
- dailyBudget: z6.number().positive().describe("Daily budget in the account currency."),
8524
- budgetName: z6.string().min(1).max(255).describe("Name for the campaign budget. Google requires it to be unique on the account."),
8525
- loginCustomerId: loginCustomerIdSchema2,
9689
+ customerId: z8.string().describe("Google Ads customer ID, with or without dashes."),
9690
+ name: z8.string().describe("Campaign name."),
9691
+ channelType: z8.enum(["SEARCH", "DISPLAY", "SHOPPING", "PERFORMANCE_MAX", "DEMAND_GEN"]).describe("Advertising channel. Classic VIDEO creation is not supported by Google Ads API; Demand Gen is a distinct, supported video-capable campaign type."),
9692
+ dailyBudget: z8.number().positive().describe("Daily budget in the account currency."),
9693
+ loginCustomerId: z8.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
9694
+ brandGuidelinesEnabled: z8.literal(false).optional().describe("For Performance Max, explicitly pass false to create an empty paused campaign without campaign-level brand assets. Brand-enabled creation is not supported by this tool."),
9695
+ euPoliticalAdvertising: z8.boolean().optional().describe("Required for execution: explicitly declare whether this campaign contains EU political advertising."),
9696
+ merchantId: z8.string().regex(/^\d+$/).optional().describe("Merchant Center account ID; required for Shopping."),
9697
+ feedLabel: z8.string().optional().describe("Merchant Center feed label for Shopping."),
9698
+ validateOnly: z8.boolean().optional().describe("Validate the atomic request with Google without creating any resources."),
8526
9699
  confirm: confirmSchema
8527
9700
  },
8528
9701
  async (a) => {
8529
- const cid = stripDashes(a.customerId);
8530
- let micros;
8531
- try {
8532
- micros = toMicros(Number(a.dailyBudget));
8533
- } catch (error) {
8534
- return ko(error instanceof Error ? error.message : String(error));
8535
- }
8536
- if (!a.confirm) {
9702
+ const { customerId, name, channelType, dailyBudget, loginCustomerId, confirm } = a;
9703
+ const cid = String(customerId).replace(/-/g, "");
9704
+ const micros = toMicros(Number(dailyBudget));
9705
+ if (!confirm && !a.validateOnly) {
8537
9706
  return preview("google_ads_create_campaign", {
8538
9707
  customer: cid,
8539
- name: a.name,
8540
- channelType: a.channelType,
8541
- dailyBudget: a.dailyBudget,
9708
+ name,
9709
+ channelType,
9710
+ dailyBudget,
8542
9711
  inMicros: micros,
8543
- budgetName: a.budgetName,
8544
- status: "PAUSED"
9712
+ status: "PAUSED",
9713
+ euPoliticalAdvertising: a.euPoliticalAdvertising ?? "declaration required before execution",
9714
+ brandGuidelinesEnabled: a.brandGuidelinesEnabled,
9715
+ merchantId: a.merchantId,
9716
+ feedLabel: a.feedLabel,
9717
+ biddingStrategy: ["PERFORMANCE_MAX", "DEMAND_GEN"].includes(String(channelType)) ? "MAXIMIZE_CONVERSIONS" : "MANUAL_CPC"
8545
9718
  });
8546
9719
  }
8547
- const login = a.loginCustomerId;
8548
- const budget = await client.mutate(cid, "campaignBudgets", [
8549
- {
8550
- create: {
8551
- name: a.budgetName,
8552
- amountMicros: String(micros),
8553
- deliveryMethod: "STANDARD"
8554
- }
8555
- }
8556
- ], login);
8557
- const budgetResource = budget.results?.[0]?.resourceName;
8558
- if (!budgetResource) {
8559
- return ko("Google Ads did not return a budget resource name, so the campaign was not created.");
8560
- }
8561
- const campaign = await client.mutate(cid, "campaigns", [
8562
- {
8563
- create: {
8564
- name: a.name,
8565
- status: "PAUSED",
8566
- advertisingChannelType: a.channelType,
8567
- campaignBudget: budgetResource,
8568
- manualCpc: {}
8569
- }
8570
- }
8571
- ], login);
9720
+ if (channelType === "VIDEO") throw new Error("Google Ads API cannot create VIDEO campaigns. Use an explicitly requested DEMAND_GEN video campaign through google_ads_apply_plan.");
9721
+ if (typeof a.euPoliticalAdvertising !== "boolean") throw new Error("Declare euPoliticalAdvertising explicitly before creating the campaign.");
9722
+ if (channelType === "PERFORMANCE_MAX" && a.brandGuidelinesEnabled !== false) throw new Error("Performance Max requires explicit brandGuidelinesEnabled: false for empty paused creation; brand assets are not supported by this tool.");
9723
+ if (channelType === "SHOPPING" && !a.merchantId) throw new Error("Shopping requires merchantId for an accessible Merchant Center account.");
9724
+ const budgetName = `customers/${cid}/campaignBudgets/-1`;
9725
+ const campaignData = {
9726
+ name,
9727
+ status: "PAUSED",
9728
+ advertisingChannelType: channelType,
9729
+ campaignBudget: budgetName,
9730
+ containsEuPoliticalAdvertising: a.euPoliticalAdvertising ? "CONTAINS_EU_POLITICAL_ADVERTISING" : "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING",
9731
+ ...["PERFORMANCE_MAX", "DEMAND_GEN"].includes(String(channelType)) ? { maximizeConversions: {}, ...channelType === "PERFORMANCE_MAX" ? { brandGuidelinesEnabled: false } : {} } : { manualCpc: {} },
9732
+ ...channelType === "SHOPPING" ? { shoppingSetting: { merchantId: a.merchantId, campaignPriority: 0, feedLabel: a.feedLabel } } : {}
9733
+ };
9734
+ const result2 = await request(`${ADS_BASE}/customers/${cid}/googleAds:mutate`, {
9735
+ method: "POST",
9736
+ headers: await headers(loginCustomerId ?? config.loginCustomerId),
9737
+ body: JSON.stringify({ partialFailure: false, validateOnly: a.validateOnly === true, mutateOperations: [
9738
+ { campaignBudgetOperation: { create: { resourceName: budgetName, name: `${name} budget ${randomUUID()}`, amountMicros: String(micros), deliveryMethod: "STANDARD", explicitlyShared: false } } },
9739
+ { campaignOperation: { create: campaignData } }
9740
+ ] })
9741
+ }, "Google Ads campaign creation");
8572
9742
  return ok2({
8573
- applied: true,
9743
+ applied: a.validateOnly !== true,
9744
+ validatedByGoogle: a.validateOnly === true,
8574
9745
  action: "google_ads_create_campaign",
8575
9746
  status: "PAUSED",
8576
- budget: budgetResource,
8577
- result: campaign
9747
+ budget: result2.mutateOperationResponses?.[0]?.campaignBudgetResult?.resourceName,
9748
+ result: { results: result2.mutateOperationResponses?.flatMap((r) => r.campaignResult ? [r.campaignResult] : []) ?? [] }
8578
9749
  });
8579
9750
  }
8580
9751
  );
8581
- server.tool(
9752
+ c.tool(
8582
9753
  "google_ads_update_campaign_budget",
8583
9754
  "Change the daily budget of a Google Ads campaign. The amount is in the account currency (12.50 for 12.50 EUR). Previews by default.",
8584
9755
  {
8585
- customerId: z6.string().describe("Google Ads customer ID, with or without dashes."),
8586
- budgetId: z6.string().describe("Campaign budget ID (campaign_budget.id)."),
8587
- dailyAmount: z6.number().positive().describe("New daily budget, in the account currency."),
8588
- loginCustomerId: loginCustomerIdSchema2,
9756
+ customerId: z8.string().describe("Google Ads customer ID, with or without dashes."),
9757
+ budgetId: z8.string().describe("Campaign budget ID (campaign_budget.id)."),
9758
+ dailyAmount: z8.number().positive().describe("New daily budget, in the account currency."),
9759
+ loginCustomerId: z8.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
8589
9760
  confirm: confirmSchema
8590
9761
  },
8591
9762
  async (a) => {
8592
- const cid = stripDashes(a.customerId);
8593
- let micros;
8594
- try {
8595
- micros = toMicros(Number(a.dailyAmount));
8596
- } catch (error) {
8597
- return ko(error instanceof Error ? error.message : String(error));
8598
- }
8599
- if (!a.confirm) {
9763
+ const { customerId, budgetId, dailyAmount, loginCustomerId, confirm } = a;
9764
+ const cid = String(customerId).replace(/-/g, "");
9765
+ const micros = toMicros(Number(dailyAmount));
9766
+ if (!confirm) {
8600
9767
  return preview("google_ads_update_campaign_budget", {
8601
9768
  customer: cid,
8602
- budget: a.budgetId,
8603
- newDailyBudget: a.dailyAmount,
9769
+ budget: budgetId,
9770
+ newDailyBudget: dailyAmount,
8604
9771
  inMicros: micros
8605
9772
  });
8606
9773
  }
8607
- const result = await client.mutate(cid, "campaignBudgets", [
9774
+ const result2 = await request(
9775
+ `${ADS_BASE}/customers/${cid}/campaignBudgets:mutate`,
8608
9776
  {
8609
- update: {
8610
- resourceName: resourceName(cid, "campaignBudgets", a.budgetId),
8611
- amountMicros: String(micros)
8612
- },
8613
- updateMask: "amount_micros"
8614
- }
8615
- ], a.loginCustomerId);
8616
- return ok2({ applied: true, action: "google_ads_update_campaign_budget", result });
9777
+ method: "POST",
9778
+ headers: await headers(loginCustomerId),
9779
+ body: JSON.stringify({
9780
+ operations: [
9781
+ {
9782
+ update: { resourceName: `customers/${cid}/campaignBudgets/${budgetId}`, amountMicros: String(micros) },
9783
+ updateMask: "amount_micros"
9784
+ }
9785
+ ]
9786
+ })
9787
+ },
9788
+ "Google Ads budget update"
9789
+ );
9790
+ return ok2({ applied: true, action: "google_ads_update_campaign_budget", result: result2 });
8617
9791
  }
8618
9792
  );
8619
- server.tool(
8620
- "google_ads_update_adgroup_bid",
8621
- "Change the default CPC bid of a Google Ads ad group. The amount is in the account currency (1.20 for 1.20 EUR). Has no effect on a campaign using an automated bidding strategy. Previews by default.",
9793
+ c.tool(
9794
+ "google_ads_rename_campaign",
9795
+ "Rename a Google Ads campaign. The name is the only thing that changes: delivery, budget and targeting are untouched. Previews by default.",
8622
9796
  {
8623
- customerId: z6.string().describe("Google Ads customer ID, with or without dashes."),
8624
- adGroupId: z6.string().describe("Ad group ID."),
8625
- cpcBid: z6.number().positive().describe("New default CPC bid, in the account currency."),
8626
- loginCustomerId: loginCustomerIdSchema2,
9797
+ customerId: z8.string().describe("Google Ads customer ID, with or without dashes."),
9798
+ campaignId: z8.string().describe("Campaign ID."),
9799
+ name: z8.string().min(1).max(255).describe("New campaign name."),
9800
+ loginCustomerId: z8.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
8627
9801
  confirm: confirmSchema
8628
9802
  },
8629
9803
  async (a) => {
8630
- const cid = stripDashes(a.customerId);
8631
- let micros;
8632
- try {
8633
- micros = toMicros(Number(a.cpcBid));
8634
- } catch (error) {
8635
- return ko(error instanceof Error ? error.message : String(error));
8636
- }
8637
- if (!a.confirm) {
8638
- return preview("google_ads_update_adgroup_bid", {
8639
- customer: cid,
8640
- adGroup: a.adGroupId,
8641
- newCpcBid: a.cpcBid,
8642
- inMicros: micros
8643
- });
8644
- }
8645
- const result = await client.mutate(cid, "adGroups", [
9804
+ const { customerId, campaignId, name, loginCustomerId, confirm } = a;
9805
+ const cid = String(customerId).replace(/-/g, "");
9806
+ if (!confirm) return preview("google_ads_rename_campaign", { customer: cid, campaign: campaignId, newName: name });
9807
+ const result2 = await request(
9808
+ `${ADS_BASE}/customers/${cid}/campaigns:mutate`,
8646
9809
  {
8647
- update: {
8648
- resourceName: resourceName(cid, "adGroups", a.adGroupId),
8649
- cpcBidMicros: String(micros)
8650
- },
8651
- updateMask: "cpc_bid_micros"
8652
- }
8653
- ], a.loginCustomerId);
8654
- return ok2({ applied: true, action: "google_ads_update_adgroup_bid", result });
9810
+ method: "POST",
9811
+ headers: await headers(loginCustomerId),
9812
+ body: JSON.stringify({
9813
+ operations: [{ update: { resourceName: `customers/${cid}/campaigns/${campaignId}`, name }, updateMask: "name" }]
9814
+ })
9815
+ },
9816
+ "Google Ads rename"
9817
+ );
9818
+ return ok2({ applied: true, action: "google_ads_rename_campaign", result: result2 });
8655
9819
  }
8656
9820
  );
8657
- server.tool(
9821
+ c.tool(
8658
9822
  "google_ads_update_campaign_schedule",
8659
9823
  "Change the start or end date of a Google Ads campaign. Dates are YYYY-MM-DD in the account time zone. Previews by default.",
8660
9824
  {
8661
- customerId: z6.string().describe("Google Ads customer ID, with or without dashes."),
8662
- campaignId: z6.string().describe("Campaign ID."),
8663
- startDate: z6.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Start date, YYYY-MM-DD, in the account time zone."),
8664
- endDate: z6.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("End date, YYYY-MM-DD. Use 2037-12-30 to mean no end date, which is what Google stores."),
8665
- loginCustomerId: loginCustomerIdSchema2,
9825
+ customerId: z8.string().describe("Google Ads customer ID, with or without dashes."),
9826
+ campaignId: z8.string().describe("Campaign ID."),
9827
+ startDate: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Start date, YYYY-MM-DD, in the account time zone."),
9828
+ endDate: z8.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Use 2037-12-30 to clear the end date (run indefinitely)."),
9829
+ loginCustomerId: z8.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
8666
9830
  confirm: confirmSchema
8667
9831
  },
8668
9832
  async (a) => {
8669
- const { startDate, endDate } = a;
9833
+ const { customerId, campaignId, startDate, endDate, loginCustomerId, confirm } = a;
8670
9834
  if (!startDate && !endDate) return ko("Provide startDate, endDate, or both.");
8671
- if (startDate && endDate && startDate > endDate) {
9835
+ for (const date of [startDate, endDate]) {
9836
+ if (date && (Number.isNaN(Date.parse(String(date))) || new Date(String(date)).toISOString().slice(0, 10) !== date)) return ko("Invalid calendar date.");
9837
+ }
9838
+ if (startDate && endDate && String(startDate) > String(endDate)) {
8672
9839
  return ko(`startDate ${startDate} is after endDate ${endDate}.`);
8673
9840
  }
8674
- const cid = stripDashes(a.customerId);
8675
- const update = {
8676
- resourceName: resourceName(cid, "campaigns", a.campaignId)
8677
- };
9841
+ const cid = String(customerId).replace(/-/g, "");
9842
+ const update = { resourceName: `customers/${cid}/campaigns/${campaignId}` };
8678
9843
  const mask = [];
8679
9844
  if (startDate) {
8680
- update.startDate = startDate;
8681
- mask.push("start_date");
9845
+ update.startDateTime = `${startDate} 00:00:00`;
9846
+ mask.push("start_date_time");
8682
9847
  }
8683
9848
  if (endDate) {
8684
- update.endDate = endDate;
8685
- mask.push("end_date");
8686
- }
8687
- if (!a.confirm) {
8688
- return preview("google_ads_update_campaign_schedule", {
8689
- customer: cid,
8690
- campaign: a.campaignId,
8691
- startDate,
8692
- endDate
8693
- });
9849
+ if (endDate !== "2037-12-30") update.endDateTime = `${endDate} 23:59:59`;
9850
+ mask.push("end_date_time");
8694
9851
  }
8695
- const result = await client.mutate(cid, "campaigns", [
8696
- { update, updateMask: mask.join(",") }
8697
- ], a.loginCustomerId);
8698
- return ok2({ applied: true, action: "google_ads_update_campaign_schedule", result });
9852
+ if (!confirm) return preview("google_ads_update_campaign_schedule", { customer: cid, campaign: campaignId, startDate, endDate });
9853
+ const result2 = await request(
9854
+ `${ADS_BASE}/customers/${cid}/campaigns:mutate`,
9855
+ {
9856
+ method: "POST",
9857
+ headers: await headers(loginCustomerId),
9858
+ body: JSON.stringify({ operations: [{ update, updateMask: mask.join(",") }] })
9859
+ },
9860
+ "Google Ads schedule update"
9861
+ );
9862
+ return ok2({ applied: true, action: "google_ads_update_campaign_schedule", result: result2 });
9863
+ }
9864
+ );
9865
+ c.tool(
9866
+ "google_ads_update_adgroup_status",
9867
+ "Pause, re-enable or remove a Google Ads ad group. Previews by default.",
9868
+ {
9869
+ customerId: z8.string().describe("Google Ads customer ID, with or without dashes."),
9870
+ adGroupId: z8.string().describe("Ad group ID."),
9871
+ status: z8.enum(["PAUSED", "ENABLED", "REMOVED"]).describe("New status."),
9872
+ loginCustomerId: z8.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
9873
+ confirm: confirmSchema
9874
+ },
9875
+ async (a) => {
9876
+ const { customerId, adGroupId, status, loginCustomerId, confirm } = a;
9877
+ const cid = String(customerId).replace(/-/g, "");
9878
+ if (!confirm) return preview("google_ads_update_adgroup_status", { customer: cid, adGroup: adGroupId, newStatus: status });
9879
+ const result2 = await request(
9880
+ `${ADS_BASE}/customers/${cid}/adGroups:mutate`,
9881
+ {
9882
+ method: "POST",
9883
+ headers: await headers(loginCustomerId),
9884
+ body: JSON.stringify({
9885
+ operations: [status === "REMOVED" ? { remove: `customers/${cid}/adGroups/${adGroupId}` } : { update: { resourceName: `customers/${cid}/adGroups/${adGroupId}`, status }, updateMask: "status" }]
9886
+ })
9887
+ },
9888
+ "Google Ads ad group status"
9889
+ );
9890
+ return ok2({ applied: true, action: "google_ads_update_adgroup_status", result: result2 });
9891
+ }
9892
+ );
9893
+ c.tool(
9894
+ "google_ads_update_adgroup_bid",
9895
+ "Change the default CPC bid of a Google Ads ad group. The amount is in the account currency (1.20 for 1.20 EUR). Has no effect on a campaign using an automated bidding strategy. Previews by default.",
9896
+ {
9897
+ customerId: z8.string().describe("Google Ads customer ID, with or without dashes."),
9898
+ adGroupId: z8.string().describe("Ad group ID."),
9899
+ cpcBid: z8.number().positive().describe("New default CPC bid, in the account currency."),
9900
+ loginCustomerId: z8.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
9901
+ confirm: confirmSchema
9902
+ },
9903
+ async (a) => {
9904
+ const { customerId, adGroupId, cpcBid, loginCustomerId, confirm } = a;
9905
+ const cid = String(customerId).replace(/-/g, "");
9906
+ const micros = toMicros(Number(cpcBid));
9907
+ if (!confirm) return preview("google_ads_update_adgroup_bid", { customer: cid, adGroup: adGroupId, newCpcBid: cpcBid, inMicros: micros });
9908
+ const result2 = await request(
9909
+ `${ADS_BASE}/customers/${cid}/adGroups:mutate`,
9910
+ {
9911
+ method: "POST",
9912
+ headers: await headers(loginCustomerId),
9913
+ body: JSON.stringify({
9914
+ operations: [{
9915
+ update: { resourceName: `customers/${cid}/adGroups/${adGroupId}`, cpcBidMicros: String(micros) },
9916
+ updateMask: "cpc_bid_micros"
9917
+ }]
9918
+ })
9919
+ },
9920
+ "Google Ads ad group bid"
9921
+ );
9922
+ return ok2({ applied: true, action: "google_ads_update_adgroup_bid", result: result2 });
8699
9923
  }
8700
9924
  );
8701
9925
  }
@@ -8703,21 +9927,23 @@ function registerGoogleAdsWrites(server, config) {
8703
9927
  // src/platforms/google-ads/index.ts
8704
9928
  function registerGoogleAds(server, config) {
8705
9929
  registerGoogleAdsTools(server, config);
9930
+ registerGoogleVideoReads(server, config);
8706
9931
  registerGoogleAdsResources(server);
8707
- logger.info("google-ads", "Registered 31 read tools and 5 resources");
9932
+ logger.info("google-ads", "Registered 35 read tools and 5 resources");
8708
9933
  if (config.enableWrites) {
8709
9934
  registerGoogleAdsWrites(server, config);
8710
- logger.info("google-ads", "Registered 7 write tools (every one previews before it applies)");
9935
+ logger.info("google-ads", "Registered 10 write tools (every one previews before it applies)");
8711
9936
  }
8712
9937
  }
8713
9938
 
8714
9939
  // src/server.ts
8715
- var PACKAGE_VERSION = "1.0.0";
9940
+ var PACKAGE_VERSION = "2.0.0";
8716
9941
  function createServer(config) {
8717
9942
  const server = new McpServer(
8718
- { name: "google-ads-mcp", version: PACKAGE_VERSION },
9943
+ { name: "google-ads-mcp", version: PACKAGE_VERSION, title: "Google Ads", websiteUrl: "https://www.getmcpads.com/tools/google-ads", icons: [{ src: "https://mcp.getmcpads.com/icon.svg", mimeType: "image/svg+xml" }] },
8719
9944
  { capabilities: { tools: { listChanged: true }, resources: { subscribe: false, listChanged: true } } }
8720
9945
  );
9946
+ installToolQuality(server);
8721
9947
  registerGoogleAds(server, config);
8722
9948
  logger.system(
8723
9949
  `google-ads-mcp v${PACKAGE_VERSION} ready, writes ${config.enableWrites ? "enabled" : "disabled"}`