@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/index.js CHANGED
@@ -1,8 +1,822 @@
1
+ // src/parameter-descriptions.ts
2
+ var PARAMETER_DESCRIPTIONS = {
3
+ "filters": "Filters combined according to this tool's query schema. Use provider field names and the declared operators.",
4
+ "searchType": "Search surface to report, such as web, image, video, news or Discover. Availability depends on the property.",
5
+ "searchTypes": "Search surfaces to compare. Each surface is queried separately; do not sum overlapping surfaces.",
6
+ "datePreset": "Named reporting period. Use the custom date range when you need exact start and end dates.",
7
+ "dateRange": "Explicit reporting start and end dates in YYYY-MM-DD format.",
8
+ "timeRange": "Explicit provider reporting period with since and until dates.",
9
+ "startDate": "First date of the reporting period, in YYYY-MM-DD format.",
10
+ "endDate": "Last date of the reporting period, in YYYY-MM-DD format, on or after startDate.",
11
+ "since": "Beginning of the requested reporting period; use the format accepted by the provider.",
12
+ "until": "End of the requested reporting period; use the format accepted by the provider.",
13
+ "orderBy": "Field used to order report rows.",
14
+ "orderField": "Provider field used to order report rows.",
15
+ "orderDirection": "Sort direction for the selected ordering field.",
16
+ "orderType": "Sort direction for the selected ordering field.",
17
+ "sortBy": "Metric or dimension used to rank the returned results.",
18
+ "sort": "Native provider ordering expressions.",
19
+ "limit": "Maximum number of returned items or rows. The declared bounds and default apply; use pagination for additional results.",
20
+ "topN": "Maximum number of highest-ranked results to return.",
21
+ "pageSize": "Maximum items requested per page. Follow the returned pagination information for remaining results.",
22
+ "page": "Page number to request, starting at 1.",
23
+ "offset": "Zero-based number of rows to skip before returning results.",
24
+ "bookmark": "Opaque Pinterest pagination bookmark returned by the previous response. Omit on the first request.",
25
+ "after": "Opaque cursor for the next page returned by Meta.",
26
+ "before": "Opaque cursor for the previous page returned by Meta.",
27
+ "includeUnchanged": "Include entries with no observed change in the comparison.",
28
+ "minImpressions": "Minimum reported impressions required for a row to enter the analysis.",
29
+ "objectives": "Analysis goals that determine the recommended large-site sampling strategy.",
30
+ "pageLimit": "Maximum number of pages considered for the sampling plan.",
31
+ "maxInspectionUrls": "Maximum URL Inspection candidates included in the plan; inspection calls consume provider quota.",
32
+ "minImpressionsForLowCtr": "Minimum impressions before a page is considered for low-CTR analysis.",
33
+ "staleSitemapDays": "Number of days after which a sitemap's last download is considered stale.",
34
+ "continueOnError": "Continue with other URLs when one inspection fails, returning individual failures.",
35
+ "dimension": "Reporting dimension used to group and compare results.",
36
+ "maxClusters": "Maximum query clusters to return.",
37
+ "topQueriesPerCluster": "Maximum example queries returned for each cluster.",
38
+ "minPages": "Minimum distinct pages ranking for a query before it is considered a cannibalization candidate.",
39
+ "adAccountId": "Owning advertising account ID. Use the exact ID returned by account discovery; do not substitute a campaign or business ID.",
40
+ "advertiserId": "TikTok advertiser account ID as a string.",
41
+ "customerId": "Google Ads customer ID, without hyphens.",
42
+ "propertyId": "Google Analytics property ID; use the property, not the account or measurement ID.",
43
+ "level": "Entity or aggregation level for the requested report.",
44
+ "dataLevel": "TikTok reporting aggregation level. Metrics and dimensions must be compatible with this level.",
45
+ "granularity": "Time bucket used to aggregate reporting rows.",
46
+ "columns": "Native Pinterest reporting column names. Discover supported columns before selecting metrics.",
47
+ "executionMode": "Choose the supported synchronous, asynchronous or automatic report execution strategy.",
48
+ "entityIds": "Exact IDs of the entities to include in the selected reporting level.",
49
+ "targetingTypes": "Native targeting categories used to break down delivery.",
50
+ "clickWindowDays": "Click-through attribution window, in days.",
51
+ "engagementWindowDays": "Engagement-through attribution window, in days.",
52
+ "viewWindowDays": "View-through attribution window, in days.",
53
+ "conversionReportTime": "Whether conversions are assigned to the ad action date or the conversion date.",
54
+ "attributionTypes": "Provider attribution categories included in the report.",
55
+ "reportingTimezone": "Provider reporting timezone selector. Keep it consistent when comparing periods.",
56
+ "campaignIds": "Restrict results to these campaign IDs.",
57
+ "adGroupIds": "Restrict results to these ad group IDs.",
58
+ "adgroupIds": "Restrict TikTok results to these ad group IDs.",
59
+ "adIds": "Restrict results to these ad IDs.",
60
+ "campaignId": "Exact campaign ID in the selected advertising account.",
61
+ "adGroupId": "Exact ad group ID in the selected advertising account.",
62
+ "adSetId": "Exact Meta ad set ID in the selected advertising account.",
63
+ "adId": "Exact ad ID in the selected advertising account.",
64
+ "entityId": "Exact ID of the entity selected by this operation. It must belong to the selected account.",
65
+ "onlyWithPeriodDelivery": "Keep only creatives with observed delivery in the requested date range.",
66
+ "breakdown": "Native reporting breakdown used to group results.",
67
+ "breakdowns": "Native reporting breakdowns. Check compatibility before combining them.",
68
+ "productGroupIds": "Restrict reporting to these product group IDs.",
69
+ "productItemIds": "Restrict reporting to these product item IDs.",
70
+ "includeProductSamples": "Include a limited sample of product records alongside inventory diagnostics.",
71
+ "sampleProductGroups": "Maximum product groups to sample for inventory diagnostics.",
72
+ "reportName": "Provider report name identifying the requested report recipe.",
73
+ "conversionProductAttributionType": "Pinterest product-conversion attribution category.",
74
+ "conversionProductBreakdown": "Pinterest product-conversion grouping level.",
75
+ "productSkuIds": "Restrict product-conversion reporting to these SKU IDs.",
76
+ "request": "Exact request schema name to inspect before composing a write payload.",
77
+ "entity": "Entity collection or level to inspect.",
78
+ "entityType": "Provider entity type to retrieve.",
79
+ "entityStatuses": "Native status values used to filter the entity collection.",
80
+ "statusFilter": "Native status values used to filter the returned entities.",
81
+ "targetingType": "Targeting dictionary to inspect, such as interests, locations or demographics.",
82
+ "interestId": "Native interest ID for the targeting lookup.",
83
+ "mode": "Operation or analysis mode. Only the values declared in this schema are supported.",
84
+ "matchTypes": "Keyword match types included in the lookup.",
85
+ "countryCode": "Country code used to localize keyword or targeting results.",
86
+ "keywords": "Keyword phrases to analyze; use the language and market of the intended audience.",
87
+ "term": "Search term used for the keyword lookup.",
88
+ "terms": "Search terms used for the keyword lookup.",
89
+ "audienceId": "Exact ID of the audience to inspect or modify.",
90
+ "customerListId": "Exact customer-list ID. Reading metadata does not upload customer data.",
91
+ "businessId": "Exact business account ID used for the lookup.",
92
+ "accountType": "Provider account category used to filter results.",
93
+ "ownershipType": "Filter audiences by provider ownership category.",
94
+ "excludeNca": "Whether to exclude Pinterest new-customer-acquisition audiences from this lookup.",
95
+ "audienceInsightType": "Pinterest audience-insights category to request.",
96
+ "surfaces": "Conversion-setup surfaces to inspect, according to the supported values.",
97
+ "includeDeletedTags": "Include deleted conversion tags in diagnostics.",
98
+ "lookbackPeriod": "Provider lookback period used for conversion diagnostics.",
99
+ "sourcePlatform": "Filter conversion data by source platform.",
100
+ "ingestionSource": "Filter conversion events by ingestion source.",
101
+ "catalogId": "Exact product catalog ID accessible to the selected account.",
102
+ "feedId": "Exact catalog feed ID.",
103
+ "processingResultId": "Exact catalog feed processing result ID to inspect.",
104
+ "productGroupId": "Exact product group ID.",
105
+ "productSetId": "Exact Meta product set ID belonging to the selected catalog.",
106
+ "exportType": "Provider export category to request.",
107
+ "action": "Supported operation within this tool's scoped provider surface.",
108
+ "includeDetails": "Include detailed provider records in addition to summary information.",
109
+ "leadFormId": "Exact lead form ID to inspect.",
110
+ "subscriptionId": "Exact subscription ID to inspect.",
111
+ "assetId": "Exact provider asset ID.",
112
+ "memberId": "Exact business member ID used for the read lookup.",
113
+ "partnerId": "Exact business partner ID used for the read lookup.",
114
+ "pinId": "Exact Pinterest pin ID.",
115
+ "pinIds": "Exact Pinterest pin IDs to include.",
116
+ "metrics": "Native or documented calculated metric names to request. Check compatibility with the selected dimensions.",
117
+ "metric": "Metric used for the analysis; use a name from the platform metric catalogue.",
118
+ "boardId": "Exact Pinterest board ID.",
119
+ "searchTerm": "Text used to filter or search the selected collection.",
120
+ "region": "Provider region selector used to localize trends.",
121
+ "productRegion": "Region used for product trend analysis.",
122
+ "trendType": "Provider trend category to return.",
123
+ "interests": "Native interest categories used to filter trends.",
124
+ "genders": "Native gender categories used to filter aggregate trends.",
125
+ "ageBuckets": "Native age groups used to filter aggregate trends.",
126
+ "includeKeywords": "Include related keyword details in the trend response.",
127
+ "includeDemographics": "Include available aggregate demographic breakdowns.",
128
+ "productCategories": "Native product category IDs or values used to filter trends.",
129
+ "verticals": "Native business verticals used to filter trends.",
130
+ "productLookbackDays": "Product-trend lookback window, in days.",
131
+ "productEngagementType": "Engagement signal used for product-trend analysis.",
132
+ "featuredInterest": "Featured interest category for the trends lookup.",
133
+ "reportType": "Native provider report type; determines supported metrics and dimensions.",
134
+ "promotionId": "Exact Pinterest product-group promotion ID.",
135
+ "type": "Provider object or operation type selected from this schema's allowed values.",
136
+ "productBreakdown": "Native product grouping used to join catalog items with reported insights.",
137
+ "catalogProductLimit": "Maximum catalog product records read for the join.",
138
+ "includeAdsets": "Include ad set configuration when checking brand safety.",
139
+ "includeBlockLists": "Include accessible brand-safety block-list metadata.",
140
+ "includeRawTargeting": "Include the native targeting specification for diagnostics.",
141
+ "cellEntityType": "Entity type represented by experiment cells.",
142
+ "includePagePosts": "Read related Facebook Page posts when the token has permission.",
143
+ "includeInstagramMedia": "Read related Instagram media when the token has permission.",
144
+ "includeInsights": "Include available insight metrics; additional provider permissions may be required.",
145
+ "edge": "Allowlisted Meta Graph edge to read on the selected node.",
146
+ "fields": "Native provider fields to return. Select only fields supported by the chosen object or report.",
147
+ "filtering": "Native provider filter expressions; credentials and account overrides are not allowed.",
148
+ "includeSummary": "Request the provider's available summary or total-count metadata.",
149
+ "actionBreakdowns": "Meta action-level breakdowns, subject to Insights compatibility rules.",
150
+ "actionAttributionWindows": "Meta attribution windows used to attribute action metrics.",
151
+ "timeIncrement": "Provider time-bucket size or supported aggregate value.",
152
+ "locationTypes": "Location dictionary categories to include in targeting search.",
153
+ "adFormat": "Native Meta ad preview format; must be compatible with this ad's creative.",
154
+ "handle": "Asynchronous catalog batch handle returned by the submission to inspect.",
155
+ "keepEmptyRows": "Keep provider rows whose selected metrics are all zero, where supported.",
156
+ "sampleLimit": "Maximum records included in the diagnostic sample.",
157
+ "isOpenFunnel": "Allow users to enter at any funnel step instead of requiring the first step.",
158
+ "visualizationType": "Supported funnel visualization mode.",
159
+ "breakdownLimit": "Maximum breakdown values to include in the funnel response.",
160
+ "nextActionLimit": "Maximum next actions returned for each funnel step.",
161
+ "fallbackToStepCounts": "If the advanced funnel is unavailable, return independent step counts with the limitation made explicit.",
162
+ "report": "Native GA4 report request object for this endpoint.",
163
+ "reports": "Native GA4 report request objects to execute in one batch.",
164
+ "dimensions": "Native reporting dimensions. Validate compatibility with the selected metrics.",
165
+ "compatibilityFilter": "Limit compatibility results to the requested GA4 compatibility category.",
166
+ "collection": "Allowlisted administrative resource collection to read.",
167
+ "settings": "Property configuration surfaces to inspect.",
168
+ "includeRecurring": "Include recurring audience-export configurations.",
169
+ "audienceExportName": "Full GA4 audience-export resource name returned by list_audience_exports.",
170
+ "queryLifetime": "Request lifetime reporting instead of the explicit reporting period where TikTok supports it.",
171
+ "declineThresholdPct": "Percentage decline used as a candidate signal for creative fatigue, requiring further evidence.",
172
+ "includeSavedAudiences": "Include accessible saved-audience metadata in the overlap analysis.",
173
+ "overlapThreshold": "Overlap threshold used to flag audience pairs, in the units declared by this schema.",
174
+ "serviceType": "TikTok report service category.",
175
+ "catalog": "Allowlisted targeting dictionary or catalog to inspect.",
176
+ "query": "Search text used to find matching entries in the selected catalogue.",
177
+ "parameters": "Native query parameters for this allowlisted read endpoint. Do not provide credentials or account overrides.",
178
+ "account_id": "Exact account ID selected for this platform in the GetMCPAds workspace.",
179
+ "platform": "Platform key identifying the connected advertising or analytics source.",
180
+ "id": "Exact saved object ID returned by the corresponding create or list tool.",
181
+ "revision": "Last known object revision, used to reject conflicting updates. Read the object before editing.",
182
+ "name": "Human-readable name for this object.",
183
+ "title": "Human-readable title for the resulting object or review.",
184
+ "clientLabel": "Display label identifying the client in the saved view.",
185
+ "config": "Complete saved-view configuration, including source, selected account, period and display options.",
186
+ "subject": "Entity or creative subject whose dated performance will be analyzed.",
187
+ "compare_previous": "Include the immediately preceding period of equal duration for comparison.",
188
+ "profile": "Creative brief profile containing the client's business context and analysis preferences.",
189
+ "primaryMetric": "Metric used to rank or evaluate the review; it must be available for the selected platform.",
190
+ "direction": "Whether higher or lower values of the primary metric represent improvement.",
191
+ "creative_ids": "Exact creative IDs to include in this review.",
192
+ "request_id": "Caller-generated request identifier. Reuse it for a retry of the same operation to avoid duplicate work.",
193
+ "expiresInDays": "Number of days before the public review sharing link expires.",
194
+ "client_id": "Exact saved business-client workspace ID.",
195
+ "review_id": "Exact review ID returned by the review creation or listing tool.",
196
+ "page_id": "Exact page identifier within the landing-page review.",
197
+ "entity_id": "Exact entity identifier in the selected review or platform context.",
198
+ "source_key": "Source identifier returned by the business review; keep the platform/account association intact.",
199
+ "metric_key": "Exact metric key in the review evidence.",
200
+ "follow_up_on": "Follow-up date for the decision, using the format declared in this schema.",
201
+ "hypothesis": "Specific hypothesis to record, supported by the review evidence.",
202
+ "successCriterion": "Measurable condition used to decide whether the recorded action succeeded.",
203
+ "outcome": "Observed outcome to record; distinguish evidence from an untested expectation.",
204
+ "status": "Status selected from the allowed values for this object or operation.",
205
+ "retry_source": "Source identifier to retry after reviewing its reported error.",
206
+ "site_origin": "Website origin to analyze, including https:// and hostname, without credentials or a path.",
207
+ "matchType": "Google Ads keyword match type for the forecast.",
208
+ "negativeKeywords": "Keyword phrases excluded from the keyword forecast.",
209
+ "negativeMatchType": "Match type applied to the negative keywords.",
210
+ "geoTargetIds": "Google Ads geo-target constant IDs for the intended locations.",
211
+ "languageId": "Google Ads language constant ID for the keyword request.",
212
+ "languageIds": "Google Ads language constant IDs for the intended audience.",
213
+ "network": "Google Ads search network selector used for planning.",
214
+ "biddingStrategy": "Supported bidding strategy for this temporary forecast. The request does not create a live campaign.",
215
+ "includeAdultKeywords": "Allow adult keyword ideas where supported by the provider and the selected market.",
216
+ "seedKeywords": "Seed keyword phrases from which Google generates keyword ideas.",
217
+ "includeAverageCpc": "Request average cost-per-click metrics when available.",
218
+ "includeDeviceBreakdown": "Include device-specific planning metrics when supported.",
219
+ "includeKeywordConcepts": "Include Google's keyword concept grouping metadata.",
220
+ "startYearMonth": "First year and month of the historical metrics period.",
221
+ "endYearMonth": "Last year and month of the historical metrics period.",
222
+ "includeDismissed": "Include recommendations already dismissed in the account.",
223
+ "operation": "Exact allowlisted operation to execute. Arbitrary provider operations are not accepted.",
224
+ "category": "Category selected from the provider dictionary or field catalogue.",
225
+ "selectable": "Filter the field catalogue by whether the field can appear in SELECT.",
226
+ "filterable": "Filter the field catalogue by whether the field can appear in WHERE.",
227
+ "sortable": "Filter the field catalogue by whether the field can appear in ORDER BY.",
228
+ "pageToken": "Opaque continuation token returned by the preceding provider response.",
229
+ "locationNames": "Human-readable location names for Google to resolve into geo-target constants.",
230
+ "locale": "Locale used for human-readable lookup results.",
231
+ "resource": "Native Google Ads resource from which the GAQL query selects rows.",
232
+ "cursor": "Opaque pagination cursor returned by the preceding response; omit for the first page.",
233
+ "kind": "Entity or request kind whose schema or data is being requested.",
234
+ "startTime": "Beginning of the reporting period as an ISO 8601 timestamp with an explicit timezone.",
235
+ "endTime": "End of the reporting period as an ISO 8601 timestamp with an explicit timezone, after startTime.",
236
+ "reportDimension": "Native dimension used to break down the Snapchat report."
237
+ };
238
+
239
+ // src/tool-quality.ts
240
+ import { z } from "zod";
241
+ var RESULT_SCHEMA = {
242
+ type: "object",
243
+ properties: {
244
+ result: {
245
+ 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.",
246
+ type: ["object", "array", "string", "number", "boolean", "null"]
247
+ }
248
+ },
249
+ required: ["result"],
250
+ additionalProperties: true
251
+ };
252
+ function toolAnnotations(write) {
253
+ return { readOnlyHint: !write, destructiveHint: write, idempotentHint: !write, openWorldHint: true };
254
+ }
255
+ function structuredResult(value) {
256
+ if (value.isError) return value;
257
+ let result2 = value.content;
258
+ const first = value.content[0];
259
+ if (value.content.length === 1 && first?.type === "text" && typeof first.text === "string") {
260
+ result2 = first.text;
261
+ try {
262
+ result2 = JSON.parse(first.text);
263
+ } catch {
264
+ }
265
+ }
266
+ return { ...value, structuredContent: { ...value.structuredContent, result: result2 } };
267
+ }
268
+ var resultShape = { result: z.union([z.object({}).passthrough(), z.array(z.unknown()), z.string(), z.number(), z.boolean(), z.null()]).describe(RESULT_SCHEMA.properties.result.description) };
269
+ function installToolQuality(server) {
270
+ server.tool = ((name, description, inputSchema, handler) => {
271
+ const write = Object.hasOwn(inputSchema, "confirm");
272
+ inputSchema = Object.fromEntries(Object.entries(inputSchema).map(([key, schema]) => [key, schema.description || PARAMETER_DESCRIPTIONS[key] ? schema.describe(schema.description || PARAMETER_DESCRIPTIONS[key]) : schema]));
273
+ return server.registerTool(name, {
274
+ title: name.replace(/_/g, " "),
275
+ description,
276
+ inputSchema,
277
+ outputSchema: resultShape,
278
+ annotations: toolAnnotations(write)
279
+ }, async (...args) => structuredResult(await handler(...args)));
280
+ });
281
+ }
282
+
1
283
  // src/server.ts
2
284
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
285
 
286
+ // src/platforms/google-ads/extended-writes.ts
287
+ import { webcrypto } from "crypto";
288
+ import { z as z2 } from "zod";
289
+ var BASE = "https://googleads.googleapis.com/v25";
290
+ var resources = {
291
+ conversionActionOperation: "conversionActions",
292
+ campaignBudgetOperation: "campaignBudgets",
293
+ campaignOperation: "campaigns",
294
+ adGroupOperation: "adGroups",
295
+ adGroupAdOperation: "adGroupAds",
296
+ adOperation: "ads",
297
+ assetOperation: "assets",
298
+ assetGroupOperation: "assetGroups",
299
+ assetGroupAssetOperation: "assetGroupAssets",
300
+ campaignAssetOperation: "campaignAssets",
301
+ campaignCriterionOperation: "campaignCriteria",
302
+ adGroupCriterionOperation: "adGroupCriteria",
303
+ assetGroupListingGroupFilterOperation: "assetGroupListingGroupFilters"
304
+ };
305
+ var json = z2.record(z2.unknown());
306
+ var operation = z2.object({
307
+ kind: z2.enum(
308
+ Object.keys(resources)
309
+ ),
310
+ create: json.optional(),
311
+ update: json.optional(),
312
+ remove: z2.string().optional(),
313
+ updateMask: z2.string().min(1).optional()
314
+ }).strict();
315
+ var result = (v) => ({
316
+ content: [{ type: "text", text: JSON.stringify(v, null, 2) }]
317
+ });
318
+ function canonical(v) {
319
+ if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
320
+ if (v && typeof v === "object")
321
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`;
322
+ return JSON.stringify(v);
323
+ }
324
+ async function digest(v) {
325
+ return Array.from(
326
+ new Uint8Array(
327
+ await webcrypto.subtle.digest(
328
+ "SHA-256",
329
+ new TextEncoder().encode(canonical(v))
330
+ )
331
+ )
332
+ ).map((n) => n.toString(16).padStart(2, "0")).join("");
333
+ }
334
+ function previewData(v) {
335
+ if (Array.isArray(v)) return v.map(previewData);
336
+ if (v && typeof v === "object")
337
+ return Object.fromEntries(
338
+ Object.entries(v).map(([k, x]) => [
339
+ k,
340
+ k === "data" && typeof x === "string" ? `[base64 media: ${x.length} characters]` : previewData(x)
341
+ ])
342
+ );
343
+ return v;
344
+ }
345
+ function normalizeGooglePlan(customerId, input) {
346
+ if (!/^\d{10}$/.test(customerId))
347
+ throw new Error("Use a ten-digit customer ID.");
348
+ const prefix = `customers/${customerId}/`;
349
+ function check(v) {
350
+ if (typeof v === "string" && v.startsWith("customers/") && !v.startsWith(prefix))
351
+ throw new Error(
352
+ "Every customer resource must belong to the selected customerId."
353
+ );
354
+ if (Array.isArray(v)) v.forEach(check);
355
+ else if (v && typeof v === "object")
356
+ for (const [k, x] of Object.entries(v)) {
357
+ if (/access.?token|refresh.?token|developer.?token|authorization|client.?secret/i.test(
358
+ k
359
+ ))
360
+ throw new Error("Do not put credentials in a plan.");
361
+ if (k === "data" && typeof x === "string") {
362
+ let valid = x.length > 0 && x.length <= 6990508 && /^[A-Za-z0-9+/]*={0,2}$/.test(x);
363
+ try {
364
+ valid = valid && atob(x).length <= 5 * 1024 * 1024;
365
+ } catch {
366
+ valid = false;
367
+ }
368
+ if (!valid) throw new Error("Image data must be base64, at most 5 MiB decoded.");
369
+ }
370
+ check(x);
371
+ }
372
+ }
373
+ return input.map((raw) => {
374
+ const op = operation.parse(raw);
375
+ if ([op.create, op.update, op.remove].filter((x) => x !== void 0).length !== 1)
376
+ throw new Error(
377
+ "Each operation needs exactly one of create, update or remove."
378
+ );
379
+ if (op.update && !op.updateMask)
380
+ throw new Error("Updates require an explicit updateMask.");
381
+ if (op.kind === "adOperation" && op.update && ("name" in op.update || op.updateMask?.split(",").some((field) => field.trim() === "name")))
382
+ 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.");
383
+ if (!op.update && op.updateMask)
384
+ throw new Error("updateMask is only valid on updates.");
385
+ if (op.updateMask && /(^|,)\s*(\*|resource_name)\s*(,|$)/.test(op.updateMask))
386
+ throw new Error(
387
+ "Use a precise updateMask; never wildcard or resource_name."
388
+ );
389
+ const body = structuredClone(op.create ?? op.update);
390
+ const expected = prefix + resources[op.kind] + "/";
391
+ const resource = op.remove ?? body?.resourceName;
392
+ if ((op.update || op.remove) && !resource)
393
+ throw new Error("Update/remove requires a resource name.");
394
+ if (resource && (!resource.startsWith(expected) || !/^-?\d+(~[A-Za-z0-9_-]+)*$/.test(resource.slice(expected.length))))
395
+ throw new Error(`Expected resource in ${expected}.`);
396
+ if (op.create && [
397
+ "campaignOperation",
398
+ "adGroupOperation",
399
+ "adGroupAdOperation",
400
+ "assetGroupOperation"
401
+ ].includes(op.kind)) {
402
+ if (body.status && body.status !== "PAUSED")
403
+ throw new Error("New campaigns, groups and ads must be PAUSED.");
404
+ body.status = "PAUSED";
405
+ }
406
+ if (op.create && op.kind === "campaignOperation") {
407
+ if (body.advertisingChannelType === "VIDEO")
408
+ throw new Error(
409
+ "Google Ads API cannot create VIDEO campaigns. Use an explicitly requested DEMAND_GEN video campaign instead."
410
+ );
411
+ if (![
412
+ "CONTAINS_EU_POLITICAL_ADVERTISING",
413
+ "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING"
414
+ ].includes(body.containsEuPoliticalAdvertising))
415
+ throw new Error(
416
+ "Explicit EU political advertising declaration required."
417
+ );
418
+ }
419
+ const value = op.remove ? { remove: op.remove } : op.create ? { create: body } : { update: body, updateMask: op.updateMask };
420
+ check(value);
421
+ return { [op.kind]: value };
422
+ });
423
+ }
424
+ function registerGoogleVideoReads(c, config) {
425
+ 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.", {
426
+ customerId: z2.string().regex(/^[\d-]+$/),
427
+ currency: z2.string().regex(/^[A-Z]{3}$/).describe("Actual customer currency, verified before reading the upload."),
428
+ uploadId: z2.string().regex(/^\d+$/).describe("Numeric upload ID returned by google_ads_upload_video."),
429
+ loginCustomerId: z2.string().regex(/^[\d-]+$/).optional().describe("Optional MCC manager ID; defaults to the configured manager."),
430
+ testAccountOnly: z2.boolean().optional().describe("Require customer.test_account=true; enable for sandbox checks.")
431
+ }, async (raw) => {
432
+ const a = z2.object({ customerId: z2.string().regex(/^[\d-]+$/), currency: z2.string().regex(/^[A-Z]{3}$/), uploadId: z2.string().regex(/^\d+$/), loginCustomerId: z2.string().regex(/^[\d-]+$/).optional(), testAccountOnly: z2.boolean().optional() }).strict().parse(raw);
433
+ const cid = a.customerId.replace(/-/g, "");
434
+ const { api } = await googleSession(config, cid, a.currency, a.testAccountOnly === true, (a.loginCustomerId ?? config.loginCustomerId)?.replace(/-/g, ""));
435
+ const resourceName = `customers/${cid}/youTubeVideoUploads/${a.uploadId}`;
436
+ 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` });
437
+ const video = response.results?.[0]?.youTubeVideoUpload;
438
+ return result({ uploadId: a.uploadId, state: video?.state ?? "NOT_FOUND", ...video?.state === "PROCESSED" && video.videoId ? { videoId: video.videoId } : {} });
439
+ });
440
+ }
441
+ function registerGoogleExtendedWrites(c, config) {
442
+ const original = c;
443
+ const descriptions = {
444
+ customerId: "Selected Google Ads customer ID, not the manager account.",
445
+ currency: "Actual account currency as a three-letter ISO code; verified natively before writes.",
446
+ loginCustomerId: "Optional MCC ID; defaults to the selected account's inferred manager.",
447
+ testAccountOnly: "Require customer.test_account=true before writing. Always use true for sandbox acceptance.",
448
+ confirm: "Apply only when true and expectedPlanHash matches the exact preview.",
449
+ validateOnly: "Ask Google to validate without applying. Takes priority over confirm.",
450
+ expectedPlanHash: "SHA-256 planHash from the preview of this exact payload, required for application.",
451
+ uploadId: "Numeric ID of a video uploaded through Google Ads in this customer.",
452
+ title: "Nonempty title for this new video upload.",
453
+ mimeType: "Actual MIME type of the supplied video bytes."
454
+ };
455
+ c = {
456
+ tool: (n, d, shape, h) => original.tool(
457
+ n,
458
+ d,
459
+ Object.fromEntries(
460
+ Object.entries(shape).map(([k, v]) => [
461
+ k,
462
+ v.description ? v : v.describe(descriptions[k] ?? k)
463
+ ])
464
+ ),
465
+ h
466
+ )
467
+ };
468
+ registerGoogleVideoWrites(c, config);
469
+ c.tool(
470
+ "google_ads_apply_plan",
471
+ "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.",
472
+ {
473
+ customerId: z2.string().regex(/^[\d-]+$/),
474
+ currency: z2.string().regex(/^[A-Z]{3}$/).describe(
475
+ "Actual account currency, mandatory even when only linking assets."
476
+ ),
477
+ operations: z2.array(operation).min(1).max(100).describe(
478
+ "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)."
479
+ ),
480
+ loginCustomerId: z2.string().regex(/^[\d-]+$/).optional(),
481
+ testAccountOnly: z2.boolean().optional().describe(
482
+ "Require Google's customer.test_account=true before any mutation. Use true for acceptance tests."
483
+ ),
484
+ validateOnly: z2.boolean().optional(),
485
+ confirm: z2.boolean().optional(),
486
+ expectedPlanHash: z2.string().regex(/^[a-f0-9]{64}$/).optional()
487
+ },
488
+ async (a) => {
489
+ const cid = a.customerId.replace(/-/g, "");
490
+ const operations = normalizeGooglePlan(cid, a.operations);
491
+ if (JSON.stringify(operations).length > 12e6)
492
+ throw new Error(
493
+ "Plan too large; upload assets in separate confirmed plans and reference their resource names."
494
+ );
495
+ const manager = (a.loginCustomerId ?? config.loginCustomerId)?.replace(
496
+ /-/g,
497
+ ""
498
+ );
499
+ const planHash = await digest({
500
+ customerId: cid,
501
+ currency: a.currency,
502
+ manager: manager ?? null,
503
+ testAccountOnly: a.testAccountOnly === true,
504
+ operations
505
+ });
506
+ if (!a.confirm && !a.validateOnly)
507
+ return result({
508
+ applied: false,
509
+ planHash,
510
+ customerId: cid,
511
+ currency: a.currency,
512
+ operationCount: operations.length,
513
+ operations: previewData(operations)
514
+ });
515
+ if (a.confirm && !a.validateOnly && a.expectedPlanHash !== planHash)
516
+ throw new Error(
517
+ "Preview this exact plan first and provide its matching expectedPlanHash."
518
+ );
519
+ const { api, customer } = await googleSession(
520
+ config,
521
+ cid,
522
+ a.currency,
523
+ a.testAccountOnly,
524
+ manager
525
+ );
526
+ const response = await api("googleAds:mutate", {
527
+ mutateOperations: operations,
528
+ partialFailure: false,
529
+ validateOnly: a.validateOnly === true
530
+ });
531
+ return result({
532
+ applied: a.validateOnly !== true,
533
+ validatedByGoogle: true,
534
+ planHash,
535
+ operationCount: operations.length,
536
+ customerId: cid,
537
+ currency: a.currency,
538
+ testAccount: customer.testAccount === true,
539
+ result: response
540
+ });
541
+ }
542
+ );
543
+ }
544
+ async function googleSession(config, cid, currency, testAccountOnly, manager) {
545
+ const auth = await fetch("https://oauth2.googleapis.com/token", {
546
+ method: "POST",
547
+ headers: { "content-type": "application/x-www-form-urlencoded" },
548
+ body: new URLSearchParams({
549
+ grant_type: "refresh_token",
550
+ client_id: config.clientId,
551
+ client_secret: config.clientSecret,
552
+ refresh_token: config.refreshToken
553
+ }),
554
+ redirect: "error"
555
+ });
556
+ const token = await auth.json();
557
+ if (!auth.ok || !token.access_token)
558
+ throw new Error(
559
+ "Google authorization could not be refreshed. Reconnect the Google Ads source."
560
+ );
561
+ const headers = {
562
+ authorization: `Bearer ${token.access_token}`,
563
+ "developer-token": config.developerToken,
564
+ "content-type": "application/json"
565
+ };
566
+ if (manager) headers["login-customer-id"] = manager;
567
+ async function api(path, body) {
568
+ const r = await fetch(`${BASE}/customers/${cid}/${path}`, {
569
+ method: "POST",
570
+ headers,
571
+ body: JSON.stringify(body),
572
+ redirect: "error"
573
+ });
574
+ const x = await r.json();
575
+ if (!r.ok) {
576
+ const errors = x.error?.details?.flatMap((d) => d.errors ?? []).map((e) => ({
577
+ code: e.errorCode,
578
+ message: e.message,
579
+ location: e.location
580
+ }));
581
+ throw new Error(
582
+ `Google Ads: ${JSON.stringify(errors?.length ? errors : x.error?.message ?? r.status).slice(0, 4e3)}`
583
+ );
584
+ }
585
+ return x;
586
+ }
587
+ const read = await api("googleAds:search", {
588
+ query: "SELECT customer.id, customer.currency_code, customer.test_account FROM customer"
589
+ });
590
+ const customer = read.results?.[0]?.customer;
591
+ if (customer?.id !== cid || customer?.currencyCode !== currency)
592
+ throw new Error("Customer identity or currency does not match the plan.");
593
+ if (testAccountOnly && customer.testAccount !== true)
594
+ throw new Error("This plan requires a Google test account.");
595
+ return { headers, api, customer };
596
+ }
597
+ function registerGoogleVideoWrites(c, config) {
598
+ const common = {
599
+ customerId: z2.string().regex(/^[\d-]+$/),
600
+ currency: z2.string().regex(/^[A-Z]{3}$/),
601
+ loginCustomerId: z2.string().regex(/^[\d-]+$/).optional(),
602
+ testAccountOnly: z2.boolean().optional(),
603
+ confirm: z2.boolean().optional(),
604
+ expectedPlanHash: z2.string().regex(/^[a-f0-9]{64}$/).optional()
605
+ };
606
+ c.tool(
607
+ "google_ads_upload_video",
608
+ "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.",
609
+ {
610
+ ...common,
611
+ bytesBase64: z2.string().min(4).max(22369624).describe(
612
+ "Raw base64 video bytes, no data URL. Prefer an existing YouTube asset for large videos."
613
+ ),
614
+ mimeType: z2.enum(["video/mp4", "video/webm", "video/quicktime"]),
615
+ title: z2.string().trim().min(1).max(100),
616
+ description: z2.string().trim().min(1).max(5e3).describe(
617
+ "Nonempty video description; Google rejects an empty value during upload."
618
+ ),
619
+ visibility: z2.literal("UNLISTED").describe(
620
+ "Explicit acknowledgment of the visibility of this YouTube upload."
621
+ )
622
+ },
623
+ async (a) => {
624
+ const cid = a.customerId.replace(/-/g, "");
625
+ if (!/^\d{10}$/.test(cid))
626
+ throw new Error("Use a ten-digit customer ID.");
627
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(a.bytesBase64))
628
+ throw new Error("Invalid base64 video.");
629
+ let binary;
630
+ try {
631
+ binary = atob(a.bytesBase64);
632
+ } catch {
633
+ throw new Error("Invalid base64 video.");
634
+ }
635
+ if (binary.length > 16 * 1024 * 1024)
636
+ throw new Error("Video exceeds 16 MiB.");
637
+ const manager = (a.loginCustomerId ?? config.loginCustomerId)?.replace(
638
+ /-/g,
639
+ ""
640
+ );
641
+ const planHash = await digest({
642
+ customerId: cid,
643
+ currency: a.currency,
644
+ manager: manager ?? null,
645
+ testAccountOnly: a.testAccountOnly === true,
646
+ title: a.title,
647
+ description: a.description ?? "",
648
+ mimeType: a.mimeType,
649
+ visibility: a.visibility,
650
+ bytes: a.bytesBase64
651
+ });
652
+ if (!a.confirm)
653
+ return result({
654
+ applied: false,
655
+ planHash,
656
+ customerId: cid,
657
+ title: a.title,
658
+ description: a.description,
659
+ mimeType: a.mimeType,
660
+ visibility: a.visibility,
661
+ byteLength: binary.length
662
+ });
663
+ if (a.expectedPlanHash !== planHash)
664
+ throw new Error(
665
+ "Preview this exact upload first and supply expectedPlanHash."
666
+ );
667
+ const { headers } = await googleSession(
668
+ config,
669
+ cid,
670
+ a.currency,
671
+ a.testAccountOnly,
672
+ manager
673
+ );
674
+ const start = await fetch(
675
+ `https://googleads.googleapis.com/resumable/upload/v25/customers/${cid}/youTubeVideoUploads:create`,
676
+ {
677
+ method: "POST",
678
+ headers: {
679
+ ...headers,
680
+ "X-Goog-Upload-Protocol": "resumable",
681
+ "X-Goog-Upload-Command": "start",
682
+ "X-Goog-Upload-Header-Content-Length": String(binary.length)
683
+ },
684
+ body: JSON.stringify({
685
+ customer_id: cid,
686
+ you_tube_video_upload: {
687
+ video_title: a.title,
688
+ video_description: a.description ?? "",
689
+ video_privacy: "UNLISTED"
690
+ }
691
+ }),
692
+ redirect: "error"
693
+ }
694
+ );
695
+ if (!start.ok)
696
+ throw new Error(
697
+ `Google video upload initiation failed (${start.status}). No automatic retry.`
698
+ );
699
+ const target = start.headers.get("x-goog-upload-url");
700
+ if (!target || new URL(target).origin !== "https://googleads.googleapis.com")
701
+ throw new Error(
702
+ "Unexpected Google upload URL; no bytes or credentials sent."
703
+ );
704
+ const uploaded = await fetch(target, {
705
+ method: "PUT",
706
+ headers: {
707
+ ...headers,
708
+ "content-type": a.mimeType,
709
+ "X-Goog-Upload-Offset": "0",
710
+ "X-Goog-Upload-Command": "upload, finalize"
711
+ },
712
+ body: Uint8Array.from(binary, (c2) => c2.charCodeAt(0)),
713
+ redirect: "error"
714
+ });
715
+ if (!uploaded.ok) {
716
+ const error = await uploaded.json().catch(() => ({}));
717
+ throw new Error(
718
+ `Google video upload failed (${uploaded.status}): ${String(error.error?.message ?? "unknown error").slice(0, 700)}. Inspect you_tube_video_upload before retrying.`
719
+ );
720
+ }
721
+ const response = await uploaded.json();
722
+ if (!String(response.resourceName).startsWith(
723
+ `customers/${cid}/youTubeVideoUploads/`
724
+ ))
725
+ throw new Error(
726
+ "Google did not return an owned video upload resource. Inspect uploads before retrying."
727
+ );
728
+ return result({
729
+ applied: true,
730
+ planHash,
731
+ resourceName: response.resourceName,
732
+ uploadId: String(response.resourceName).split("/").pop(),
733
+ visibility: "UNLISTED",
734
+ nextAction: "Query you_tube_video_upload.state and video_id; wait for PROCESSED before creating the video asset."
735
+ });
736
+ }
737
+ );
738
+ c.tool(
739
+ "google_ads_remove_video_upload",
740
+ "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.",
741
+ { ...common, uploadId: z2.string().regex(/^\d+$/) },
742
+ async (a) => {
743
+ const cid = a.customerId.replace(/-/g, "");
744
+ if (!/^\d{10}$/.test(cid))
745
+ throw new Error("Use a ten-digit customer ID.");
746
+ const manager = (a.loginCustomerId ?? config.loginCustomerId)?.replace(
747
+ /-/g,
748
+ ""
749
+ );
750
+ const resourceName = `customers/${cid}/youTubeVideoUploads/${a.uploadId}`;
751
+ const planHash = await digest({
752
+ customerId: cid,
753
+ currency: a.currency,
754
+ manager: manager ?? null,
755
+ testAccountOnly: a.testAccountOnly === true,
756
+ remove: resourceName
757
+ });
758
+ if (!a.confirm)
759
+ return result({ applied: false, planHash, remove: resourceName });
760
+ if (a.expectedPlanHash !== planHash)
761
+ throw new Error(
762
+ "Preview this exact removal first and supply expectedPlanHash."
763
+ );
764
+ const { api } = await googleSession(
765
+ config,
766
+ cid,
767
+ a.currency,
768
+ a.testAccountOnly,
769
+ manager
770
+ );
771
+ return result({
772
+ applied: true,
773
+ planHash,
774
+ result: await api("youTubeVideoUploads:remove", {
775
+ resourceNames: [resourceName]
776
+ })
777
+ });
778
+ }
779
+ );
780
+ }
781
+
782
+ // src/platforms/google-ads/pmax-metrics.ts
783
+ var PMAX_METRIC_FIELDS = [
784
+ "metrics.impressions",
785
+ "metrics.clicks",
786
+ "metrics.cost_micros",
787
+ "metrics.conversions",
788
+ "metrics.conversions_value",
789
+ "metrics.all_conversions",
790
+ "metrics.all_conversions_from_interactions_rate",
791
+ "metrics.all_conversions_from_interactions_value_per_interaction",
792
+ "metrics.all_conversions_value",
793
+ "metrics.all_conversions_value_per_cost",
794
+ "metrics.average_cost",
795
+ "metrics.average_cpc",
796
+ "metrics.average_cpe",
797
+ "metrics.average_cpm",
798
+ "metrics.conversions_from_interactions_rate",
799
+ "metrics.conversions_from_interactions_value_per_interaction",
800
+ "metrics.conversions_value_per_cost",
801
+ "metrics.cost_per_all_conversions",
802
+ "metrics.cost_per_conversion",
803
+ "metrics.cross_device_conversions",
804
+ "metrics.cross_device_conversions_value",
805
+ "metrics.ctr",
806
+ "metrics.engagement_rate",
807
+ "metrics.engagements",
808
+ "metrics.interaction_rate",
809
+ "metrics.interactions",
810
+ "metrics.trueview_average_cpv",
811
+ "metrics.value_per_all_conversions",
812
+ "metrics.value_per_conversion",
813
+ "metrics.video_trueview_view_rate",
814
+ "metrics.video_trueview_views",
815
+ "metrics.view_through_conversions"
816
+ ];
817
+
4
818
  // src/platforms/google-ads/tools.ts
5
- import { z as z4 } from "zod";
819
+ import { z as z6 } from "zod";
6
820
 
7
821
  // src/platforms/google-ads/types.ts
8
822
  var GoogleAdsApiException = class extends Error {
@@ -3640,9 +4454,9 @@ var BIDDING_TARGET_DIMENSIONS = [
3640
4454
  {
3641
4455
  key: "campaignStartDate",
3642
4456
  name: "Campaign Start Date",
3643
- description: "The start date of the campaign in YYYY-MM-DD format.",
4457
+ description: "The start date and time of the campaign in the account time zone (YYYY-MM-DD HH:mm:ss).",
3644
4458
  category: "entity",
3645
- apiField: "campaign.start_date",
4459
+ apiField: "campaign.start_date_time",
3646
4460
  isSegment: false,
3647
4461
  isResourceAttribute: true,
3648
4462
  compatibleResources: ["campaign"]
@@ -3650,9 +4464,9 @@ var BIDDING_TARGET_DIMENSIONS = [
3650
4464
  {
3651
4465
  key: "campaignEndDate",
3652
4466
  name: "Campaign End Date",
3653
- description: "The end date of the campaign in YYYY-MM-DD format (if set).",
4467
+ description: "The end date and time of the campaign in the account time zone (YYYY-MM-DD HH:mm:ss), if set.",
3654
4468
  category: "entity",
3655
- apiField: "campaign.end_date",
4469
+ apiField: "campaign.end_date_time",
3656
4470
  isSegment: false,
3657
4471
  isResourceAttribute: true,
3658
4472
  compatibleResources: ["campaign"]
@@ -4085,7 +4899,7 @@ function buildDateWhereClause(startDate, endDate, datePreset) {
4085
4899
  }
4086
4900
  return { clause: null, warnings: [] };
4087
4901
  }
4088
- function planQuery(request) {
4902
+ function planQuery(request2) {
4089
4903
  const {
4090
4904
  resource,
4091
4905
  metrics,
@@ -4097,7 +4911,7 @@ function planQuery(request) {
4097
4911
  orderBy,
4098
4912
  orderDirection,
4099
4913
  limit
4100
- } = request;
4914
+ } = request2;
4101
4915
  const errors = [];
4102
4916
  const warnings = [];
4103
4917
  const calculatedMetricKeys = metrics.filter((k) => isCalculatedMetric(k));
@@ -4474,13 +5288,6 @@ var PlatformApiError = class extends Error {
4474
5288
  this.retryAfter = retryAfter;
4475
5289
  this.name = "PlatformApiError";
4476
5290
  }
4477
- platform;
4478
- code;
4479
- isRateLimit;
4480
- isAuth;
4481
- isPermission;
4482
- suggestion;
4483
- retryAfter;
4484
5291
  toMcpError() {
4485
5292
  return {
4486
5293
  error: this.message,
@@ -4615,7 +5422,7 @@ var KeywordPlannerRateLimiter = class {
4615
5422
  };
4616
5423
 
4617
5424
  // src/platforms/google-ads/read-rpc.ts
4618
- import { z } from "zod";
5425
+ import { z as z3 } from "zod";
4619
5426
  var READ_ONLY_OPERATION_NAMES = [
4620
5427
  "generateAudienceCompositionInsights",
4621
5428
  "generateAudienceDefinition",
@@ -4731,13 +5538,13 @@ function validateGoogleAdsReadOnlyRequest(value, path = "request", depth = 0, st
4731
5538
  }
4732
5539
  throw new Error(`${path} must contain JSON-compatible values.`);
4733
5540
  }
4734
- function buildGoogleAdsReadOnlyPath(operation, customerId) {
4735
- const config = GOOGLE_ADS_READ_ONLY_OPERATIONS[operation];
4736
- if (!config) throw new Error(`Unsupported read-only operation: ${operation}.`);
5541
+ function buildGoogleAdsReadOnlyPath(operation2, customerId) {
5542
+ const config = GOOGLE_ADS_READ_ONLY_OPERATIONS[operation2];
5543
+ if (!config) throw new Error(`Unsupported read-only operation: ${operation2}.`);
4737
5544
  if (config.scope === "customer") {
4738
5545
  const cleanCustomerId = customerId?.replace(/-/g, "");
4739
5546
  if (!cleanCustomerId || !/^\d+$/.test(cleanCustomerId)) {
4740
- throw new Error(`${operation} requires a numeric customerId.`);
5547
+ throw new Error(`${operation2} requires a numeric customerId.`);
4741
5548
  }
4742
5549
  return { path: `customers/${cleanCustomerId}${config.path}`, config };
4743
5550
  }
@@ -4748,20 +5555,20 @@ function registerGoogleAdsReadOnlyRpcTool(server, client, ok3) {
4748
5555
  "google_ads_run_readonly_rpc",
4749
5556
  "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.",
4750
5557
  {
4751
- operation: z.enum(READ_ONLY_OPERATION_NAMES),
4752
- customerId: z.string().regex(/^\d[\d-]*\d$|^\d$/).optional().describe("Required for customer-scoped operations; omit for global planning catalogs"),
4753
- request: z.record(z.unknown()).optional().default({}).describe("Official REST JSON request body for POST operations, or query parameters for GET operations")
5558
+ operation: z3.enum(READ_ONLY_OPERATION_NAMES),
5559
+ customerId: z3.string().regex(/^\d[\d-]*\d$|^\d$/).optional().describe("Required for customer-scoped operations; omit for global planning catalogs"),
5560
+ request: z3.record(z3.unknown()).optional().default({}).describe("Official REST JSON request body for POST operations, or query parameters for GET operations")
4754
5561
  },
4755
- async ({ operation, customerId, request }) => {
5562
+ async ({ operation: operation2, customerId, request: request2 }) => {
4756
5563
  try {
4757
- validateGoogleAdsReadOnlyRequest(request);
4758
- const encodedLength = JSON.stringify(request).length;
5564
+ validateGoogleAdsReadOnlyRequest(request2);
5565
+ const encodedLength = JSON.stringify(request2).length;
4759
5566
  if (encodedLength > 1e5) throw new Error("Encoded request exceeds 100,000 characters.");
4760
- const { path, config } = buildGoogleAdsReadOnlyPath(operation, customerId);
5567
+ const { path, config } = buildGoogleAdsReadOnlyPath(operation2, customerId);
4761
5568
  let response;
4762
5569
  if (config.method === "GET") {
4763
5570
  const query = {};
4764
- for (const [key, value] of Object.entries(request)) {
5571
+ for (const [key, value] of Object.entries(request2)) {
4765
5572
  if (!["string", "number", "boolean"].includes(typeof value)) {
4766
5573
  throw new Error(`GET query parameter ${key} must be a string, number, or boolean.`);
4767
5574
  }
@@ -4769,17 +5576,17 @@ function registerGoogleAdsReadOnlyRpcTool(server, client, ok3) {
4769
5576
  }
4770
5577
  response = await client.runReadOnlyService(path, "GET", void 0, query);
4771
5578
  } else {
4772
- response = await client.runReadOnlyService(path, "POST", request);
5579
+ response = await client.runReadOnlyService(path, "POST", request2);
4773
5580
  }
4774
5581
  return ok3({
4775
5582
  dataKind: "google_ads_readonly_rpc",
4776
- operation,
5583
+ operation: operation2,
4777
5584
  purpose: config.purpose,
4778
5585
  scope: config.scope,
4779
5586
  method: config.method,
4780
5587
  response,
4781
5588
  readOnly: true,
4782
- warnings: operation === "generateShareablePreviews" ? ["The returned preview URL is shareable and can expose ad creative until it expires; disclose it only to intended recipients."] : [],
5589
+ warnings: operation2 === "generateShareablePreviews" ? ["The returned preview URL is shareable and can expose ad creative until it expires; disclose it only to intended recipients."] : [],
4783
5590
  limitations: [
4784
5591
  "The request body follows Google's native REST schema and is intentionally not translated into this server's metric aliases.",
4785
5592
  "Availability depends on developer-token access level, account eligibility, OAuth permissions, and operation-specific quotas."
@@ -4794,8 +5601,20 @@ function registerGoogleAdsReadOnlyRpcTool(server, client, ok3) {
4794
5601
  );
4795
5602
  }
4796
5603
 
5604
+ // src/core/redirects.ts
5605
+ function refuseRedirect(response, api) {
5606
+ if (response.status < 300 || response.status >= 400) return;
5607
+ throw new Error(`${api} returned an unexpected HTTP ${response.status} redirect. Redirects are never followed. Check the configured endpoint and authorization.`);
5608
+ }
5609
+
4797
5610
  // src/platforms/google-ads/client.ts
4798
5611
  var DEFAULT_GOOGLE_ADS_REQUEST_TIMEOUT_MS = 3e4;
5612
+ function withManagerHint(message, url, announcedManager) {
5613
+ if (announcedManager) return message;
5614
+ if (!/permission|PERMISSION_DENIED/i.test(message)) return message;
5615
+ const customer = url.match(/customers\/(\d+)/)?.[1];
5616
+ 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}` : "") + `.`;
5617
+ }
4799
5618
  function googleAdsRequestTimeoutMs() {
4800
5619
  const parsed = Number(process.env["GOOGLE_ADS_REQUEST_TIMEOUT_MS"]);
4801
5620
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GOOGLE_ADS_REQUEST_TIMEOUT_MS;
@@ -4808,6 +5627,14 @@ var GoogleAdsClient = class {
4808
5627
  loginCustomerId;
4809
5628
  accessToken = "";
4810
5629
  tokenExpiresAt = 0;
5630
+ /**
5631
+ * Le gestionnaire résolu pour un compte, ou `undefined` s'il n'en a pas.
5632
+ *
5633
+ * La distinction compte : `undefined` mémorisé veut dire « on a cherché et il
5634
+ * n'y en a pas », ce qui évite de refaire la découverte à chaque appel sur un
5635
+ * compte accessible en direct.
5636
+ */
5637
+ managerCache = /* @__PURE__ */ new Map();
4811
5638
  rateLimiter = new RateLimiter();
4812
5639
  keywordPlannerRateLimiter = new KeywordPlannerRateLimiter();
4813
5640
  constructor(config) {
@@ -4864,18 +5691,66 @@ var GoogleAdsClient = class {
4864
5691
  // ============================================
4865
5692
  // PRIVATE METHODS
4866
5693
  // ============================================
4867
- getHeaders() {
5694
+ getHeaders(loginCustomerId) {
4868
5695
  const headers = {
4869
5696
  Authorization: `Bearer ${this.accessToken}`,
4870
5697
  "developer-token": this.developerToken,
4871
5698
  "Content-Type": "application/json"
4872
5699
  };
4873
- if (this.loginCustomerId) {
4874
- headers["login-customer-id"] = this.loginCustomerId;
5700
+ const manager = loginCustomerId ?? this.loginCustomerId;
5701
+ if (manager) {
5702
+ headers["login-customer-id"] = stripCustomerId(manager);
4875
5703
  }
4876
5704
  return headers;
4877
5705
  }
4878
- async request(url, options = {}, beforeAttempt, deadlineAtMs) {
5706
+ /**
5707
+ * The manager account to announce when querying a given customer.
5708
+ *
5709
+ * Google rejects any request against an account managed by an MCC unless the
5710
+ * call names that MCC in `login-customer-id`. The rejection is a bare
5711
+ * `USER_PERMISSION_DENIED` that names neither the account nor the manager, so
5712
+ * it reads as missing access rather than as a missing header, and that is
5713
+ * where people lose an afternoon.
5714
+ *
5715
+ * `GOOGLE_ADS_LOGIN_CUSTOMER_ID` solves it when you know the answer in
5716
+ * advance. This resolves it when you do not: if the customer is not directly
5717
+ * accessible, the accessible managers are asked which accounts they hold, and
5718
+ * the one holding this customer is used.
5719
+ *
5720
+ * Resolved once and cached for the life of the client. The hierarchy of an
5721
+ * advertising account does not change between two tool calls, and paying two
5722
+ * extra requests on every query to discover that it has not would be worse
5723
+ * than the problem.
5724
+ */
5725
+ async resolveLoginCustomerId(customerId) {
5726
+ if (this.loginCustomerId) return this.loginCustomerId;
5727
+ const wanted = stripCustomerId(customerId);
5728
+ if (this.managerCache.has(wanted)) return this.managerCache.get(wanted);
5729
+ try {
5730
+ const accessible = (await this.listAccessibleCustomers()).map((name) => stripCustomerId(name.replace(/^customers\//, "")));
5731
+ if (accessible.includes(wanted)) {
5732
+ this.managerCache.set(wanted, void 0);
5733
+ return void 0;
5734
+ }
5735
+ for (const candidate of accessible) {
5736
+ const children = await this.getClientAccounts(candidate);
5737
+ for (const child of children) {
5738
+ if (stripCustomerId(child.id) === wanted) {
5739
+ this.managerCache.set(wanted, candidate);
5740
+ return candidate;
5741
+ }
5742
+ }
5743
+ }
5744
+ } catch (error) {
5745
+ logger.debug(
5746
+ "google-ads",
5747
+ `Could not resolve a manager for ${wanted}: ${error instanceof Error ? error.message : error}`
5748
+ );
5749
+ }
5750
+ this.managerCache.set(wanted, void 0);
5751
+ return void 0;
5752
+ }
5753
+ async request(url, options = {}, beforeAttempt, deadlineAtMs, loginCustomerId) {
4879
5754
  await this.ensureValidToken();
4880
5755
  return this.rateLimiter.execute(async () => {
4881
5756
  await beforeAttempt?.();
@@ -4900,10 +5775,11 @@ var GoogleAdsClient = class {
4900
5775
  // whatever host the redirect names.
4901
5776
  redirect: "error",
4902
5777
  headers: {
4903
- ...this.getHeaders(),
5778
+ ...this.getHeaders(loginCustomerId),
4904
5779
  ...options.headers
4905
5780
  }
4906
5781
  });
5782
+ refuseRedirect(response, "Google Ads API");
4907
5783
  if (!response.ok) {
4908
5784
  const errorBody = await response.json().catch(() => ({}));
4909
5785
  const errorDetails = errorBody?.error;
@@ -4912,7 +5788,7 @@ var GoogleAdsClient = class {
4912
5788
  const requestId = errorDetails?.details?.[0]?.requestId ?? errorBody?.[0]?.error?.details?.[0]?.requestId;
4913
5789
  const detailedMessage = gaErrors?.[0]?.message || errorDetails?.message || `Request failed: ${response.statusText}`;
4914
5790
  throw new GoogleAdsApiException(
4915
- detailedMessage,
5791
+ withManagerHint(detailedMessage, url, loginCustomerId),
4916
5792
  response.status,
4917
5793
  errorDetails?.status,
4918
5794
  requestId,
@@ -4973,12 +5849,16 @@ var GoogleAdsClient = class {
4973
5849
  async searchStream(customerId, gaqlQuery) {
4974
5850
  const cleanCustomerId = stripCustomerId(customerId);
4975
5851
  const url = `${GOOGLE_ADS_API_BASE_URL}/customers/${cleanCustomerId}/googleAds:searchStream`;
5852
+ const manager = await this.resolveLoginCustomerId(cleanCustomerId);
4976
5853
  const response = await this.request(
4977
5854
  url,
4978
5855
  {
4979
5856
  method: "POST",
4980
5857
  body: JSON.stringify({ query: gaqlQuery })
4981
- }
5858
+ },
5859
+ void 0,
5860
+ void 0,
5861
+ manager
4982
5862
  );
4983
5863
  const allRows = [];
4984
5864
  for (const batch of response) {
@@ -4995,12 +5875,19 @@ var GoogleAdsClient = class {
4995
5875
  async search(customerId, gaqlQuery, pageToken) {
4996
5876
  const cleanCustomerId = stripCustomerId(customerId);
4997
5877
  const url = `${GOOGLE_ADS_API_BASE_URL}/customers/${cleanCustomerId}/googleAds:search`;
5878
+ const manager = await this.resolveLoginCustomerId(cleanCustomerId);
4998
5879
  const body = { query: gaqlQuery };
4999
5880
  if (pageToken) body.pageToken = pageToken;
5000
- return this.request(url, {
5001
- method: "POST",
5002
- body: JSON.stringify(body)
5003
- });
5881
+ return this.request(
5882
+ url,
5883
+ {
5884
+ method: "POST",
5885
+ body: JSON.stringify(body)
5886
+ },
5887
+ void 0,
5888
+ void 0,
5889
+ manager
5890
+ );
5004
5891
  }
5005
5892
  // ============================================
5006
5893
  // KEYWORD PLANNER (PLANLESS, READ-ONLY RPCS)
@@ -5153,8 +6040,8 @@ var GoogleAdsClient = class {
5153
6040
  return {
5154
6041
  id: String(row.customer?.id || cleanCustomerId),
5155
6042
  descriptiveName: row.customer?.descriptiveName || "Unknown Account",
5156
- currencyCode: row.customer?.currencyCode || "USD",
5157
- timeZone: row.customer?.timeZone || "America/New_York",
6043
+ currencyCode: row.customer?.currencyCode || "",
6044
+ timeZone: row.customer?.timeZone || "",
5158
6045
  manager: row.customer?.manager || false,
5159
6046
  testAccount: row.customer?.testAccount || false,
5160
6047
  resourceName: row.customer?.resourceName || `customers/${cleanCustomerId}`
@@ -5222,9 +6109,9 @@ var GoogleAdsClient = class {
5222
6109
  /**
5223
6110
  * Execute a query request with automatic query planning
5224
6111
  */
5225
- async executeQuery(request) {
6112
+ async executeQuery(request2) {
5226
6113
  const startTime = Date.now();
5227
- const plan = planQuery(request);
6114
+ const plan = planQuery(request2);
5228
6115
  const errors = [...plan.errors];
5229
6116
  const warnings = [...plan.warnings];
5230
6117
  const gaqlQueries = [];
@@ -5252,7 +6139,7 @@ var GoogleAdsClient = class {
5252
6139
  gaqlQueries.push(query.gaql);
5253
6140
  rawRequests.push({ gaql: query.gaql, resource: query.resource });
5254
6141
  try {
5255
- const apiRows = (await this.search(request.customerId, query.gaql)).results ?? [];
6142
+ const apiRows = (await this.search(request2.customerId, query.gaql)).results ?? [];
5256
6143
  rawResponses.push(apiRows.slice(0, 5));
5257
6144
  const flatRows = apiRows.map((row) => flattenGoogleAdsRow(row));
5258
6145
  queryResults.push(flatRows);
@@ -5463,7 +6350,7 @@ function camelToSnakeResource(key) {
5463
6350
  }
5464
6351
 
5465
6352
  // src/platforms/google-ads/keyword-planner.ts
5466
- import { z as z2 } from "zod";
6353
+ import { z as z4 } from "zod";
5467
6354
  var FORECAST_WITH_BREAKDOWN_TIME_BUDGET_MS = 45e3;
5468
6355
  var MONTHS = [
5469
6356
  "JANUARY",
@@ -5482,17 +6369,17 @@ var MONTHS = [
5482
6369
  var MONTH_NUMBER = new Map(
5483
6370
  MONTHS.map((month, index) => [month, index + 1])
5484
6371
  );
5485
- var customerIdSchema = z2.string().regex(/^\d[\d-]*\d$|^\d$/, "Expected a numeric Google Ads customer ID, with or without dashes");
5486
- var numericIdSchema = z2.string().regex(/^\d+$/, "Expected a numeric Google Ads criterion ID");
5487
- var keywordTextSchema = z2.string().trim().min(1).max(80).refine(
6372
+ var customerIdSchema = z4.string().regex(/^\d[\d-]*\d$|^\d$/, "Expected a numeric Google Ads customer ID, with or without dashes");
6373
+ var numericIdSchema = z4.string().regex(/^\d+$/, "Expected a numeric Google Ads criterion ID");
6374
+ var keywordTextSchema = z4.string().trim().min(1).max(80).refine(
5488
6375
  (value) => value.split(/\s+/).length <= 10,
5489
6376
  "Google Ads keywords can contain at most 10 words"
5490
6377
  );
5491
- var yearMonthSchema = z2.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Expected YYYY-MM");
5492
- var isoDateSchema = z2.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
5493
- var networkSchema = z2.enum(["GOOGLE_SEARCH", "GOOGLE_SEARCH_AND_PARTNERS"]);
5494
- var matchTypeSchema = z2.enum(["EXACT", "PHRASE", "BROAD"]);
5495
- var currencyAmountSchema = z2.number().finite().min(1e-6).max(1e9);
6378
+ var yearMonthSchema = z4.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Expected YYYY-MM");
6379
+ var isoDateSchema = z4.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD");
6380
+ var networkSchema = z4.enum(["GOOGLE_SEARCH", "GOOGLE_SEARCH_AND_PARTNERS"]);
6381
+ var matchTypeSchema = z4.enum(["EXACT", "PHRASE", "BROAD"]);
6382
+ var currencyAmountSchema = z4.number().finite().min(1e-6).max(1e9);
5496
6383
  function compactObject(value) {
5497
6384
  return Object.fromEntries(
5498
6385
  Object.entries(value).filter(([, fieldValue]) => fieldValue !== void 0)
@@ -5764,22 +6651,22 @@ function normalizeKeywordHistoricalMetrics(metrics, includeMonthlySearchVolumes
5764
6651
  trends: computeKeywordPlannerTrends(metrics?.monthlySearchVolumes)
5765
6652
  };
5766
6653
  }
5767
- function normalizeHistoricalResult(result, includeMonthlySearchVolumes) {
6654
+ function normalizeHistoricalResult(result2, includeMonthlySearchVolumes) {
5768
6655
  return {
5769
- keyword: result.text ?? null,
5770
- closeVariants: result.closeVariants ?? [],
6656
+ keyword: result2.text ?? null,
6657
+ closeVariants: result2.closeVariants ?? [],
5771
6658
  metrics: normalizeKeywordHistoricalMetrics(
5772
- result.keywordMetrics,
6659
+ result2.keywordMetrics,
5773
6660
  includeMonthlySearchVolumes
5774
6661
  )
5775
6662
  };
5776
6663
  }
5777
- function normalizeIdeaResult(result) {
6664
+ function normalizeIdeaResult(result2) {
5778
6665
  return {
5779
- keyword: result.text ?? null,
5780
- closeVariants: result.closeVariants ?? [],
5781
- metrics: normalizeKeywordHistoricalMetrics(result.keywordIdeaMetrics),
5782
- concepts: result.keywordAnnotations?.concepts ?? []
6666
+ keyword: result2.text ?? null,
6667
+ closeVariants: result2.closeVariants ?? [],
6668
+ metrics: normalizeKeywordHistoricalMetrics(result2.keywordIdeaMetrics),
6669
+ concepts: result2.keywordAnnotations?.concepts ?? []
5783
6670
  };
5784
6671
  }
5785
6672
  function normalizeAggregateMetrics(results) {
@@ -5956,15 +6843,15 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
5956
6843
  "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.",
5957
6844
  {
5958
6845
  customerId: customerIdSchema.describe("Google Ads serving customer ID; use the client account, not its MCC manager"),
5959
- keywords: z2.array(keywordTextSchema).min(1).max(1e4).describe("Keywords to analyze; Google may combine near-exact close variants"),
5960
- geoTargetIds: z2.array(numericIdSchema).max(10).optional().default([]).describe("Geo target criterion IDs, e.g. 2250 for France; empty means all geographies"),
6846
+ keywords: z4.array(keywordTextSchema).min(1).max(1e4).describe("Keywords to analyze; Google may combine near-exact close variants"),
6847
+ geoTargetIds: z4.array(numericIdSchema).max(10).optional().default([]).describe("Geo target criterion IDs, e.g. 2250 for France; empty means all geographies"),
5961
6848
  languageId: numericIdSchema.optional().describe("Optional language criterion ID, e.g. 1002 for French; omit for all languages"),
5962
6849
  network: networkSchema.optional().default("GOOGLE_SEARCH"),
5963
- includeAdultKeywords: z2.boolean().optional().default(false),
5964
- includeAverageCpc: z2.boolean().optional().default(true).describe("Request legacy average CPC in addition to top-of-page bid ranges"),
5965
- includeDeviceBreakdown: z2.boolean().optional().default(false).describe("Return aggregate searches by device across all requested keywords"),
5966
- includeMonthlySearchVolumes: z2.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"),
5967
- historyMonths: z2.number().int().min(3).max(48).optional().default(24).describe("History length when no explicit YYYY-MM range is supplied; 24 enables YoY calculation"),
6850
+ includeAdultKeywords: z4.boolean().optional().default(false),
6851
+ includeAverageCpc: z4.boolean().optional().default(true).describe("Request legacy average CPC in addition to top-of-page bid ranges"),
6852
+ includeDeviceBreakdown: z4.boolean().optional().default(false).describe("Return aggregate searches by device across all requested keywords"),
6853
+ includeMonthlySearchVolumes: z4.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"),
6854
+ historyMonths: z4.number().int().min(3).max(48).optional().default(24).describe("History length when no explicit YYYY-MM range is supplied; 24 enables YoY calculation"),
5968
6855
  startYearMonth: yearMonthSchema.optional().describe("Optional inclusive historical range start YYYY-MM"),
5969
6856
  endYearMonth: yearMonthSchema.optional().describe("Optional inclusive historical range end YYYY-MM")
5970
6857
  },
@@ -5984,7 +6871,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
5984
6871
  }) => {
5985
6872
  try {
5986
6873
  const startedAt = Date.now();
5987
- const { request, historyRange } = buildKeywordHistoricalMetricsRequest({
6874
+ const { request: request2, historyRange } = buildKeywordHistoricalMetricsRequest({
5988
6875
  keywords,
5989
6876
  geoTargetIds,
5990
6877
  languageId,
@@ -6000,9 +6887,9 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6000
6887
  keywords.length,
6001
6888
  historyRange.monthCount
6002
6889
  );
6003
- const response = await client.generateKeywordHistoricalMetrics(customerId, request);
6004
- const results = (response.results ?? []).map((result) => normalizeHistoricalResult(
6005
- result,
6890
+ const response = await client.generateKeywordHistoricalMetrics(customerId, request2);
6891
+ const results = (response.results ?? []).map((result2) => normalizeHistoricalResult(
6892
+ result2,
6006
6893
  includeMonthlySearchVolumes
6007
6894
  ));
6008
6895
  const warnings = [];
@@ -6056,21 +6943,21 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6056
6943
  "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.",
6057
6944
  {
6058
6945
  customerId: customerIdSchema.describe("Google Ads serving customer ID; use the client account, not its MCC manager"),
6059
- seedKeywords: z2.array(keywordTextSchema).min(1).max(20).optional(),
6060
- url: z2.string().trim().min(1).optional().describe("Specific page URL to crawl; combine with seedKeywords if desired"),
6061
- site: z2.string().trim().min(1).optional().describe("Whole-domain seed; exclusive with seedKeywords/url"),
6062
- geoTargetIds: z2.array(numericIdSchema).max(10).optional().default([]),
6946
+ seedKeywords: z4.array(keywordTextSchema).min(1).max(20).optional(),
6947
+ url: z4.string().trim().min(1).optional().describe("Specific page URL to crawl; combine with seedKeywords if desired"),
6948
+ site: z4.string().trim().min(1).optional().describe("Whole-domain seed; exclusive with seedKeywords/url"),
6949
+ geoTargetIds: z4.array(numericIdSchema).max(10).optional().default([]),
6063
6950
  languageId: numericIdSchema.optional(),
6064
6951
  network: networkSchema.optional().default("GOOGLE_SEARCH"),
6065
- includeAdultKeywords: z2.boolean().optional().default(false),
6066
- includeAverageCpc: z2.boolean().optional().default(true),
6067
- includeDeviceBreakdown: z2.boolean().optional().default(false),
6068
- includeKeywordConcepts: z2.boolean().optional().default(true),
6069
- historyMonths: z2.number().int().min(3).max(48).optional().default(13).describe("13 months is enough to compute latest-month YoY while limiting response size"),
6952
+ includeAdultKeywords: z4.boolean().optional().default(false),
6953
+ includeAverageCpc: z4.boolean().optional().default(true),
6954
+ includeDeviceBreakdown: z4.boolean().optional().default(false),
6955
+ includeKeywordConcepts: z4.boolean().optional().default(true),
6956
+ historyMonths: z4.number().int().min(3).max(48).optional().default(13).describe("13 months is enough to compute latest-month YoY while limiting response size"),
6070
6957
  startYearMonth: yearMonthSchema.optional(),
6071
6958
  endYearMonth: yearMonthSchema.optional(),
6072
- pageSize: z2.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"),
6073
- pageToken: z2.string().optional().describe("nextPageToken from a prior identical request")
6959
+ pageSize: z4.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"),
6960
+ pageToken: z4.string().optional().describe("nextPageToken from a prior identical request")
6074
6961
  },
6075
6962
  async ({
6076
6963
  customerId,
@@ -6092,7 +6979,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6092
6979
  }) => {
6093
6980
  try {
6094
6981
  const startedAt = Date.now();
6095
- const { request, historyRange, seedType } = buildKeywordIdeasRequest({
6982
+ const { request: request2, historyRange, seedType } = buildKeywordIdeasRequest({
6096
6983
  seedKeywords,
6097
6984
  url,
6098
6985
  site,
@@ -6113,7 +7000,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6113
7000
  pageSize,
6114
7001
  historyRange.monthCount
6115
7002
  );
6116
- const response = await client.generateKeywordIdeas(customerId, request);
7003
+ const response = await client.generateKeywordIdeas(customerId, request2);
6117
7004
  const ideas = (response.results ?? []).map(normalizeIdeaResult);
6118
7005
  const nextPageToken = response.nextPageToken ?? null;
6119
7006
  return ok3({
@@ -6162,23 +7049,23 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6162
7049
  "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.",
6163
7050
  {
6164
7051
  customerId: customerIdSchema.describe("Google Ads serving customer ID; use a relevant client account for better estimates"),
6165
- keywords: z2.array(keywordTextSchema).min(1).max(1e3),
7052
+ keywords: z4.array(keywordTextSchema).min(1).max(1e3),
6166
7053
  matchType: matchTypeSchema.optional().default("BROAD"),
6167
- negativeKeywords: z2.array(keywordTextSchema).max(1e3).optional().default([]),
7054
+ negativeKeywords: z4.array(keywordTextSchema).max(1e3).optional().default([]),
6168
7055
  negativeMatchType: matchTypeSchema.optional().default("BROAD"),
6169
- geoTargetIds: z2.array(numericIdSchema).max(20).optional().default([]),
6170
- languageIds: z2.array(numericIdSchema).max(10).optional().default([]),
7056
+ geoTargetIds: z4.array(numericIdSchema).max(20).optional().default([]),
7057
+ languageIds: z4.array(numericIdSchema).max(10).optional().default([]),
6171
7058
  network: networkSchema.optional().default("GOOGLE_SEARCH"),
6172
- biddingStrategy: z2.enum(["MANUAL_CPC", "MAXIMIZE_CLICKS", "MAXIMIZE_CONVERSIONS"]).optional().default("MANUAL_CPC"),
7059
+ biddingStrategy: z4.enum(["MANUAL_CPC", "MAXIMIZE_CLICKS", "MAXIMIZE_CONVERSIONS"]).optional().default("MANUAL_CPC"),
6173
7060
  maxCpcBid: currencyAmountSchema.optional().describe("Bid in standard account-currency units; required for MANUAL_CPC"),
6174
7061
  dailyBudget: currencyAmountSchema.optional().describe("Daily amount in standard currency units; required for maximize strategies and optional for MANUAL_CPC"),
6175
7062
  maxCpcBidCeiling: currencyAmountSchema.optional().describe("Optional standard-currency CPC ceiling for MAXIMIZE_CLICKS"),
6176
- conversionRate: z2.number().finite().min(0).max(1).optional().describe("Expected conversion rate as a decimal, e.g. 0.02 for 2%"),
6177
- currencyCode: z2.string().regex(/^[A-Z]{3}$/).optional().describe("Optional ISO 4217 conversion currency; account currency is used by default"),
7063
+ conversionRate: z4.number().finite().min(0).max(1).optional().describe("Expected conversion rate as a decimal, e.g. 0.02 for 2%"),
7064
+ currencyCode: z4.string().regex(/^[A-Z]{3}$/).optional().describe("Optional ISO 4217 conversion currency; account currency is used by default"),
6178
7065
  startDate: isoDateSchema.optional().describe("Optional inclusive future forecast start YYYY-MM-DD; supply with endDate"),
6179
7066
  endDate: isoDateSchema.optional().describe("Optional inclusive forecast end YYYY-MM-DD, no more than one year ahead; supply with startDate"),
6180
- includeKeywordBreakdown: z2.boolean().optional().default(false).describe("Make independent one-keyword forecast calls in addition to the combined campaign forecast"),
6181
- keywordBreakdownLimit: z2.number().int().min(1).max(20).optional().default(10).describe("Safety cap for additional rate-limited Keyword Planner requests")
7067
+ includeKeywordBreakdown: z4.boolean().optional().default(false).describe("Make independent one-keyword forecast calls in addition to the combined campaign forecast"),
7068
+ keywordBreakdownLimit: z4.number().int().min(1).max(20).optional().default(10).describe("Safety cap for additional rate-limited Keyword Planner requests")
6182
7069
  },
6183
7070
  async ({
6184
7071
  customerId,
@@ -6219,11 +7106,11 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6219
7106
  startDate,
6220
7107
  endDate
6221
7108
  };
6222
- const { request, periodDays } = buildKeywordForecastRequest(baseInput);
7109
+ const { request: request2, periodDays } = buildKeywordForecastRequest(baseInput);
6223
7110
  const deadlineAtMs = includeKeywordBreakdown ? startedAt + FORECAST_WITH_BREAKDOWN_TIME_BUDGET_MS : void 0;
6224
7111
  const response = await client.generateKeywordForecastMetrics(
6225
7112
  customerId,
6226
- request,
7113
+ request2,
6227
7114
  deadlineAtMs
6228
7115
  );
6229
7116
  const campaignForecast = normalizeKeywordForecastMetrics(
@@ -6313,7 +7200,7 @@ function registerGoogleAdsKeywordPlannerTools(server, client, ok3) {
6313
7200
  }
6314
7201
 
6315
7202
  // src/platforms/google-ads/discovery-tools.ts
6316
- import { z as z3 } from "zod";
7203
+ import { z as z5 } from "zod";
6317
7204
  var FIELD_COLUMNS = [
6318
7205
  "name",
6319
7206
  "category",
@@ -6329,12 +7216,12 @@ var FIELD_COLUMNS = [
6329
7216
  "metrics",
6330
7217
  "segments"
6331
7218
  ].join(", ");
6332
- var customerIdSchema2 = z3.string().regex(
7219
+ var customerIdSchema2 = z5.string().regex(
6333
7220
  /^\d[\d-]*\d$|^\d$/,
6334
7221
  "Expected a numeric Google Ads customer ID, with or without dashes"
6335
7222
  );
6336
- var numericIdSchema2 = z3.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
6337
- var fieldCategorySchema = z3.enum([
7223
+ var numericIdSchema2 = z5.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
7224
+ var fieldCategorySchema = z5.enum([
6338
7225
  "RESOURCE",
6339
7226
  "ATTRIBUTE",
6340
7227
  "SEGMENT",
@@ -6389,14 +7276,14 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6389
7276
  "google_ads_search_fields",
6390
7277
  "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.",
6391
7278
  {
6392
- query: z3.string().trim().min(6).max(5e4).optional().describe("Optional raw GoogleAdsField SELECT query. When provided it replaces the structured filters."),
6393
- nameContains: z3.string().trim().min(1).max(250).optional().describe("Case-sensitive field-name substring, e.g. conversion or asset_group"),
7279
+ query: z5.string().trim().min(6).max(5e4).optional().describe("Optional raw GoogleAdsField SELECT query. When provided it replaces the structured filters."),
7280
+ nameContains: z5.string().trim().min(1).max(250).optional().describe("Case-sensitive field-name substring, e.g. conversion or asset_group"),
6394
7281
  category: fieldCategorySchema.optional(),
6395
- selectable: z3.boolean().optional(),
6396
- filterable: z3.boolean().optional(),
6397
- sortable: z3.boolean().optional(),
6398
- pageSize: z3.number().int().min(1).max(1e4).optional().default(500),
6399
- pageToken: z3.string().trim().min(1).max(1e4).optional()
7282
+ selectable: z5.boolean().optional(),
7283
+ filterable: z5.boolean().optional(),
7284
+ sortable: z5.boolean().optional(),
7285
+ pageSize: z5.number().int().min(1).max(1e4).optional().default(500),
7286
+ pageToken: z5.string().trim().min(1).max(1e4).optional()
6400
7287
  },
6401
7288
  async ({ query, nameContains, category, selectable, filterable, sortable, pageSize, pageToken }) => {
6402
7289
  try {
@@ -6439,22 +7326,22 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6439
7326
  "google_ads_suggest_geo_targets",
6440
7327
  "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.",
6441
7328
  {
6442
- locationNames: z3.array(z3.string().trim().min(1).max(250)).min(1).max(25).optional(),
6443
- geoTargetIds: z3.array(numericIdSchema2).min(1).max(25).optional(),
6444
- locale: z3.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"),
6445
- countryCode: z3.string().trim().regex(/^[A-Za-z]{2}$/, "Expected an ISO-3166 alpha-2 country code").optional().transform((value) => value?.toUpperCase())
7329
+ locationNames: z5.array(z5.string().trim().min(1).max(250)).min(1).max(25).optional(),
7330
+ geoTargetIds: z5.array(numericIdSchema2).min(1).max(25).optional(),
7331
+ locale: z5.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"),
7332
+ countryCode: z5.string().trim().regex(/^[A-Za-z]{2}$/, "Expected an ISO-3166 alpha-2 country code").optional().transform((value) => value?.toUpperCase())
6446
7333
  },
6447
7334
  async ({ locationNames, geoTargetIds, locale, countryCode }) => {
6448
7335
  try {
6449
7336
  if (Boolean(locationNames?.length) === Boolean(geoTargetIds?.length)) {
6450
7337
  throw new Error("Provide exactly one of locationNames or geoTargetIds.");
6451
7338
  }
6452
- const request = {
7339
+ const request2 = {
6453
7340
  locale,
6454
7341
  ...countryCode ? { countryCode } : {},
6455
7342
  ...locationNames ? { locationNames: { names: [...new Set(locationNames)] } } : { geoTargets: { geoTargetConstants: [...new Set(geoTargetIds)].map((id) => `geoTargetConstants/${id}`) } }
6456
7343
  };
6457
- const response = await client.suggestGeoTargetConstants(request);
7344
+ const response = await client.suggestGeoTargetConstants(request2);
6458
7345
  const suggestions = (response.geoTargetConstantSuggestions ?? []).map((suggestion) => ({
6459
7346
  searchTerm: suggestion.searchTerm ?? null,
6460
7347
  locale: suggestion.locale ?? locale,
@@ -6468,7 +7355,7 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6468
7355
  dataKind: "geo_target_suggestions",
6469
7356
  suggestions,
6470
7357
  count: suggestions.length,
6471
- request,
7358
+ request: request2,
6472
7359
  readOnly: true,
6473
7360
  warnings: [],
6474
7361
  limitations: ["Reach is approximate and rounded by Google."],
@@ -6484,19 +7371,19 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6484
7371
  "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.",
6485
7372
  {
6486
7373
  customerId: customerIdSchema2.describe("Serving customer ID containing the existing ad groups"),
6487
- keywords: z3.array(z3.string().trim().min(1).max(80)).min(1).max(1e3),
6488
- adGroupIds: z3.array(numericIdSchema2).min(1).max(200).describe("Existing ad group IDs in the same customer account")
7374
+ keywords: z5.array(z5.string().trim().min(1).max(80)).min(1).max(1e3),
7375
+ adGroupIds: z5.array(numericIdSchema2).min(1).max(200).describe("Existing ad group IDs in the same customer account")
6489
7376
  },
6490
7377
  async ({ customerId, keywords, adGroupIds }) => {
6491
7378
  try {
6492
7379
  const cleanCustomerId = customerId.replace(/-/g, "");
6493
- const request = {
7380
+ const request2 = {
6494
7381
  keywords: [...new Set(keywords)],
6495
7382
  adGroups: [...new Set(adGroupIds)].map(
6496
7383
  (id) => `customers/${cleanCustomerId}/adGroups/${id}`
6497
7384
  )
6498
7385
  };
6499
- const response = await client.generateAdGroupThemes(cleanCustomerId, request);
7386
+ const response = await client.generateAdGroupThemes(cleanCustomerId, request2);
6500
7387
  const suggestions = response.adGroupKeywordSuggestions ?? [];
6501
7388
  const unusableAdGroups = response.unusableAdGroups ?? [];
6502
7389
  return ok3({
@@ -6505,8 +7392,8 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6505
7392
  suggestionCount: suggestions.length,
6506
7393
  unusableAdGroups,
6507
7394
  unusableAdGroupCount: unusableAdGroups.length,
6508
- requestedKeywordCount: request.keywords.length,
6509
- requestedAdGroupCount: request.adGroups.length,
7395
+ requestedKeywordCount: request2.keywords.length,
7396
+ requestedAdGroupCount: request2.adGroups.length,
6510
7397
  readOnly: true,
6511
7398
  warnings: unusableAdGroups.length ? ["Google could not use one or more supplied ad groups; inspect unusableAdGroups for their campaign context."] : [],
6512
7399
  limitations: ["This RPC suggests organization into existing ad groups; it does not create themes, ad groups, or keywords."],
@@ -6520,11 +7407,11 @@ function registerGoogleAdsDiscoveryTools(server, client, ok3) {
6520
7407
  }
6521
7408
 
6522
7409
  // src/platforms/google-ads/tools.ts
6523
- var customerIdSchema3 = z4.string().describe("Google Ads customer ID (without dashes, e.g., 1234567890)");
6524
- var loginCustomerIdSchema = z4.string().optional().describe("MCC Manager account ID (required for sub-accounts managed by an MCC)");
6525
- var isoDateSchema2 = z4.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected date format YYYY-MM-DD");
6526
- var numericIdSchema3 = z4.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
6527
- var gaqlEnumSchema = z4.string().regex(/^[A-Z0-9_]+$/, "Expected an uppercase Google Ads enum value");
7410
+ var customerIdSchema3 = z6.string().describe("Google Ads customer ID (without dashes, e.g., 1234567890)");
7411
+ var loginCustomerIdSchema = z6.string().optional().describe("MCC Manager account ID (required for sub-accounts managed by an MCC)");
7412
+ var isoDateSchema2 = z6.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected date format YYYY-MM-DD");
7413
+ var numericIdSchema3 = z6.string().regex(/^\d+$/, "Expected a numeric Google Ads ID");
7414
+ var gaqlEnumSchema = z6.string().regex(/^[A-Z0-9_]+$/, "Expected an uppercase Google Ads enum value");
6528
7415
  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.";
6529
7416
  function isAgentResponseRecord(value) {
6530
7417
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -6687,6 +7574,35 @@ function normalizeChangeEventRange(startDate, endDate) {
6687
7574
  warnings
6688
7575
  };
6689
7576
  }
7577
+ function recordOf(value) {
7578
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
7579
+ }
7580
+ function assetIdFromResourceName(value) {
7581
+ if (typeof value !== "string") return void 0;
7582
+ return value.match(/\/assets\/(\d+)$/)?.[1];
7583
+ }
7584
+ function youtubeDerivedUrls(videoId) {
7585
+ if (typeof videoId !== "string" || !/^[A-Za-z0-9_-]{5,20}$/.test(videoId)) return void 0;
7586
+ return {
7587
+ watch_url: `https://www.youtube.com/watch?v=${videoId}`,
7588
+ embed_url: `https://www.youtube.com/embed/${videoId}`,
7589
+ thumbnail_url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
7590
+ thumbnail_fallback_url: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`
7591
+ };
7592
+ }
7593
+ function collectAssetIds(value, into) {
7594
+ const id = assetIdFromResourceName(value);
7595
+ if (id) {
7596
+ into.add(id);
7597
+ return;
7598
+ }
7599
+ if (Array.isArray(value)) {
7600
+ for (const item of value) collectAssetIds(item, into);
7601
+ return;
7602
+ }
7603
+ const record = recordOf(value);
7604
+ if (record) for (const item of Object.values(record)) collectAssetIds(item, into);
7605
+ }
6690
7606
  async function runGaqlWithFallback(client, customerId, attempts) {
6691
7607
  const warnings = [];
6692
7608
  let lastError;
@@ -6774,7 +7690,7 @@ function registerGoogleAdsTools(server, config) {
6774
7690
  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`,
6775
7691
  {
6776
7692
  customerId: customerIdSchema3,
6777
- query: z4.string().min(10).describe("GAQL query string (SELECT ... FROM ... WHERE ...)")
7693
+ query: z6.string().min(10).describe("GAQL query string (SELECT ... FROM ... WHERE ...)")
6778
7694
  },
6779
7695
  async ({ customerId, query }) => {
6780
7696
  try {
@@ -6791,12 +7707,12 @@ Example: SELECT campaign.name, metrics.impressions FROM campaign WHERE campaign.
6791
7707
  Use google-ads://metrics for available metrics, google-ads://dimensions for dimensions.`,
6792
7708
  {
6793
7709
  customerId: customerIdSchema3,
6794
- resource: z4.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.)"),
6795
- metrics: z4.array(z4.string()).min(1).describe("Metric keys (e.g., impressions, clicks, cost_micros, conversions)"),
6796
- dimensions: z4.array(z4.string()).optional().describe("Dimension keys (e.g., date, campaignName, device)"),
6797
- startDate: z4.string().optional().describe("Start date YYYY-MM-DD"),
6798
- endDate: z4.string().optional().describe("End date YYYY-MM-DD"),
6799
- datePreset: z4.enum([
7710
+ resource: z6.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.)"),
7711
+ metrics: z6.array(z6.string()).min(1).describe("Metric keys (e.g., impressions, clicks, cost_micros, conversions)"),
7712
+ dimensions: z6.array(z6.string()).optional().describe("Dimension keys (e.g., date, campaignName, device)"),
7713
+ startDate: z6.string().optional().describe("Start date YYYY-MM-DD"),
7714
+ endDate: z6.string().optional().describe("End date YYYY-MM-DD"),
7715
+ datePreset: z6.enum([
6800
7716
  "TODAY",
6801
7717
  "YESTERDAY",
6802
7718
  "LAST_7_DAYS",
@@ -6808,9 +7724,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6808
7724
  "THIS_QUARTER",
6809
7725
  "LAST_QUARTER"
6810
7726
  ]).optional().describe("Predefined date range"),
6811
- orderBy: z4.string().optional().describe("Optional GAQL field to order by, e.g. metrics.impressions or campaign.name"),
6812
- orderDirection: z4.enum(["ASC", "DESC"]).optional().default("DESC"),
6813
- limit: z4.number().int().min(1).max(1e4).optional().default(500)
7727
+ orderBy: z6.string().optional().describe("Optional GAQL field to order by, e.g. metrics.impressions or campaign.name"),
7728
+ orderDirection: z6.enum(["ASC", "DESC"]).optional().default("DESC"),
7729
+ limit: z6.number().int().min(1).max(1e4).optional().default(500)
6814
7730
  },
6815
7731
  async ({ customerId, resource, metrics, dimensions, startDate, endDate, datePreset, orderBy, orderDirection, limit }) => {
6816
7732
  try {
@@ -6837,7 +7753,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6837
7753
  gaqlPreview: generateQueryPreview(plan)
6838
7754
  });
6839
7755
  }
6840
- const result = await client.executeQuery({
7756
+ const result2 = await client.executeQuery({
6841
7757
  customerId,
6842
7758
  resource,
6843
7759
  metrics,
@@ -6850,18 +7766,18 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6850
7766
  orderDirection,
6851
7767
  limit
6852
7768
  });
6853
- const debugWarnings = Array.isArray(result.debug.warnings) ? result.debug.warnings : [];
6854
- const debugErrors = Array.isArray(result.debug.errors) ? result.debug.errors : [];
6855
- const zeroMetricLimitWarnings = result.data.length === limit && allReturnedMetricValuesAreZero(result.data, metrics) ? [ZERO_METRIC_LIMIT_WARNING] : [];
7769
+ const debugWarnings = Array.isArray(result2.debug.warnings) ? result2.debug.warnings : [];
7770
+ const debugErrors = Array.isArray(result2.debug.errors) ? result2.debug.errors : [];
7771
+ const zeroMetricLimitWarnings = result2.data.length === limit && allReturnedMetricValuesAreZero(result2.data, metrics) ? [ZERO_METRIC_LIMIT_WARNING] : [];
6856
7772
  const warnings = [...dateInput.warnings, ...debugWarnings, ...zeroMetricLimitWarnings];
6857
7773
  return ok({
6858
7774
  status: debugErrors.length > 0 ? "error" : "ok",
6859
- data: result.data,
6860
- rowCount: result.data.length,
7775
+ data: result2.data,
7776
+ rowCount: result2.data.length,
6861
7777
  errors: debugErrors,
6862
7778
  warnings,
6863
7779
  debug: {
6864
- ...result.debug,
7780
+ ...result2.debug,
6865
7781
  warnings,
6866
7782
  executionTimeMs: Date.now() - startTime,
6867
7783
  gaqlPreview: generateQueryPreview(plan)
@@ -6877,8 +7793,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6877
7793
  "List campaigns for a Google Ads account with status, budget, channel type, and bidding strategy.",
6878
7794
  {
6879
7795
  customerId: customerIdSchema3,
6880
- statusFilter: z4.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
6881
- limit: z4.number().int().min(1).max(1e3).optional().default(100)
7796
+ statusFilter: z6.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
7797
+ limit: z6.number().int().min(1).max(1e3).optional().default(100)
6882
7798
  },
6883
7799
  async ({ customerId, statusFilter, limit }) => {
6884
7800
  try {
@@ -6897,9 +7813,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6897
7813
  "List ad groups for a Google Ads account, optionally filtered by campaign.",
6898
7814
  {
6899
7815
  customerId: customerIdSchema3,
6900
- campaignId: z4.string().optional().describe("Filter by campaign ID"),
6901
- statusFilter: z4.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
6902
- limit: z4.number().int().min(1).max(1e3).optional().default(100)
7816
+ campaignId: z6.string().optional().describe("Filter by campaign ID"),
7817
+ statusFilter: z6.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
7818
+ limit: z6.number().int().min(1).max(1e3).optional().default(100)
6903
7819
  },
6904
7820
  async ({ customerId, campaignId, statusFilter, limit }) => {
6905
7821
  try {
@@ -6921,9 +7837,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6921
7837
  "Get keyword-level performance data from keyword_view resource. Shows quality score, impressions, clicks, cost.",
6922
7838
  {
6923
7839
  customerId: customerIdSchema3,
6924
- startDate: z4.string().describe("Start date YYYY-MM-DD"),
6925
- endDate: z4.string().describe("End date YYYY-MM-DD"),
6926
- limit: z4.number().int().min(1).max(1e3).optional().default(100)
7840
+ startDate: z6.string().describe("Start date YYYY-MM-DD"),
7841
+ endDate: z6.string().describe("End date YYYY-MM-DD"),
7842
+ limit: z6.number().int().min(1).max(1e3).optional().default(100)
6927
7843
  },
6928
7844
  async ({ customerId, startDate, endDate, limit }) => {
6929
7845
  try {
@@ -6939,14 +7855,14 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6939
7855
  "google_ads_validate_query",
6940
7856
  "Validate metric/dimension/resource compatibility BEFORE executing a query. Checks segment restrictions and resource availability.",
6941
7857
  {
6942
- metrics: z4.array(z4.string()).min(1).describe("Metric keys to validate"),
6943
- dimensions: z4.array(z4.string()).optional().describe("Dimension keys to validate"),
6944
- resource: z4.string().optional().default("campaign")
7858
+ metrics: z6.array(z6.string()).min(1).describe("Metric keys to validate"),
7859
+ dimensions: z6.array(z6.string()).optional().describe("Dimension keys to validate"),
7860
+ resource: z6.string().optional().default("campaign")
6945
7861
  },
6946
7862
  async ({ metrics, dimensions, resource }) => {
6947
7863
  try {
6948
- const result = validateQuerySelection(metrics, dimensions ?? [], resource, dimensions ?? []);
6949
- return ok(result);
7864
+ const result2 = validateQuerySelection(metrics, dimensions ?? [], resource, dimensions ?? []);
7865
+ return ok(result2);
6950
7866
  } catch (e) {
6951
7867
  return formatMcpToolError(e);
6952
7868
  }
@@ -6976,7 +7892,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
6976
7892
  warnings.push("GOOGLE_ADS_LOGIN_CUSTOMER_ID is configured but was not returned by listAccessibleCustomers; verify MCC access if child account queries fail.");
6977
7893
  }
6978
7894
  if (!loginCustomerId && accessibleCustomers.some((customer) => customer.manager)) {
6979
- 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.");
7895
+ warnings.push(
7896
+ "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."
7897
+ );
6980
7898
  }
6981
7899
  return ok({
6982
7900
  status: "ok",
@@ -7007,7 +7925,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7007
7925
  "List accessible customers and, where possible, manager/client relationships from GAQL customer_client. Falls back to accessible customers if hierarchy queries are unavailable.",
7008
7926
  {
7009
7927
  managerCustomerId: loginCustomerIdSchema,
7010
- includeInactive: z4.boolean().optional().default(false).describe("Include non-ENABLED customer_client links")
7928
+ includeInactive: z6.boolean().optional().default(false).describe("Include non-ENABLED customer_client links")
7011
7929
  },
7012
7930
  async ({ managerCustomerId, includeInactive }) => {
7013
7931
  try {
@@ -7082,8 +8000,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7082
8000
  "List conversion actions with status, type, category, primary/include-in-conversions flags, owner customer, and last activity dates when supported.",
7083
8001
  {
7084
8002
  customerId: customerIdSchema3,
7085
- statusFilter: z4.enum(["ENABLED", "HIDDEN", "REMOVED"]).optional(),
7086
- limit: z4.number().int().min(1).max(1e3).optional().default(1e3)
8003
+ statusFilter: z6.enum(["ENABLED", "HIDDEN", "REMOVED"]).optional(),
8004
+ limit: z6.number().int().min(1).max(1e3).optional().default(1e3)
7087
8005
  },
7088
8006
  async ({ customerId, statusFilter, limit }) => {
7089
8007
  try {
@@ -7122,11 +8040,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7122
8040
  ORDER BY conversion_action.name ASC
7123
8041
  LIMIT ${limit}
7124
8042
  `);
7125
- const result = await runGaqlWithFallback(client, customerId, [
8043
+ const result2 = await runGaqlWithFallback(client, customerId, [
7126
8044
  { label: "conversion_action_with_last_activity", gaql: richGaql, failureWarning: "Conversion action query with last activity metrics failed" },
7127
8045
  { label: "conversion_action_minimal", gaql: fallbackGaql }
7128
8046
  ]);
7129
- return ok({ conversionActions: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8047
+ return ok({ conversionActions: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7130
8048
  } catch (e) {
7131
8049
  return formatMcpToolError(e);
7132
8050
  }
@@ -7141,10 +8059,10 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7141
8059
  endDate: isoDateSchema2.optional().describe("End date YYYY-MM-DD. Defaults to today."),
7142
8060
  resourceType: gaqlEnumSchema.optional().describe("Optional ChangeEventResourceType filter, e.g. CAMPAIGN or AD_GROUP_AD"),
7143
8061
  operation: gaqlEnumSchema.optional().describe("Optional ResourceChangeOperation filter, e.g. CREATE, UPDATE, REMOVE"),
7144
- userEmail: z4.string().email().optional().describe("Optional user email filter"),
7145
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8062
+ userEmail: z6.string().email().optional().describe("Optional user email filter"),
8063
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
7146
8064
  },
7147
- async ({ customerId, startDate, endDate, resourceType, operation, userEmail, limit }) => {
8065
+ async ({ customerId, startDate, endDate, resourceType, operation: operation2, userEmail, limit }) => {
7148
8066
  try {
7149
8067
  const range = normalizeChangeEventRange(startDate, endDate);
7150
8068
  const where = [
@@ -7152,7 +8070,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7152
8070
  `change_event.change_date_time < '${range.endExclusiveDate}'`
7153
8071
  ];
7154
8072
  if (resourceType) where.push(`change_event.change_resource_type = '${resourceType}'`);
7155
- if (operation) where.push(`change_event.resource_change_operation = '${operation}'`);
8073
+ if (operation2) where.push(`change_event.resource_change_operation = '${operation2}'`);
7156
8074
  if (userEmail) where.push(`change_event.user_email = '${quoteGaqlString(userEmail)}'`);
7157
8075
  const gaql = oneLineGaql(`
7158
8076
  SELECT
@@ -7190,8 +8108,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7190
8108
  {
7191
8109
  customerId: customerIdSchema3,
7192
8110
  typeFilter: gaqlEnumSchema.optional().describe("Optional RecommendationType enum filter"),
7193
- includeDismissed: z4.boolean().optional().default(false),
7194
- limit: z4.number().int().min(1).max(1e3).optional().default(1e3)
8111
+ includeDismissed: z6.boolean().optional().default(false),
8112
+ limit: z6.number().int().min(1).max(1e3).optional().default(1e3)
7195
8113
  },
7196
8114
  async ({ customerId, typeFilter, includeDismissed, limit }) => {
7197
8115
  try {
@@ -7231,11 +8149,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7231
8149
  ORDER BY recommendation.type ASC
7232
8150
  LIMIT ${limit}
7233
8151
  `);
7234
- const result = await runGaqlWithFallback(client, customerId, [
8152
+ const result2 = await runGaqlWithFallback(client, customerId, [
7235
8153
  { label: "recommendations_with_impact", gaql: richGaql, failureWarning: "Recommendation query with attributed resources/impact failed" },
7236
8154
  { label: "recommendations_minimal", gaql: fallbackGaql }
7237
8155
  ]);
7238
- return ok({ recommendations: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8156
+ return ok({ recommendations: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7239
8157
  } catch (e) {
7240
8158
  return formatMcpToolError(e);
7241
8159
  }
@@ -7246,8 +8164,8 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7246
8164
  "List campaign budgets with amount, status, delivery method, and recommended budget fields when supported.",
7247
8165
  {
7248
8166
  customerId: customerIdSchema3,
7249
- statusFilter: z4.enum(["ENABLED", "REMOVED", "UNKNOWN", "UNSPECIFIED"]).optional(),
7250
- limit: z4.number().int().min(1).max(1e3).optional().default(1e3)
8167
+ statusFilter: z6.enum(["ENABLED", "REMOVED", "UNKNOWN", "UNSPECIFIED"]).optional(),
8168
+ limit: z6.number().int().min(1).max(1e3).optional().default(1e3)
7251
8169
  },
7252
8170
  async ({ customerId, statusFilter, limit }) => {
7253
8171
  try {
@@ -7291,11 +8209,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7291
8209
  ORDER BY campaign_budget.name ASC
7292
8210
  LIMIT ${limit}
7293
8211
  `);
7294
- const result = await runGaqlWithFallback(client, customerId, [
8212
+ const result2 = await runGaqlWithFallback(client, customerId, [
7295
8213
  { label: "campaign_budget_with_recommendations", gaql: richGaql, failureWarning: "Campaign budget query with recommended budget fields failed" },
7296
8214
  { label: "campaign_budget_minimal", gaql: fallbackGaql }
7297
8215
  ]);
7298
- return ok({ budgets: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8216
+ return ok({ budgets: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7299
8217
  } catch (e) {
7300
8218
  return formatMcpToolError(e);
7301
8219
  }
@@ -7309,7 +8227,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7309
8227
  startDate: isoDateSchema2.optional().describe("Optional start date YYYY-MM-DD for metrics"),
7310
8228
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD for metrics"),
7311
8229
  typeFilter: gaqlEnumSchema.optional().describe("Optional BiddingStrategyType enum filter"),
7312
- limit: z4.number().int().min(1).max(1e3).optional().default(1e3)
8230
+ limit: z6.number().int().min(1).max(1e3).optional().default(1e3)
7313
8231
  },
7314
8232
  async ({ customerId, startDate, endDate, typeFilter, limit }) => {
7315
8233
  try {
@@ -7356,11 +8274,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7356
8274
  ORDER BY bidding_strategy.name ASC
7357
8275
  LIMIT ${limit}
7358
8276
  `);
7359
- const result = await runGaqlWithFallback(client, customerId, [
8277
+ const result2 = await runGaqlWithFallback(client, customerId, [
7360
8278
  { label: "bidding_strategy_requested", gaql, failureWarning: "Bidding strategy query with requested fields failed" },
7361
8279
  { label: "bidding_strategy_structure", gaql: fallbackGaql }
7362
8280
  ]);
7363
- return ok({ biddingStrategies: result.rows, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8281
+ return ok({ biddingStrategies: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7364
8282
  } catch (e) {
7365
8283
  return formatMcpToolError(e);
7366
8284
  }
@@ -7371,13 +8289,13 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7371
8289
  "Fetch search term performance from search_term_view or campaign_search_term_insight depending on reportType.",
7372
8290
  {
7373
8291
  customerId: customerIdSchema3,
7374
- reportType: z4.enum(["search_term_view", "campaign_search_term_insight"]).optional().default("search_term_view"),
8292
+ reportType: z6.enum(["search_term_view", "campaign_search_term_insight"]).optional().default("search_term_view"),
7375
8293
  startDate: isoDateSchema2.describe("Start date YYYY-MM-DD"),
7376
8294
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
7377
8295
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7378
8296
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter. Applies directly to search_term_view and via segments.ad_group for insight reports."),
7379
- searchTermContains: z4.string().min(1).optional().describe("Optional substring filter for search_term_view"),
7380
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8297
+ searchTermContains: z6.string().min(1).optional().describe("Optional substring filter for search_term_view"),
8298
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
7381
8299
  },
7382
8300
  async ({ customerId, reportType, startDate, endDate, campaignId, adGroupId, searchTermContains, limit }) => {
7383
8301
  try {
@@ -7450,7 +8368,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7450
8368
  startDate: isoDateSchema2.describe("Start date YYYY-MM-DD"),
7451
8369
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
7452
8370
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7453
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8371
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
7454
8372
  },
7455
8373
  async ({ customerId, startDate, endDate, campaignId, limit }) => {
7456
8374
  try {
@@ -7494,11 +8412,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7494
8412
  ORDER BY metrics.clicks DESC
7495
8413
  LIMIT ${limit}
7496
8414
  `);
7497
- const result = await runGaqlWithFallback(client, customerId, [
8415
+ const result2 = await runGaqlWithFallback(client, customerId, [
7498
8416
  { label: "landing_page_with_quality_metrics", gaql: richGaql, failureWarning: "Landing page query with quality metrics failed" },
7499
8417
  { label: "landing_page_minimal", gaql: fallbackGaql }
7500
8418
  ]);
7501
- return ok({ landingPages: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8419
+ return ok({ landingPages: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7502
8420
  } catch (e) {
7503
8421
  return formatMcpToolError(e);
7504
8422
  }
@@ -7511,27 +8429,30 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7511
8429
  customerId: customerIdSchema3,
7512
8430
  startDate: isoDateSchema2.optional().describe("Optional start date YYYY-MM-DD for performance metrics"),
7513
8431
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD for performance metrics"),
8432
+ metrics: z6.array(z6.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."),
7514
8433
  campaignId: numericIdSchema3.optional().describe("Optional Performance Max campaign ID filter"),
7515
8434
  assetGroupId: numericIdSchema3.optional().describe("Optional asset group ID filter"),
8435
+ assetGroupIds: z6.array(numericIdSchema3).min(1).max(10).optional().describe("Restrict reporting to these asset groups."),
8436
+ assetId: numericIdSchema3.optional().describe("Restrict reporting to one asset."),
8437
+ daily: z6.boolean().optional().default(false).describe("Segment dated performance by segments.date."),
7516
8438
  fieldType: gaqlEnumSchema.optional().describe("Optional AssetFieldType enum filter, e.g. HEADLINE, LONG_HEADLINE, MARKETING_IMAGE"),
7517
- statusFilter: z4.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
7518
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8439
+ statusFilter: z6.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
8440
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
7519
8441
  },
7520
- async ({ customerId, startDate, endDate, campaignId, assetGroupId, fieldType, statusFilter, limit }) => {
8442
+ async ({ customerId, startDate, endDate, metrics, campaignId, assetGroupId, assetGroupIds, assetId, daily, fieldType, statusFilter, limit }) => {
7521
8443
  try {
7522
8444
  const warnings = [];
7523
8445
  const where = ["campaign.advertising_channel_type = 'PERFORMANCE_MAX'", ...optionalDateRange(startDate, endDate)];
7524
8446
  if (campaignId) where.push(`campaign.id = ${campaignId}`);
7525
8447
  if (assetGroupId) where.push(`asset_group.id = ${assetGroupId}`);
8448
+ if (assetGroupIds) where.push(`asset_group.id IN (${assetGroupIds.join(",")})`);
8449
+ if (assetId) where.push(`asset.id = ${assetId}`);
8450
+ if (daily && (!startDate || !endDate)) throw new Error("Daily reporting requires both startDate and endDate.");
7526
8451
  if (fieldType) where.push(`asset_group_asset.field_type = '${fieldType}'`);
7527
8452
  if (statusFilter) where.push(`asset_group_asset.status = '${statusFilter}'`);
7528
8453
  if (!startDate && !endDate) warnings.push("No date range provided; performance metrics were omitted and only asset structure was returned.");
7529
- const metricsFields = startDate || endDate ? `,
7530
- metrics.impressions,
7531
- metrics.clicks,
7532
- metrics.cost_micros,
7533
- metrics.conversions,
7534
- metrics.conversions_value` : "";
8454
+ const selectedMetrics = [.../* @__PURE__ */ new Set(["metrics.impressions", "metrics.cost_micros", ...metrics ?? PMAX_METRIC_FIELDS.slice(0, 5)])];
8455
+ const metricsFields = startDate || endDate ? `, ${selectedMetrics.join(", ")}${daily ? ", segments.date" : ""}` : "";
7535
8456
  const richGaql = oneLineGaql(`
7536
8457
  SELECT
7537
8458
  campaign.id,
@@ -7578,11 +8499,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7578
8499
  ORDER BY campaign.name ASC, asset_group.name ASC
7579
8500
  LIMIT ${limit}
7580
8501
  `);
7581
- const result = await runGaqlWithFallback(client, customerId, [
8502
+ const result2 = await runGaqlWithFallback(client, customerId, [
7582
8503
  { label: "pmax_assets_with_asset_details", gaql: richGaql, failureWarning: "PMax asset query with asset details failed" },
7583
8504
  { label: "pmax_assets_minimal", gaql: fallbackGaql }
7584
8505
  ]);
7585
- return ok({ pmaxAssets: result.rows, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8506
+ return ok({ pmaxAssets: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7586
8507
  } catch (e) {
7587
8508
  return formatMcpToolError(e);
7588
8509
  }
@@ -7593,7 +8514,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7593
8514
  "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.",
7594
8515
  {
7595
8516
  customerId: customerIdSchema3,
7596
- level: z4.enum(["campaign", "ad_group", "bidding_strategy"]).optional().default("campaign").describe("Simulation resource level to query"),
8517
+ level: z6.enum(["campaign", "ad_group", "bidding_strategy"]).optional().default("campaign").describe("Simulation resource level to query"),
7597
8518
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter. Applies directly to campaign simulations and as campaign context for ad group simulations."),
7598
8519
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter for ad_group simulations"),
7599
8520
  biddingStrategyId: numericIdSchema3.optional().describe("Optional portfolio bidding strategy ID filter for bidding_strategy simulations"),
@@ -7601,7 +8522,7 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7601
8522
  simulationEndDate: isoDateSchema2.optional().describe("Optional maximum simulation end date YYYY-MM-DD"),
7602
8523
  typeFilter: gaqlEnumSchema.optional().describe("Optional SimulationType enum, e.g. BUDGET, TARGET_CPA, TARGET_ROAS, CPC_BID"),
7603
8524
  modificationMethod: gaqlEnumSchema.optional().describe("Optional SimulationModificationMethod enum, e.g. UNIFORM, SCALING, DEFAULT"),
7604
- limit: z4.number().int().min(1).max(1e3).optional().default(1e3)
8525
+ limit: z6.number().int().min(1).max(1e3).optional().default(1e3)
7605
8526
  },
7606
8527
  async ({ customerId, level, campaignId, adGroupId, biddingStrategyId, simulationStartDate, simulationEndDate, typeFilter, modificationMethod, limit }) => {
7607
8528
  try {
@@ -7730,11 +8651,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7730
8651
  ORDER BY ${resource}.start_date DESC
7731
8652
  LIMIT ${limit}
7732
8653
  `);
7733
- const result = await runGaqlWithFallback(client, customerId, [
8654
+ const result2 = await runGaqlWithFallback(client, customerId, [
7734
8655
  { label: `${resource}_with_points`, gaql: richGaql, failureWarning: "Simulation query with projected point lists failed" },
7735
8656
  { label: `${resource}_metadata`, gaql: fallbackGaql }
7736
8657
  ]);
7737
- return ok({ simulations: result.rows, level, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8658
+ return ok({ simulations: result2.rows, level, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7738
8659
  } catch (e) {
7739
8660
  return formatMcpToolError(e);
7740
8661
  }
@@ -7749,9 +8670,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7749
8670
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
7750
8671
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7751
8672
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter"),
7752
- searchTermContains: z4.string().min(1).optional().describe("Optional search term substring filter"),
7753
- serpType: z4.enum(["ADS_AND_ORGANIC", "ADS_ONLY", "ORGANIC_ONLY", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional search engine results page type segment filter"),
7754
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8673
+ searchTermContains: z6.string().min(1).optional().describe("Optional search term substring filter"),
8674
+ serpType: z6.enum(["ADS_AND_ORGANIC", "ADS_ONLY", "ORGANIC_ONLY", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional search engine results page type segment filter"),
8675
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
7755
8676
  },
7756
8677
  async ({ customerId, startDate, endDate, campaignId, adGroupId, searchTermContains, serpType, limit }) => {
7757
8678
  try {
@@ -7811,11 +8732,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7811
8732
  ORDER BY metrics.clicks DESC
7812
8733
  LIMIT ${limit}
7813
8734
  `);
7814
- const result = await runGaqlWithFallback(client, customerId, [
8735
+ const result2 = await runGaqlWithFallback(client, customerId, [
7815
8736
  { 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" },
7816
8737
  { label: "search_term_view_paid_only", gaql: fallbackGaql }
7817
8738
  ]);
7818
- return ok({ searchTerms: result.rows, source: result.queryLabel, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8739
+ return ok({ searchTerms: result2.rows, source: result2.queryLabel, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
7819
8740
  } catch (e) {
7820
8741
  return formatMcpToolError(e);
7821
8742
  }
@@ -7826,17 +8747,17 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7826
8747
  "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.",
7827
8748
  {
7828
8749
  customerId: customerIdSchema3,
7829
- scope: z4.enum(["account", "campaign", "ad_group"]).optional().default("account").describe("shopping_product scope. ad_group scope requires campaignId and adGroupId."),
8750
+ scope: z6.enum(["account", "campaign", "ad_group"]).optional().default("account").describe("shopping_product scope. ad_group scope requires campaignId and adGroupId."),
7830
8751
  startDate: isoDateSchema2.optional().describe("Optional start date YYYY-MM-DD. Used only as a WHERE filter; shopping_product cannot segment by date."),
7831
8752
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD. Used only as a WHERE filter; shopping_product cannot segment by date."),
7832
8753
  campaignId: numericIdSchema3.optional().describe("Campaign ID for campaign or ad_group scope"),
7833
8754
  adGroupId: numericIdSchema3.optional().describe("Ad group ID for ad_group scope"),
7834
8755
  merchantCenterId: numericIdSchema3.optional().describe("Optional Merchant Center ID filter"),
7835
- itemId: z4.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
7836
- titleContains: z4.string().min(1).optional().describe("Optional product title substring filter"),
7837
- feedLabel: z4.string().min(1).optional().describe("Optional feed label filter"),
7838
- statusFilter: z4.enum(["ELIGIBLE", "ELIGIBLE_LIMITED", "NOT_ELIGIBLE", "UNKNOWN", "UNSPECIFIED"]).optional(),
7839
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8756
+ itemId: z6.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
8757
+ titleContains: z6.string().min(1).optional().describe("Optional product title substring filter"),
8758
+ feedLabel: z6.string().min(1).optional().describe("Optional feed label filter"),
8759
+ statusFilter: z6.enum(["ELIGIBLE", "ELIGIBLE_LIMITED", "NOT_ELIGIBLE", "UNKNOWN", "UNSPECIFIED"]).optional(),
8760
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
7840
8761
  },
7841
8762
  async ({ customerId, scope, startDate, endDate, campaignId, adGroupId, merchantCenterId, itemId, titleContains, feedLabel, statusFilter, limit }) => {
7842
8763
  try {
@@ -7923,11 +8844,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7923
8844
  ORDER BY shopping_product.title ASC
7924
8845
  LIMIT ${limit}
7925
8846
  `);
7926
- const result = await runGaqlWithFallback(client, customerId, [
8847
+ const result2 = await runGaqlWithFallback(client, customerId, [
7927
8848
  { label: "shopping_product_with_metrics_and_issues", gaql: richGaql, failureWarning: "Shopping product query with performance/cart/issue fields failed" },
7928
8849
  { label: "shopping_product_catalog", gaql: fallbackGaql }
7929
8850
  ]);
7930
- return ok({ shoppingProducts: result.rows, scope, count: result.rows.length, gaql: result.gaql, warnings: [...warnings, ...result.warnings] });
8851
+ return ok({ shoppingProducts: result2.rows, scope, count: result2.rows.length, gaql: result2.gaql, warnings: [...warnings, ...result2.warnings] });
7931
8852
  } catch (e) {
7932
8853
  return formatMcpToolError(e);
7933
8854
  }
@@ -7943,11 +8864,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
7943
8864
  campaignId: numericIdSchema3.optional().describe("Optional campaign ID filter"),
7944
8865
  adGroupId: numericIdSchema3.optional().describe("Optional ad group ID filter"),
7945
8866
  merchantCenterId: numericIdSchema3.optional().describe("Optional Merchant Center ID filter"),
7946
- itemId: z4.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
7947
- titleContains: z4.string().min(1).optional().describe("Optional product title substring filter"),
7948
- brand: z4.string().min(1).optional().describe("Optional product brand filter"),
7949
- feedLabel: z4.string().min(1).optional().describe("Optional product feed label filter"),
7950
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8867
+ itemId: z6.string().min(1).optional().describe("Optional Merchant Center item ID filter"),
8868
+ titleContains: z6.string().min(1).optional().describe("Optional product title substring filter"),
8869
+ brand: z6.string().min(1).optional().describe("Optional product brand filter"),
8870
+ feedLabel: z6.string().min(1).optional().describe("Optional product feed label filter"),
8871
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
7951
8872
  },
7952
8873
  async ({ customerId, startDate, endDate, campaignId, adGroupId, merchantCenterId, itemId, titleContains, brand, feedLabel, limit }) => {
7953
8874
  try {
@@ -8007,11 +8928,11 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8007
8928
  ORDER BY metrics.cost_micros DESC
8008
8929
  LIMIT ${limit}
8009
8930
  `);
8010
- const result = await runGaqlWithFallback(client, customerId, [
8931
+ const result2 = await runGaqlWithFallback(client, customerId, [
8011
8932
  { label: "shopping_performance_with_cart_metrics", gaql: richGaql, failureWarning: "Shopping performance query with cart/profit metrics failed" },
8012
8933
  { label: "shopping_performance_core", gaql: fallbackGaql }
8013
8934
  ]);
8014
- return ok({ shoppingPerformance: result.rows, count: result.rows.length, gaql: result.gaql, warnings: result.warnings });
8935
+ return ok({ shoppingPerformance: result2.rows, count: result2.rows.length, gaql: result2.gaql, warnings: result2.warnings });
8015
8936
  } catch (e) {
8016
8937
  return formatMcpToolError(e);
8017
8938
  }
@@ -8025,9 +8946,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8025
8946
  startDate: isoDateSchema2.describe("Start date YYYY-MM-DD"),
8026
8947
  endDate: isoDateSchema2.describe("End date YYYY-MM-DD"),
8027
8948
  campaignId: numericIdSchema3.optional().describe("Optional Performance Max campaign ID filter"),
8028
- placementType: z4.enum(["WEBSITE", "MOBILE_APPLICATION", "YOUTUBE_VIDEO", "YOUTUBE_CHANNEL", "GOOGLE_PRODUCTS", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional PlacementType enum filter"),
8029
- placementContains: z4.string().min(1).optional().describe("Optional substring filter for the placement string"),
8030
- limit: z4.number().int().min(1).max(1e4).optional().default(1e3)
8949
+ placementType: z6.enum(["WEBSITE", "MOBILE_APPLICATION", "YOUTUBE_VIDEO", "YOUTUBE_CHANNEL", "GOOGLE_PRODUCTS", "UNKNOWN", "UNSPECIFIED"]).optional().describe("Optional PlacementType enum filter"),
8950
+ placementContains: z6.string().min(1).optional().describe("Optional substring filter for the placement string"),
8951
+ limit: z6.number().int().min(1).max(1e4).optional().default(1e3)
8031
8952
  },
8032
8953
  async ({ customerId, startDate, endDate, campaignId, placementType, placementContains, limit }) => {
8033
8954
  try {
@@ -8063,16 +8984,16 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8063
8984
  ORDER BY metrics.impressions DESC
8064
8985
  LIMIT ${limit}
8065
8986
  `);
8066
- const result = await runGaqlWithFallback(client, customerId, [
8987
+ const result2 = await runGaqlWithFallback(client, customerId, [
8067
8988
  { label: "pmax_placements_with_target_url_and_campaign", gaql: richGaql, failureWarning: "PMax placement query with target URL/campaign context failed" },
8068
8989
  { label: "pmax_placements_core", gaql: fallbackGaql }
8069
8990
  ]);
8070
8991
  return ok({
8071
- placements: result.rows,
8072
- pmaxPlacements: result.rows,
8073
- count: result.rows.length,
8074
- gaql: result.gaql,
8075
- warnings: [...warnings, ...result.warnings]
8992
+ placements: result2.rows,
8993
+ pmaxPlacements: result2.rows,
8994
+ count: result2.rows.length,
8995
+ gaql: result2.gaql,
8996
+ warnings: [...warnings, ...result2.warnings]
8076
8997
  });
8077
8998
  } catch (e) {
8078
8999
  return formatMcpToolError(e);
@@ -8088,9 +9009,9 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8088
9009
  endDate: isoDateSchema2.optional().describe("Optional end date YYYY-MM-DD for asset group metrics/top combinations"),
8089
9010
  campaignId: numericIdSchema3.optional().describe("Optional Performance Max campaign ID filter"),
8090
9011
  assetGroupId: numericIdSchema3.optional().describe("Optional asset group ID filter"),
8091
- statusFilter: z4.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
8092
- includeTopCombinations: z4.boolean().optional().default(true).describe("Also query asset_group_top_combination_view when available"),
8093
- limit: z4.number().int().min(1).max(1e3).optional().default(1e3)
9012
+ statusFilter: z6.enum(["ENABLED", "PAUSED", "REMOVED"]).optional(),
9013
+ includeTopCombinations: z6.boolean().optional().default(true).describe("Also query asset_group_top_combination_view when available"),
9014
+ limit: z6.number().int().min(1).max(1e3).optional().default(1e3)
8094
9015
  },
8095
9016
  async ({ customerId, startDate, endDate, campaignId, assetGroupId, statusFilter, includeTopCombinations, limit }) => {
8096
9017
  try {
@@ -8190,6 +9111,207 @@ Use google-ads://metrics for available metrics, google-ads://dimensions for dime
8190
9111
  }
8191
9112
  }
8192
9113
  );
9114
+ server.tool(
9115
+ "google_ads_list_image_assets",
9116
+ "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.",
9117
+ {
9118
+ customerId: customerIdSchema3,
9119
+ beforeAssetId: numericIdSchema3.optional().describe("Continue the descending library before this last-seen asset ID. Use the same filters."),
9120
+ assetIds: z6.array(numericIdSchema3).min(1).max(8).optional().describe("Resolve these exact native asset IDs, without scanning the library."),
9121
+ nameFilter: z6.string().optional().describe("Only assets whose file name contains this text"),
9122
+ minWidth: z6.number().int().min(1).optional().describe("Only images at least this wide, in pixels"),
9123
+ minHeight: z6.number().int().min(1).optional().describe("Only images at least this tall, in pixels"),
9124
+ limit: z6.number().int().min(1).max(1e4).optional().default(500)
9125
+ },
9126
+ async ({ customerId, beforeAssetId, assetIds, nameFilter, minWidth, minHeight, limit }) => {
9127
+ try {
9128
+ const where = ["asset.type = 'IMAGE'"];
9129
+ if (beforeAssetId) where.push(`asset.id < ${beforeAssetId}`);
9130
+ if (assetIds?.length) where.push(`asset.id IN (${assetIds.join(",")})`);
9131
+ if (nameFilter) where.push(`asset.name LIKE '%${quoteGaqlString(nameFilter)}%'`);
9132
+ if (minWidth) where.push(`asset.image_asset.full_size.width_pixels >= ${minWidth}`);
9133
+ if (minHeight) where.push(`asset.image_asset.full_size.height_pixels >= ${minHeight}`);
9134
+ const { rows, gaql, warnings } = await runGaqlWithFallback(client, customerId, [{
9135
+ label: "image asset library",
9136
+ gaql: oneLineGaql(`
9137
+ SELECT
9138
+ asset.id, asset.name, asset.type, asset.source,
9139
+ asset.image_asset.full_size.url,
9140
+ asset.image_asset.full_size.width_pixels,
9141
+ asset.image_asset.full_size.height_pixels,
9142
+ asset.image_asset.file_size,
9143
+ asset.image_asset.mime_type
9144
+ FROM asset${buildWhere(where)} ORDER BY asset.id DESC LIMIT ${limit}`)
9145
+ }]);
9146
+ return ok({
9147
+ imageAssets: rows,
9148
+ count: rows.length,
9149
+ gaql,
9150
+ warnings,
9151
+ limitations: [
9152
+ "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.",
9153
+ "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.",
9154
+ "Shopping/PLA product visuals come from Merchant Center feeds and never appear in this asset library."
9155
+ ],
9156
+ nextActions: rows.length >= limit ? ["The LIMIT was reached; narrow with nameFilter or dimensions, or raise limit."] : []
9157
+ });
9158
+ } catch (e) {
9159
+ return formatMcpToolError(e);
9160
+ }
9161
+ }
9162
+ );
9163
+ server.tool(
9164
+ "google_ads_list_video_assets",
9165
+ "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.",
9166
+ {
9167
+ customerId: customerIdSchema3,
9168
+ beforeAssetId: numericIdSchema3.optional().describe("Continue the descending library before this last-seen asset ID. Use the same filters."),
9169
+ assetIds: z6.array(numericIdSchema3).min(1).max(8).optional().describe("Resolve these exact native asset IDs, without scanning the library."),
9170
+ nameFilter: z6.string().optional().describe("Only assets whose name contains this text"),
9171
+ limit: z6.number().int().min(1).max(1e4).optional().default(500)
9172
+ },
9173
+ async ({ customerId, beforeAssetId, assetIds, nameFilter, limit }) => {
9174
+ try {
9175
+ const where = ["asset.type = 'YOUTUBE_VIDEO'"];
9176
+ if (beforeAssetId) where.push(`asset.id < ${beforeAssetId}`);
9177
+ if (assetIds?.length) where.push(`asset.id IN (${assetIds.join(",")})`);
9178
+ if (nameFilter) where.push(`asset.name LIKE '%${quoteGaqlString(nameFilter)}%'`);
9179
+ const { rows, gaql, warnings } = await runGaqlWithFallback(client, customerId, [{
9180
+ label: "video asset library",
9181
+ gaql: oneLineGaql(`
9182
+ SELECT
9183
+ asset.id, asset.name, asset.type, asset.source,
9184
+ asset.youtube_video_asset.youtube_video_id,
9185
+ asset.youtube_video_asset.youtube_video_title
9186
+ FROM asset${buildWhere(where)} ORDER BY asset.id DESC LIMIT ${limit}`)
9187
+ }]);
9188
+ const videoAssets = rows.map((row) => {
9189
+ const asset = recordOf(recordOf(row)?.asset);
9190
+ const youtube = recordOf(asset?.youtubeVideoAsset);
9191
+ return { ...row, derivedUrls: youtubeDerivedUrls(youtube?.youtubeVideoId) };
9192
+ });
9193
+ return ok({
9194
+ videoAssets,
9195
+ count: videoAssets.length,
9196
+ gaql,
9197
+ warnings,
9198
+ limitations: [
9199
+ "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.",
9200
+ "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)."
9201
+ ],
9202
+ nextActions: videoAssets.length >= limit ? ["The LIMIT was reached; narrow with nameFilter or raise limit."] : []
9203
+ });
9204
+ } catch (e) {
9205
+ return formatMcpToolError(e);
9206
+ }
9207
+ }
9208
+ );
9209
+ server.tool(
9210
+ "google_ads_get_demand_gen_assets",
9211
+ "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.",
9212
+ {
9213
+ customerId: customerIdSchema3,
9214
+ campaignId: numericIdSchema3.optional().describe("Optional Demand Gen campaign ID filter"),
9215
+ limit: z6.number().int().min(1).max(1e4).optional().default(500)
9216
+ },
9217
+ async ({ customerId, campaignId, limit }) => {
9218
+ try {
9219
+ const where = ["campaign.advertising_channel_type = 'DEMAND_GEN'"];
9220
+ if (campaignId) where.push(`campaign.id = ${campaignId}`);
9221
+ const demandGenFields = `
9222
+ ad_group_ad.ad.demand_gen_video_responsive_ad.videos,
9223
+ ad_group_ad.ad.demand_gen_video_responsive_ad.logo_images,
9224
+ ad_group_ad.ad.demand_gen_multi_asset_ad.marketing_images,
9225
+ ad_group_ad.ad.demand_gen_multi_asset_ad.square_marketing_images,
9226
+ ad_group_ad.ad.demand_gen_multi_asset_ad.portrait_marketing_images,
9227
+ ad_group_ad.ad.demand_gen_multi_asset_ad.logo_images,
9228
+ ad_group_ad.ad.demand_gen_carousel_ad.carousel_cards`;
9229
+ const baseFields = `
9230
+ campaign.id, campaign.name, ad_group.id, ad_group.name,
9231
+ ad_group_ad.status, ad_group_ad.ad.id, ad_group_ad.ad.name, ad_group_ad.ad.type`;
9232
+ const ads = await runGaqlWithFallback(client, customerId, [
9233
+ {
9234
+ label: "demand gen ads with creative references",
9235
+ gaql: oneLineGaql(`SELECT ${baseFields}, ${demandGenFields} FROM ad_group_ad${buildWhere(where)} LIMIT ${limit}`),
9236
+ failureWarning: "Full Demand Gen creative field selection failed; falling back to ad identifiers only"
9237
+ },
9238
+ {
9239
+ label: "demand gen ads without creative fields",
9240
+ gaql: oneLineGaql(`SELECT ${baseFields} FROM ad_group_ad${buildWhere(where)} LIMIT ${limit}`)
9241
+ }
9242
+ ]);
9243
+ const referencedIds = /* @__PURE__ */ new Set();
9244
+ for (const row of ads.rows) {
9245
+ const ad = recordOf(recordOf(recordOf(row)?.adGroupAd)?.ad);
9246
+ if (!ad) continue;
9247
+ collectAssetIds(ad.demandGenVideoResponsiveAd, referencedIds);
9248
+ collectAssetIds(ad.demandGenMultiAssetAd, referencedIds);
9249
+ collectAssetIds(ad.demandGenCarouselAd, referencedIds);
9250
+ }
9251
+ const warnings = [...ads.warnings];
9252
+ const assetsById = /* @__PURE__ */ new Map();
9253
+ const idList = [...referencedIds];
9254
+ const chunkSize = 500;
9255
+ for (let index = 0; index < idList.length; index += chunkSize) {
9256
+ const chunk = idList.slice(index, index + chunkSize);
9257
+ try {
9258
+ const resolved = await client.searchStream(customerId, oneLineGaql(`
9259
+ SELECT
9260
+ asset.id, asset.name, asset.type,
9261
+ asset.image_asset.full_size.url,
9262
+ asset.image_asset.full_size.width_pixels,
9263
+ asset.image_asset.full_size.height_pixels,
9264
+ asset.youtube_video_asset.youtube_video_id,
9265
+ asset.youtube_video_asset.youtube_video_title
9266
+ FROM asset WHERE asset.id IN (${chunk.join(", ")})`));
9267
+ for (const row of resolved) {
9268
+ const asset = recordOf(recordOf(row)?.asset);
9269
+ const rawId = asset?.id;
9270
+ const id = typeof rawId === "string" || typeof rawId === "number" ? String(rawId) : void 0;
9271
+ if (!asset || !id) continue;
9272
+ const youtube = recordOf(asset.youtubeVideoAsset);
9273
+ assetsById.set(id, {
9274
+ ...asset,
9275
+ derivedUrls: youtubeDerivedUrls(youtube?.youtubeVideoId)
9276
+ });
9277
+ }
9278
+ } catch (error) {
9279
+ warnings.push(`Asset resolution failed for ${chunk.length} referenced assets: ${getErrorMessage(error)}`);
9280
+ }
9281
+ }
9282
+ const resolvedAds = ads.rows.map((row) => {
9283
+ const record = recordOf(row) ?? {};
9284
+ const ad = recordOf(recordOf(record.adGroupAd)?.ad);
9285
+ const ids = /* @__PURE__ */ new Set();
9286
+ if (ad) {
9287
+ collectAssetIds(ad.demandGenVideoResponsiveAd, ids);
9288
+ collectAssetIds(ad.demandGenMultiAssetAd, ids);
9289
+ collectAssetIds(ad.demandGenCarouselAd, ids);
9290
+ }
9291
+ return {
9292
+ ...record,
9293
+ resolvedAssets: [...ids].map((id) => assetsById.get(id) ?? { id, unresolved: true })
9294
+ };
9295
+ });
9296
+ return ok({
9297
+ ads: resolvedAds,
9298
+ count: resolvedAds.length,
9299
+ referencedAssetCount: referencedIds.size,
9300
+ resolvedAssetCount: assetsById.size,
9301
+ gaql: ads.gaql,
9302
+ warnings,
9303
+ limitations: [
9304
+ "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.",
9305
+ "Image full_size.url values on tpc.googlesyndication.com are publicly served with no visible expiry.",
9306
+ "Catalog-driven visuals come from Merchant Center feeds and are not part of ad creative references."
9307
+ ],
9308
+ nextActions: resolvedAds.length >= limit ? ["The LIMIT was reached; filter by campaignId or raise limit."] : []
9309
+ });
9310
+ } catch (e) {
9311
+ return formatMcpToolError(e);
9312
+ }
9313
+ }
9314
+ );
8193
9315
  registerGoogleAdsKeywordPlannerTools(server, client, ok);
8194
9316
  registerGoogleAdsDiscoveryTools(server, client, ok);
8195
9317
  registerGoogleAdsReadOnlyRpcTool(server, client, ok);
@@ -8235,7 +9357,11 @@ var GOOGLE_ADS_TOOL_MANIFEST = {
8235
9357
  { 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." },
8236
9358
  { name: "google_ads_get_insights", purpose: "Generate validated performance GAQL using the metric and dimension catalogs." },
8237
9359
  { name: "google_ads_validate_query", purpose: "Validate metric/dimension/resource compatibility before querying." },
8238
- { name: "google_ads_run_gaql", purpose: "Run raw read-only GAQL SELECT queries for advanced reporting." }
9360
+ { name: "google_ads_run_gaql", purpose: "Run raw read-only GAQL SELECT queries for advanced reporting." },
9361
+ { name: "google_ads_list_image_assets", purpose: "List the account's image asset library with stable public full-size URLs, dimensions, and file metadata." },
9362
+ { name: "google_ads_list_video_assets", purpose: "List YouTube video assets with derived watch, embed, and public thumbnail URLs." },
9363
+ { name: "google_ads_get_demand_gen_assets", purpose: "List Demand Gen ads with their referenced image and YouTube creatives resolved." },
9364
+ { "name": "google_ads_get_video_upload", "purpose": "Read the processing state and YouTube video ID of one Google Ads upload in the selected customer" }
8239
9365
  ],
8240
9366
  resources: [
8241
9367
  "google-ads://manifest",
@@ -8391,7 +9517,7 @@ function registerGoogleAdsResources(server) {
8391
9517
  mimeType: "application/json",
8392
9518
  text: JSON.stringify({
8393
9519
  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).",
8394
- 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"],
9520
+ 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"],
8395
9521
  queryFormat: "SELECT ... FROM resource WHERE ... ORDER BY ... LIMIT N"
8396
9522
  }, null, 2)
8397
9523
  }]
@@ -8399,7 +9525,25 @@ function registerGoogleAdsResources(server) {
8399
9525
  }
8400
9526
 
8401
9527
  // src/platforms/google-ads/writes.ts
8402
- import { z as z5 } from "zod";
9528
+ import { randomUUID } from "crypto";
9529
+ import { z as z7 } from "zod";
9530
+
9531
+ // src/core/money.ts
9532
+ function guard(amount, currency) {
9533
+ if (!Number.isFinite(amount)) throw new Error(`Amount must be a finite number, received ${amount}.`);
9534
+ if (amount <= 0) throw new Error(`Amount must be greater than zero, received ${amount}.`);
9535
+ if (amount > 1e6) {
9536
+ throw new Error(
9537
+ `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.`
9538
+ );
9539
+ }
9540
+ return amount;
9541
+ }
9542
+ function toMicros(amount) {
9543
+ return Math.round(guard(amount, "units") * 1e6);
9544
+ }
9545
+
9546
+ // src/platforms/google-ads/writes.ts
8403
9547
  function ok2(data) {
8404
9548
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
8405
9549
  }
@@ -8414,246 +9558,326 @@ function preview(action, details) {
8414
9558
  message: "Preview only, nothing was changed. Repeat the same call with confirm: true to apply this change to the live account."
8415
9559
  });
8416
9560
  }
8417
- var confirmSchema = z5.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
8418
- var loginCustomerIdSchema2 = z5.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise.");
8419
- function toMicros(amount) {
8420
- if (!Number.isFinite(amount) || amount <= 0) {
8421
- throw new Error(`Expected a positive amount, received "${amount}".`);
8422
- }
8423
- return Math.round(amount * 1e6);
8424
- }
8425
- var stripDashes = (id) => String(id).replace(/-/g, "");
8426
- function registerGoogleAdsWrites(server, config) {
8427
- const client = new GoogleAdsClient(config);
8428
- const resourceName = (cid, collection, id) => `customers/${cid}/${collection}/${id}`;
8429
- const statusTool = (name, label, param, collection) => server.tool(
8430
- name,
8431
- `Pause, re-enable or remove a Google Ads ${label}. Previews by default: without confirm: true, the tool describes the change without applying it.`,
8432
- {
8433
- customerId: z5.string().describe("Google Ads customer ID, with or without dashes."),
8434
- [param]: z5.string().describe(`${label} ID.`),
8435
- status: z5.enum(["PAUSED", "ENABLED", "REMOVED"]).describe("New status."),
8436
- loginCustomerId: loginCustomerIdSchema2,
8437
- confirm: confirmSchema
8438
- },
8439
- async (a) => {
8440
- const cid = stripDashes(a.customerId);
8441
- const id = String(a[param]);
8442
- if (!a.confirm) {
8443
- return preview(name, { customer: cid, target: id, newStatus: a.status });
8444
- }
8445
- const result = await client.mutate(cid, collection, [
8446
- { update: { resourceName: resourceName(cid, collection, id), status: a.status }, updateMask: "status" }
8447
- ], a.loginCustomerId);
8448
- return ok2({ applied: true, action: name, result });
9561
+ var confirmSchema = z7.boolean().optional().describe("Set to true to actually apply the change. Without it, the tool only previews.");
9562
+ async function request(url, init, contexte) {
9563
+ const response = await fetch(url, { ...init, redirect: "error" });
9564
+ const body = await response.text();
9565
+ let parsed;
9566
+ try {
9567
+ parsed = JSON.parse(body);
9568
+ } catch {
9569
+ parsed = body;
9570
+ }
9571
+ if (!response.ok) {
9572
+ if (url.startsWith(ADS_BASE + "/")) {
9573
+ const error = parsed?.error;
9574
+ const details = error?.details?.flatMap((d) => d.errors ?? []).map((e) => ({ code: e.errorCode, message: e.message, location: e.location }));
9575
+ throw new Error(`${contexte}: ${JSON.stringify(details?.length ? details : error?.message ?? parsed).slice(0, 3e3)}`);
8449
9576
  }
8450
- );
8451
- statusTool("google_ads_update_campaign_status", "campaign", "campaignId", "campaigns");
8452
- statusTool("google_ads_update_adgroup_status", "ad group", "adGroupId", "adGroups");
8453
- server.tool(
8454
- "google_ads_rename_campaign",
8455
- "Rename a Google Ads campaign. The name is the only thing that changes: delivery, budget and targeting are untouched. Previews by default.",
9577
+ throw new Error(`${contexte} : ${typeof parsed === "string" ? parsed.slice(0, 300) : JSON.stringify(parsed).slice(0, 300)}`);
9578
+ }
9579
+ return parsed;
9580
+ }
9581
+ var ADS_BASE = "https://googleads.googleapis.com/v25";
9582
+ function registerGoogleAdsWrites(c, config) {
9583
+ registerGoogleExtendedWrites(c, config);
9584
+ async function accessToken() {
9585
+ const r = await request(
9586
+ "https://oauth2.googleapis.com/token",
9587
+ {
9588
+ method: "POST",
9589
+ headers: { "content-type": "application/x-www-form-urlencoded" },
9590
+ body: new URLSearchParams({
9591
+ grant_type: "refresh_token",
9592
+ refresh_token: config.refreshToken,
9593
+ client_id: config.clientId,
9594
+ client_secret: config.clientSecret
9595
+ })
9596
+ },
9597
+ "Google token refresh"
9598
+ );
9599
+ return r.access_token;
9600
+ }
9601
+ const headers = async (loginCustomerId) => {
9602
+ const h = {
9603
+ authorization: `Bearer ${await accessToken()}`,
9604
+ "developer-token": config.developerToken,
9605
+ "content-type": "application/json"
9606
+ };
9607
+ const manager = loginCustomerId ?? config.loginCustomerId;
9608
+ if (manager) h["login-customer-id"] = manager.replace(/-/g, "");
9609
+ return h;
9610
+ };
9611
+ c.tool(
9612
+ "google_ads_update_campaign_status",
9613
+ "Pause, re-enable or remove a Google Ads campaign. Previews by default: without confirm: true, the tool describes the change without applying it.",
8456
9614
  {
8457
- customerId: z5.string().describe("Google Ads customer ID, with or without dashes."),
8458
- campaignId: z5.string().describe("Campaign ID."),
8459
- name: z5.string().min(1).max(255).describe("New campaign name."),
8460
- loginCustomerId: loginCustomerIdSchema2,
9615
+ customerId: z7.string().describe("Google Ads customer ID, with or without dashes."),
9616
+ campaignId: z7.string().describe("Campaign ID."),
9617
+ status: z7.enum(["PAUSED", "ENABLED", "REMOVED"]).describe("New status."),
9618
+ loginCustomerId: z7.string().optional().describe("Manager account, if the customer sits under an MCC."),
8461
9619
  confirm: confirmSchema
8462
9620
  },
8463
9621
  async (a) => {
8464
- const cid = stripDashes(a.customerId);
8465
- if (!a.confirm) {
8466
- return preview("google_ads_rename_campaign", { customer: cid, campaign: a.campaignId, newName: a.name });
9622
+ const { customerId, campaignId, status, loginCustomerId, confirm } = a;
9623
+ const cid = String(customerId).replace(/-/g, "");
9624
+ if (!confirm) {
9625
+ return preview("google_ads_update_campaign_status", { customer: cid, campaign: campaignId, newStatus: status });
8467
9626
  }
8468
- const result = await client.mutate(cid, "campaigns", [
8469
- { update: { resourceName: resourceName(cid, "campaigns", a.campaignId), name: a.name }, updateMask: "name" }
8470
- ], a.loginCustomerId);
8471
- return ok2({ applied: true, action: "google_ads_rename_campaign", result });
9627
+ const result2 = await request(
9628
+ `${ADS_BASE}/customers/${cid}/campaigns:mutate`,
9629
+ {
9630
+ method: "POST",
9631
+ headers: await headers(loginCustomerId),
9632
+ body: JSON.stringify({
9633
+ operations: [
9634
+ status === "REMOVED" ? { remove: `customers/${cid}/campaigns/${campaignId}` } : { update: { resourceName: `customers/${cid}/campaigns/${campaignId}`, status }, updateMask: "status" }
9635
+ ]
9636
+ })
9637
+ },
9638
+ "Google Ads status update"
9639
+ );
9640
+ return ok2({ applied: true, action: "google_ads_update_campaign_status", result: result2 });
8472
9641
  }
8473
9642
  );
8474
- server.tool(
9643
+ c.tool(
8475
9644
  "google_ads_create_campaign",
8476
9645
  "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.",
8477
9646
  {
8478
- customerId: z5.string().describe("Google Ads customer ID, with or without dashes."),
8479
- name: z5.string().min(1).max(255).describe("Campaign name."),
8480
- channelType: z5.enum(["SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"]).describe("Advertising channel."),
8481
- dailyBudget: z5.number().positive().describe("Daily budget in the account currency."),
8482
- budgetName: z5.string().min(1).max(255).describe("Name for the campaign budget. Google requires it to be unique on the account."),
8483
- loginCustomerId: loginCustomerIdSchema2,
9647
+ customerId: z7.string().describe("Google Ads customer ID, with or without dashes."),
9648
+ name: z7.string().describe("Campaign name."),
9649
+ channelType: z7.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."),
9650
+ dailyBudget: z7.number().positive().describe("Daily budget in the account currency."),
9651
+ loginCustomerId: z7.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
9652
+ brandGuidelinesEnabled: z7.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."),
9653
+ euPoliticalAdvertising: z7.boolean().optional().describe("Required for execution: explicitly declare whether this campaign contains EU political advertising."),
9654
+ merchantId: z7.string().regex(/^\d+$/).optional().describe("Merchant Center account ID; required for Shopping."),
9655
+ feedLabel: z7.string().optional().describe("Merchant Center feed label for Shopping."),
9656
+ validateOnly: z7.boolean().optional().describe("Validate the atomic request with Google without creating any resources."),
8484
9657
  confirm: confirmSchema
8485
9658
  },
8486
9659
  async (a) => {
8487
- const cid = stripDashes(a.customerId);
8488
- let micros;
8489
- try {
8490
- micros = toMicros(Number(a.dailyBudget));
8491
- } catch (error) {
8492
- return ko(error instanceof Error ? error.message : String(error));
8493
- }
8494
- if (!a.confirm) {
9660
+ const { customerId, name, channelType, dailyBudget, loginCustomerId, confirm } = a;
9661
+ const cid = String(customerId).replace(/-/g, "");
9662
+ const micros = toMicros(Number(dailyBudget));
9663
+ if (!confirm && !a.validateOnly) {
8495
9664
  return preview("google_ads_create_campaign", {
8496
9665
  customer: cid,
8497
- name: a.name,
8498
- channelType: a.channelType,
8499
- dailyBudget: a.dailyBudget,
9666
+ name,
9667
+ channelType,
9668
+ dailyBudget,
8500
9669
  inMicros: micros,
8501
- budgetName: a.budgetName,
8502
- status: "PAUSED"
9670
+ status: "PAUSED",
9671
+ euPoliticalAdvertising: a.euPoliticalAdvertising ?? "declaration required before execution",
9672
+ brandGuidelinesEnabled: a.brandGuidelinesEnabled,
9673
+ merchantId: a.merchantId,
9674
+ feedLabel: a.feedLabel,
9675
+ biddingStrategy: ["PERFORMANCE_MAX", "DEMAND_GEN"].includes(String(channelType)) ? "MAXIMIZE_CONVERSIONS" : "MANUAL_CPC"
8503
9676
  });
8504
9677
  }
8505
- const login = a.loginCustomerId;
8506
- const budget = await client.mutate(cid, "campaignBudgets", [
8507
- {
8508
- create: {
8509
- name: a.budgetName,
8510
- amountMicros: String(micros),
8511
- deliveryMethod: "STANDARD"
8512
- }
8513
- }
8514
- ], login);
8515
- const budgetResource = budget.results?.[0]?.resourceName;
8516
- if (!budgetResource) {
8517
- return ko("Google Ads did not return a budget resource name, so the campaign was not created.");
8518
- }
8519
- const campaign = await client.mutate(cid, "campaigns", [
8520
- {
8521
- create: {
8522
- name: a.name,
8523
- status: "PAUSED",
8524
- advertisingChannelType: a.channelType,
8525
- campaignBudget: budgetResource,
8526
- manualCpc: {}
8527
- }
8528
- }
8529
- ], login);
9678
+ 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.");
9679
+ if (typeof a.euPoliticalAdvertising !== "boolean") throw new Error("Declare euPoliticalAdvertising explicitly before creating the campaign.");
9680
+ 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.");
9681
+ if (channelType === "SHOPPING" && !a.merchantId) throw new Error("Shopping requires merchantId for an accessible Merchant Center account.");
9682
+ const budgetName = `customers/${cid}/campaignBudgets/-1`;
9683
+ const campaignData = {
9684
+ name,
9685
+ status: "PAUSED",
9686
+ advertisingChannelType: channelType,
9687
+ campaignBudget: budgetName,
9688
+ containsEuPoliticalAdvertising: a.euPoliticalAdvertising ? "CONTAINS_EU_POLITICAL_ADVERTISING" : "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING",
9689
+ ...["PERFORMANCE_MAX", "DEMAND_GEN"].includes(String(channelType)) ? { maximizeConversions: {}, ...channelType === "PERFORMANCE_MAX" ? { brandGuidelinesEnabled: false } : {} } : { manualCpc: {} },
9690
+ ...channelType === "SHOPPING" ? { shoppingSetting: { merchantId: a.merchantId, campaignPriority: 0, feedLabel: a.feedLabel } } : {}
9691
+ };
9692
+ const result2 = await request(`${ADS_BASE}/customers/${cid}/googleAds:mutate`, {
9693
+ method: "POST",
9694
+ headers: await headers(loginCustomerId ?? config.loginCustomerId),
9695
+ body: JSON.stringify({ partialFailure: false, validateOnly: a.validateOnly === true, mutateOperations: [
9696
+ { campaignBudgetOperation: { create: { resourceName: budgetName, name: `${name} budget ${randomUUID()}`, amountMicros: String(micros), deliveryMethod: "STANDARD", explicitlyShared: false } } },
9697
+ { campaignOperation: { create: campaignData } }
9698
+ ] })
9699
+ }, "Google Ads campaign creation");
8530
9700
  return ok2({
8531
- applied: true,
9701
+ applied: a.validateOnly !== true,
9702
+ validatedByGoogle: a.validateOnly === true,
8532
9703
  action: "google_ads_create_campaign",
8533
9704
  status: "PAUSED",
8534
- budget: budgetResource,
8535
- result: campaign
9705
+ budget: result2.mutateOperationResponses?.[0]?.campaignBudgetResult?.resourceName,
9706
+ result: { results: result2.mutateOperationResponses?.flatMap((r) => r.campaignResult ? [r.campaignResult] : []) ?? [] }
8536
9707
  });
8537
9708
  }
8538
9709
  );
8539
- server.tool(
9710
+ c.tool(
8540
9711
  "google_ads_update_campaign_budget",
8541
9712
  "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.",
8542
9713
  {
8543
- customerId: z5.string().describe("Google Ads customer ID, with or without dashes."),
8544
- budgetId: z5.string().describe("Campaign budget ID (campaign_budget.id)."),
8545
- dailyAmount: z5.number().positive().describe("New daily budget, in the account currency."),
8546
- loginCustomerId: loginCustomerIdSchema2,
9714
+ customerId: z7.string().describe("Google Ads customer ID, with or without dashes."),
9715
+ budgetId: z7.string().describe("Campaign budget ID (campaign_budget.id)."),
9716
+ dailyAmount: z7.number().positive().describe("New daily budget, in the account currency."),
9717
+ loginCustomerId: z7.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
8547
9718
  confirm: confirmSchema
8548
9719
  },
8549
9720
  async (a) => {
8550
- const cid = stripDashes(a.customerId);
8551
- let micros;
8552
- try {
8553
- micros = toMicros(Number(a.dailyAmount));
8554
- } catch (error) {
8555
- return ko(error instanceof Error ? error.message : String(error));
8556
- }
8557
- if (!a.confirm) {
9721
+ const { customerId, budgetId, dailyAmount, loginCustomerId, confirm } = a;
9722
+ const cid = String(customerId).replace(/-/g, "");
9723
+ const micros = toMicros(Number(dailyAmount));
9724
+ if (!confirm) {
8558
9725
  return preview("google_ads_update_campaign_budget", {
8559
9726
  customer: cid,
8560
- budget: a.budgetId,
8561
- newDailyBudget: a.dailyAmount,
9727
+ budget: budgetId,
9728
+ newDailyBudget: dailyAmount,
8562
9729
  inMicros: micros
8563
9730
  });
8564
9731
  }
8565
- const result = await client.mutate(cid, "campaignBudgets", [
9732
+ const result2 = await request(
9733
+ `${ADS_BASE}/customers/${cid}/campaignBudgets:mutate`,
8566
9734
  {
8567
- update: {
8568
- resourceName: resourceName(cid, "campaignBudgets", a.budgetId),
8569
- amountMicros: String(micros)
8570
- },
8571
- updateMask: "amount_micros"
8572
- }
8573
- ], a.loginCustomerId);
8574
- return ok2({ applied: true, action: "google_ads_update_campaign_budget", result });
9735
+ method: "POST",
9736
+ headers: await headers(loginCustomerId),
9737
+ body: JSON.stringify({
9738
+ operations: [
9739
+ {
9740
+ update: { resourceName: `customers/${cid}/campaignBudgets/${budgetId}`, amountMicros: String(micros) },
9741
+ updateMask: "amount_micros"
9742
+ }
9743
+ ]
9744
+ })
9745
+ },
9746
+ "Google Ads budget update"
9747
+ );
9748
+ return ok2({ applied: true, action: "google_ads_update_campaign_budget", result: result2 });
8575
9749
  }
8576
9750
  );
8577
- server.tool(
8578
- "google_ads_update_adgroup_bid",
8579
- "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.",
9751
+ c.tool(
9752
+ "google_ads_rename_campaign",
9753
+ "Rename a Google Ads campaign. The name is the only thing that changes: delivery, budget and targeting are untouched. Previews by default.",
8580
9754
  {
8581
- customerId: z5.string().describe("Google Ads customer ID, with or without dashes."),
8582
- adGroupId: z5.string().describe("Ad group ID."),
8583
- cpcBid: z5.number().positive().describe("New default CPC bid, in the account currency."),
8584
- loginCustomerId: loginCustomerIdSchema2,
9755
+ customerId: z7.string().describe("Google Ads customer ID, with or without dashes."),
9756
+ campaignId: z7.string().describe("Campaign ID."),
9757
+ name: z7.string().min(1).max(255).describe("New campaign name."),
9758
+ loginCustomerId: z7.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
8585
9759
  confirm: confirmSchema
8586
9760
  },
8587
9761
  async (a) => {
8588
- const cid = stripDashes(a.customerId);
8589
- let micros;
8590
- try {
8591
- micros = toMicros(Number(a.cpcBid));
8592
- } catch (error) {
8593
- return ko(error instanceof Error ? error.message : String(error));
8594
- }
8595
- if (!a.confirm) {
8596
- return preview("google_ads_update_adgroup_bid", {
8597
- customer: cid,
8598
- adGroup: a.adGroupId,
8599
- newCpcBid: a.cpcBid,
8600
- inMicros: micros
8601
- });
8602
- }
8603
- const result = await client.mutate(cid, "adGroups", [
9762
+ const { customerId, campaignId, name, loginCustomerId, confirm } = a;
9763
+ const cid = String(customerId).replace(/-/g, "");
9764
+ if (!confirm) return preview("google_ads_rename_campaign", { customer: cid, campaign: campaignId, newName: name });
9765
+ const result2 = await request(
9766
+ `${ADS_BASE}/customers/${cid}/campaigns:mutate`,
8604
9767
  {
8605
- update: {
8606
- resourceName: resourceName(cid, "adGroups", a.adGroupId),
8607
- cpcBidMicros: String(micros)
8608
- },
8609
- updateMask: "cpc_bid_micros"
8610
- }
8611
- ], a.loginCustomerId);
8612
- return ok2({ applied: true, action: "google_ads_update_adgroup_bid", result });
9768
+ method: "POST",
9769
+ headers: await headers(loginCustomerId),
9770
+ body: JSON.stringify({
9771
+ operations: [{ update: { resourceName: `customers/${cid}/campaigns/${campaignId}`, name }, updateMask: "name" }]
9772
+ })
9773
+ },
9774
+ "Google Ads rename"
9775
+ );
9776
+ return ok2({ applied: true, action: "google_ads_rename_campaign", result: result2 });
8613
9777
  }
8614
9778
  );
8615
- server.tool(
9779
+ c.tool(
8616
9780
  "google_ads_update_campaign_schedule",
8617
9781
  "Change the start or end date of a Google Ads campaign. Dates are YYYY-MM-DD in the account time zone. Previews by default.",
8618
9782
  {
8619
- customerId: z5.string().describe("Google Ads customer ID, with or without dashes."),
8620
- campaignId: z5.string().describe("Campaign ID."),
8621
- startDate: z5.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Start date, YYYY-MM-DD, in the account time zone."),
8622
- endDate: z5.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."),
8623
- loginCustomerId: loginCustomerIdSchema2,
9783
+ customerId: z7.string().describe("Google Ads customer ID, with or without dashes."),
9784
+ campaignId: z7.string().describe("Campaign ID."),
9785
+ startDate: z7.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Start date, YYYY-MM-DD, in the account time zone."),
9786
+ endDate: z7.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Use 2037-12-30 to clear the end date (run indefinitely)."),
9787
+ loginCustomerId: z7.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
8624
9788
  confirm: confirmSchema
8625
9789
  },
8626
9790
  async (a) => {
8627
- const { startDate, endDate } = a;
9791
+ const { customerId, campaignId, startDate, endDate, loginCustomerId, confirm } = a;
8628
9792
  if (!startDate && !endDate) return ko("Provide startDate, endDate, or both.");
8629
- if (startDate && endDate && startDate > endDate) {
9793
+ for (const date of [startDate, endDate]) {
9794
+ if (date && (Number.isNaN(Date.parse(String(date))) || new Date(String(date)).toISOString().slice(0, 10) !== date)) return ko("Invalid calendar date.");
9795
+ }
9796
+ if (startDate && endDate && String(startDate) > String(endDate)) {
8630
9797
  return ko(`startDate ${startDate} is after endDate ${endDate}.`);
8631
9798
  }
8632
- const cid = stripDashes(a.customerId);
8633
- const update = {
8634
- resourceName: resourceName(cid, "campaigns", a.campaignId)
8635
- };
9799
+ const cid = String(customerId).replace(/-/g, "");
9800
+ const update = { resourceName: `customers/${cid}/campaigns/${campaignId}` };
8636
9801
  const mask = [];
8637
9802
  if (startDate) {
8638
- update.startDate = startDate;
8639
- mask.push("start_date");
9803
+ update.startDateTime = `${startDate} 00:00:00`;
9804
+ mask.push("start_date_time");
8640
9805
  }
8641
9806
  if (endDate) {
8642
- update.endDate = endDate;
8643
- mask.push("end_date");
8644
- }
8645
- if (!a.confirm) {
8646
- return preview("google_ads_update_campaign_schedule", {
8647
- customer: cid,
8648
- campaign: a.campaignId,
8649
- startDate,
8650
- endDate
8651
- });
9807
+ if (endDate !== "2037-12-30") update.endDateTime = `${endDate} 23:59:59`;
9808
+ mask.push("end_date_time");
8652
9809
  }
8653
- const result = await client.mutate(cid, "campaigns", [
8654
- { update, updateMask: mask.join(",") }
8655
- ], a.loginCustomerId);
8656
- return ok2({ applied: true, action: "google_ads_update_campaign_schedule", result });
9810
+ if (!confirm) return preview("google_ads_update_campaign_schedule", { customer: cid, campaign: campaignId, startDate, endDate });
9811
+ const result2 = await request(
9812
+ `${ADS_BASE}/customers/${cid}/campaigns:mutate`,
9813
+ {
9814
+ method: "POST",
9815
+ headers: await headers(loginCustomerId),
9816
+ body: JSON.stringify({ operations: [{ update, updateMask: mask.join(",") }] })
9817
+ },
9818
+ "Google Ads schedule update"
9819
+ );
9820
+ return ok2({ applied: true, action: "google_ads_update_campaign_schedule", result: result2 });
9821
+ }
9822
+ );
9823
+ c.tool(
9824
+ "google_ads_update_adgroup_status",
9825
+ "Pause, re-enable or remove a Google Ads ad group. Previews by default.",
9826
+ {
9827
+ customerId: z7.string().describe("Google Ads customer ID, with or without dashes."),
9828
+ adGroupId: z7.string().describe("Ad group ID."),
9829
+ status: z7.enum(["PAUSED", "ENABLED", "REMOVED"]).describe("New status."),
9830
+ loginCustomerId: z7.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
9831
+ confirm: confirmSchema
9832
+ },
9833
+ async (a) => {
9834
+ const { customerId, adGroupId, status, loginCustomerId, confirm } = a;
9835
+ const cid = String(customerId).replace(/-/g, "");
9836
+ if (!confirm) return preview("google_ads_update_adgroup_status", { customer: cid, adGroup: adGroupId, newStatus: status });
9837
+ const result2 = await request(
9838
+ `${ADS_BASE}/customers/${cid}/adGroups:mutate`,
9839
+ {
9840
+ method: "POST",
9841
+ headers: await headers(loginCustomerId),
9842
+ body: JSON.stringify({
9843
+ operations: [status === "REMOVED" ? { remove: `customers/${cid}/adGroups/${adGroupId}` } : { update: { resourceName: `customers/${cid}/adGroups/${adGroupId}`, status }, updateMask: "status" }]
9844
+ })
9845
+ },
9846
+ "Google Ads ad group status"
9847
+ );
9848
+ return ok2({ applied: true, action: "google_ads_update_adgroup_status", result: result2 });
9849
+ }
9850
+ );
9851
+ c.tool(
9852
+ "google_ads_update_adgroup_bid",
9853
+ "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.",
9854
+ {
9855
+ customerId: z7.string().describe("Google Ads customer ID, with or without dashes."),
9856
+ adGroupId: z7.string().describe("Ad group ID."),
9857
+ cpcBid: z7.number().positive().describe("New default CPC bid, in the account currency."),
9858
+ loginCustomerId: z7.string().optional().describe("Manager account ID, when the customer sits under an MCC. Omit otherwise."),
9859
+ confirm: confirmSchema
9860
+ },
9861
+ async (a) => {
9862
+ const { customerId, adGroupId, cpcBid, loginCustomerId, confirm } = a;
9863
+ const cid = String(customerId).replace(/-/g, "");
9864
+ const micros = toMicros(Number(cpcBid));
9865
+ if (!confirm) return preview("google_ads_update_adgroup_bid", { customer: cid, adGroup: adGroupId, newCpcBid: cpcBid, inMicros: micros });
9866
+ const result2 = await request(
9867
+ `${ADS_BASE}/customers/${cid}/adGroups:mutate`,
9868
+ {
9869
+ method: "POST",
9870
+ headers: await headers(loginCustomerId),
9871
+ body: JSON.stringify({
9872
+ operations: [{
9873
+ update: { resourceName: `customers/${cid}/adGroups/${adGroupId}`, cpcBidMicros: String(micros) },
9874
+ updateMask: "cpc_bid_micros"
9875
+ }]
9876
+ })
9877
+ },
9878
+ "Google Ads ad group bid"
9879
+ );
9880
+ return ok2({ applied: true, action: "google_ads_update_adgroup_bid", result: result2 });
8657
9881
  }
8658
9882
  );
8659
9883
  }
@@ -8661,21 +9885,23 @@ function registerGoogleAdsWrites(server, config) {
8661
9885
  // src/platforms/google-ads/index.ts
8662
9886
  function registerGoogleAds(server, config) {
8663
9887
  registerGoogleAdsTools(server, config);
9888
+ registerGoogleVideoReads(server, config);
8664
9889
  registerGoogleAdsResources(server);
8665
- logger.info("google-ads", "Registered 31 read tools and 5 resources");
9890
+ logger.info("google-ads", "Registered 35 read tools and 5 resources");
8666
9891
  if (config.enableWrites) {
8667
9892
  registerGoogleAdsWrites(server, config);
8668
- logger.info("google-ads", "Registered 7 write tools (every one previews before it applies)");
9893
+ logger.info("google-ads", "Registered 10 write tools (every one previews before it applies)");
8669
9894
  }
8670
9895
  }
8671
9896
 
8672
9897
  // src/server.ts
8673
- var PACKAGE_VERSION = "1.0.0";
9898
+ var PACKAGE_VERSION = "2.0.0";
8674
9899
  function createServer(config) {
8675
9900
  const server = new McpServer(
8676
- { name: "google-ads-mcp", version: PACKAGE_VERSION },
9901
+ { 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" }] },
8677
9902
  { capabilities: { tools: { listChanged: true }, resources: { subscribe: false, listChanged: true } } }
8678
9903
  );
9904
+ installToolQuality(server);
8679
9905
  registerGoogleAds(server, config);
8680
9906
  logger.system(
8681
9907
  `google-ads-mcp v${PACKAGE_VERSION} ready, writes ${config.enableWrites ? "enabled" : "disabled"}`
@@ -8684,16 +9910,16 @@ function createServer(config) {
8684
9910
  }
8685
9911
 
8686
9912
  // src/config.ts
8687
- import { z as z6 } from "zod";
8688
- var configSchema = z6.object({
8689
- developerToken: z6.string().min(1, "GOOGLE_ADS_DEVELOPER_TOKEN is required"),
8690
- clientId: z6.string().min(1, "GOOGLE_ADS_CLIENT_ID is required"),
8691
- clientSecret: z6.string().min(1, "GOOGLE_ADS_CLIENT_SECRET is required"),
8692
- refreshToken: z6.string().min(1, "GOOGLE_ADS_REFRESH_TOKEN is required"),
8693
- loginCustomerId: z6.string().optional(),
9913
+ import { z as z8 } from "zod";
9914
+ var configSchema = z8.object({
9915
+ developerToken: z8.string().min(1, "GOOGLE_ADS_DEVELOPER_TOKEN is required"),
9916
+ clientId: z8.string().min(1, "GOOGLE_ADS_CLIENT_ID is required"),
9917
+ clientSecret: z8.string().min(1, "GOOGLE_ADS_CLIENT_SECRET is required"),
9918
+ refreshToken: z8.string().min(1, "GOOGLE_ADS_REFRESH_TOKEN is required"),
9919
+ loginCustomerId: z8.string().optional(),
8694
9920
  /** Write tools are registered only when this is true. */
8695
- enableWrites: z6.boolean().optional(),
8696
- logLevel: z6.enum(["debug", "info", "warn", "error"]).default("info")
9921
+ enableWrites: z8.boolean().optional(),
9922
+ logLevel: z8.enum(["debug", "info", "warn", "error"]).default("info")
8697
9923
  });
8698
9924
  function loadConfig() {
8699
9925
  const raw = {
@@ -8705,13 +9931,13 @@ function loadConfig() {
8705
9931
  enableWrites: isTruthy(process.env["GOOGLE_ADS_ENABLE_WRITES"]),
8706
9932
  logLevel: process.env["LOG_LEVEL"] ?? "info"
8707
9933
  };
8708
- const result = configSchema.safeParse(raw);
8709
- if (!result.success) {
8710
- const missing = result.error.issues.map((i) => i.message).join(", ");
9934
+ const result2 = configSchema.safeParse(raw);
9935
+ if (!result2.success) {
9936
+ const missing = result2.error.issues.map((i) => i.message).join(", ");
8711
9937
  logger.error("config", `Missing credentials: ${missing}`);
8712
9938
  throw new Error(`Missing Google Ads credentials: ${missing}`);
8713
9939
  }
8714
- return result.data;
9940
+ return result2.data;
8715
9941
  }
8716
9942
  function isTruthy(value) {
8717
9943
  if (!value) return false;