mcp-google-ads 1.1.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MCP Google Ads Server
2
2
 
3
- An MCP (Model Context Protocol) server for the Google Ads API with built-in safeguards for review before changes go live. Production-proven with MCC (Manager Account) support, 34 tools for campaign management, reporting, and optimization.
3
+ An MCP (Model Context Protocol) server for the Google Ads API with built-in safeguards for review before changes go live. Production-proven with MCC (Manager Account) support, 36 tools for campaign management, reporting, and optimization. v1.2.0 adds Demand Gen campaign creation end-to-end.
4
4
 
5
5
  ## Features
6
6
 
@@ -148,7 +148,7 @@ Restart Claude Code.
148
148
  5. Claude enables (requires your approval prompt)
149
149
  ```
150
150
 
151
- ### Available Tools (34)
151
+ ### Available Tools (36)
152
152
 
153
153
  #### Context & Discovery
154
154
  | Tool | Description |
@@ -162,9 +162,11 @@ Restart Claude Code.
162
162
  #### Campaign Management
163
163
  | Tool | Description |
164
164
  |------|-------------|
165
- | `google_ads_create_campaign` | Create campaign (PAUSED) |
166
- | `google_ads_create_ad_group` | Create ad group (PAUSED) |
165
+ | `google_ads_create_campaign` | Create campaign (PAUSED). Supports SEARCH + DEMAND_GEN channels, richer bidding (MANUAL_CPC / MAXIMIZE_CLICKS / MAXIMIZE_CONVERSIONS / TARGET_CPA), geo + language targeting, start/end dates |
166
+ | `google_ads_create_ad_group` | Create ad group (PAUSED). `type` accepts SEARCH_STANDARD (default) or DEMAND_GEN_MULTI_ASSET_AD_GROUP |
167
167
  | `google_ads_create_responsive_search_ad` | Create RSA with validation (PAUSED) |
168
+ | `google_ads_create_image_asset` | Upload PNG/JPG/GIF image asset (validates ≤5MB, ≥600×314) for use in Demand Gen ads |
169
+ | `google_ads_create_demand_gen_multi_asset_ad` | Create a Demand Gen multi-asset ad (PAUSED) — validates char/count caps before API call, fails fast if ad_group isn't DG |
168
170
  | `google_ads_create_keywords` | Create keywords (PAUSED) |
169
171
  | `google_ads_validate_ad` | Validate RSA without creating |
170
172
  | `google_ads_enable_items` | Enable items (make LIVE) — **requires approval** |
@@ -233,6 +235,55 @@ Restart Claude Code.
233
235
  "Run a GAQL query to get all ad groups with CTR below 2%"
234
236
  ```
235
237
 
238
+ ### Example: Create a Demand Gen Campaign End-to-End
239
+
240
+ ```
241
+ # 1. Campaign: $75/day, DEMAND_GEN channel, MAXIMIZE_CONVERSIONS default,
242
+ # targeting Alaska (21134) + Maine (21141) in English
243
+ google_ads_create_campaign({
244
+ name: "DG - Spring Promo",
245
+ daily_budget: 75,
246
+ channel_type: "DEMAND_GEN",
247
+ geo_target_ids: ["21134", "21141"],
248
+ start_date: "2026-05-01",
249
+ end_date: "2026-06-30"
250
+ })
251
+ # → campaign_id: 555123
252
+
253
+ # 2. Ad group: DEMAND_GEN_MULTI_ASSET_AD_GROUP
254
+ google_ads_create_ad_group({
255
+ campaign_id: "555123",
256
+ name: "DG AG 1",
257
+ type: "DEMAND_GEN_MULTI_ASSET_AD_GROUP"
258
+ })
259
+ # → ad_group_id: 555456
260
+
261
+ # 3. Image assets (PNG/JPG/GIF, ≥600×314, ≤5MB). Returns {asset_id, ...}
262
+ google_ads_create_image_asset({ name: "hero-landscape", file_path: "/abs/path/hero.png" })
263
+ # → asset_id: 42001
264
+ google_ads_create_image_asset({ name: "hero-square", file_path: "/abs/path/square.png" })
265
+ # → asset_id: 42002
266
+ google_ads_create_image_asset({ name: "logo", file_path: "/abs/path/logo.png" })
267
+ # → asset_id: 42003
268
+
269
+ # 4. Demand Gen multi-asset ad (PAUSED). Validates char + count caps first.
270
+ google_ads_create_demand_gen_multi_asset_ad({
271
+ ad_group_id: "555456",
272
+ final_urls: ["https://example.com/spring"],
273
+ business_name: "Example Org",
274
+ call_to_action: "LEARN_MORE",
275
+ marketing_image_asset_ids: ["42001"], // 1.91:1 landscape, ≥1 required
276
+ square_marketing_image_asset_ids: ["42002"], // 1:1 optional
277
+ logo_image_asset_ids: ["42003"], // logo optional
278
+ headlines: ["Spring Sale Now On", "Save 20% Today"], // max 5, ≤40 chars each
279
+ long_headlines: ["A longer pitch under ninety characters."], // max 5, ≤90 chars
280
+ descriptions: ["Shop the latest looks.", "Free returns."] // max 5, ≤90 chars each
281
+ })
282
+ # → resource_name: customers/.../adGroupAds/555456~67890000
283
+ ```
284
+
285
+ After all four calls the campaign, ad group, and ad all live in your account in PAUSED state and are labeled `Claude-MM-DD-YY`. Review in the Google Ads UI, then enable via `google_ads_enable_items`.
286
+
236
287
  ## Safety Features
237
288
 
238
289
  1. **Everything starts PAUSED** — Nothing goes live until you explicitly enable it
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Pure builder for ad-group creation payloads. Separate from the manager so
3
+ * it can be unit-tested without stubbing the live API.
4
+ *
5
+ * `type` for DEMAND_GEN_MULTI_ASSET_AD_GROUP emits the raw proto value 21
6
+ * because the google-ads-api v23 enum map does not include it; the typed
7
+ * AdGroup create path validates against the local enum, so DG creation must
8
+ * flow through customer.mutateResources (which trusts the numeric value).
9
+ */
10
+ export type AdGroupTypeName = "SEARCH_STANDARD" | "DEMAND_GEN_MULTI_ASSET_AD_GROUP";
11
+ export declare const AD_GROUP_TYPE_DEMAND_GEN_MULTI_ASSET = 21;
12
+ export interface AdGroupCreateInput {
13
+ customer_id_clean: string;
14
+ name: string;
15
+ campaign_id: string;
16
+ cpc_bid_micros?: number;
17
+ type?: AdGroupTypeName;
18
+ }
19
+ export interface AdGroupCreatePayload {
20
+ name: string;
21
+ campaign: string;
22
+ status: number;
23
+ cpc_bid_micros: number;
24
+ type: number;
25
+ }
26
+ export declare function buildAdGroupCreatePayload(input: AdGroupCreateInput): AdGroupCreatePayload;
@@ -0,0 +1,17 @@
1
+ import { enums } from "google-ads-api";
2
+ const AD_GROUP_TYPE_DEMAND_GEN_MULTI_ASSET = 21;
3
+ function buildAdGroupCreatePayload(input) {
4
+ const typeEnum = input.type === "DEMAND_GEN_MULTI_ASSET_AD_GROUP" ? AD_GROUP_TYPE_DEMAND_GEN_MULTI_ASSET : enums.AdGroupType.SEARCH_STANDARD;
5
+ return {
6
+ name: input.name,
7
+ campaign: `customers/${input.customer_id_clean}/campaigns/${input.campaign_id}`,
8
+ status: enums.AdGroupStatus.PAUSED,
9
+ cpc_bid_micros: input.cpc_bid_micros ?? 1e6,
10
+ type: typeEnum
11
+ };
12
+ }
13
+ export {
14
+ AD_GROUP_TYPE_DEMAND_GEN_MULTI_ASSET,
15
+ buildAdGroupCreatePayload
16
+ };
17
+ //# sourceMappingURL=adGroupBuilder.js.map
@@ -0,0 +1,55 @@
1
+ export interface AssetUrlUpdate {
2
+ asset_id: string;
3
+ final_urls: string[];
4
+ }
5
+ export interface UpdateAssetUrlsArgs {
6
+ customer_id?: string;
7
+ updates: AssetUrlUpdate[];
8
+ confirm?: boolean;
9
+ }
10
+ export interface PauseAssetLinksArgs {
11
+ customer_id?: string;
12
+ resource_names: string[];
13
+ confirm?: boolean;
14
+ }
15
+ export type AssetLinkLevel = "customer_asset" | "campaign_asset" | "ad_group_asset";
16
+ export interface ParsedAssetLink {
17
+ level: AssetLinkLevel;
18
+ customer_id: string;
19
+ resource_name: string;
20
+ }
21
+ export declare function parseAssetLinkResourceName(rn: string): ParsedAssetLink | {
22
+ error: string;
23
+ };
24
+ export declare function validateFinalUrls(urls: unknown): {
25
+ ok: true;
26
+ urls: string[];
27
+ } | {
28
+ ok: false;
29
+ error: string;
30
+ };
31
+ export declare function normalizeUpdateAssetUrlsArgs(raw: Record<string, unknown> | undefined): UpdateAssetUrlsArgs | {
32
+ error: string;
33
+ };
34
+ export declare function normalizePauseAssetLinksArgs(raw: Record<string, unknown> | undefined): PauseAssetLinksArgs | {
35
+ error: string;
36
+ };
37
+ export interface UpdateUrlsDryRun {
38
+ dry_run: true;
39
+ message: string;
40
+ customer_id: string;
41
+ updates: AssetUrlUpdate[];
42
+ warning: string;
43
+ }
44
+ export declare function buildUpdateUrlsDryRun(args: UpdateAssetUrlsArgs): UpdateUrlsDryRun;
45
+ export interface PauseLinksDryRun {
46
+ dry_run: true;
47
+ message: string;
48
+ customer_id: string;
49
+ would_pause: {
50
+ customer_asset: string[];
51
+ campaign_asset: string[];
52
+ ad_group_asset: string[];
53
+ };
54
+ }
55
+ export declare function buildPauseLinksDryRun(args: PauseAssetLinksArgs): PauseLinksDryRun;
@@ -0,0 +1,129 @@
1
+ const RE_CUSTOMER_ASSET = /^customers\/(\d+)\/customerAssets\/[^/]+$/;
2
+ const RE_CAMPAIGN_ASSET = /^customers\/(\d+)\/campaignAssets\/[^/]+$/;
3
+ const RE_AD_GROUP_ASSET = /^customers\/(\d+)\/adGroupAssets\/[^/]+$/;
4
+ function parseAssetLinkResourceName(rn) {
5
+ const trimmed = rn.trim();
6
+ let m = trimmed.match(RE_CUSTOMER_ASSET);
7
+ if (m) return { level: "customer_asset", customer_id: m[1], resource_name: trimmed };
8
+ m = trimmed.match(RE_CAMPAIGN_ASSET);
9
+ if (m) return { level: "campaign_asset", customer_id: m[1], resource_name: trimmed };
10
+ m = trimmed.match(RE_AD_GROUP_ASSET);
11
+ if (m) return { level: "ad_group_asset", customer_id: m[1], resource_name: trimmed };
12
+ return {
13
+ error: `Unrecognized asset-link resource name: "${rn}". Expected customers/{cid}/customerAssets/..., customers/{cid}/campaignAssets/..., or customers/{cid}/adGroupAssets/...`
14
+ };
15
+ }
16
+ function validateFinalUrls(urls) {
17
+ if (!Array.isArray(urls) || urls.length === 0) {
18
+ return { ok: false, error: "final_urls must be a non-empty array" };
19
+ }
20
+ const cleaned = [];
21
+ for (const u of urls) {
22
+ if (typeof u !== "string") return { ok: false, error: `final_urls entries must be strings, got ${typeof u}` };
23
+ const t = u.trim();
24
+ if (!t) return { ok: false, error: "final_urls entries must be non-empty" };
25
+ if (!/^https?:\/\//i.test(t)) {
26
+ return { ok: false, error: `final_urls entry "${t}" must start with http:// or https://` };
27
+ }
28
+ cleaned.push(t);
29
+ }
30
+ return { ok: true, urls: cleaned };
31
+ }
32
+ function coerceArray(v) {
33
+ if (Array.isArray(v)) return v;
34
+ if (typeof v === "string") {
35
+ const t = v.trim();
36
+ if (t.startsWith("[")) {
37
+ try {
38
+ const p = JSON.parse(t);
39
+ if (Array.isArray(p)) return p;
40
+ } catch {
41
+ }
42
+ }
43
+ }
44
+ return void 0;
45
+ }
46
+ function normalizeUpdateAssetUrlsArgs(raw) {
47
+ const r = raw ?? {};
48
+ const customer_id = typeof r.customer_id === "string" ? r.customer_id : void 0;
49
+ const rawUpdates = coerceArray(r.updates);
50
+ if (!rawUpdates || rawUpdates.length === 0) {
51
+ return { error: "updates must be a non-empty array" };
52
+ }
53
+ const updates = [];
54
+ for (const u of rawUpdates) {
55
+ if (!u || typeof u !== "object") return { error: "each updates entry must be an object" };
56
+ const obj = u;
57
+ const asset_id = typeof obj.asset_id === "string" ? obj.asset_id.trim() : typeof obj.asset_id === "number" ? String(obj.asset_id) : "";
58
+ if (!asset_id || !/^\d+$/.test(asset_id)) {
59
+ return { error: `invalid asset_id: ${JSON.stringify(obj.asset_id)}. Expected numeric asset ID.` };
60
+ }
61
+ const v = validateFinalUrls(obj.final_urls);
62
+ if (!v.ok) return { error: `asset_id ${asset_id}: ${v.error}` };
63
+ updates.push({ asset_id, final_urls: v.urls });
64
+ }
65
+ return {
66
+ customer_id,
67
+ updates,
68
+ confirm: r.confirm === true || r.confirm === "true"
69
+ };
70
+ }
71
+ function normalizePauseAssetLinksArgs(raw) {
72
+ const r = raw ?? {};
73
+ const customer_id = typeof r.customer_id === "string" ? r.customer_id : void 0;
74
+ const rawRns = coerceArray(r.resource_names);
75
+ if (!rawRns || rawRns.length === 0) {
76
+ return { error: "resource_names must be a non-empty array" };
77
+ }
78
+ const resource_names = [];
79
+ for (const rn of rawRns) {
80
+ if (typeof rn !== "string" || !rn.trim()) {
81
+ return { error: "each resource_names entry must be a non-empty string" };
82
+ }
83
+ const parsed = parseAssetLinkResourceName(rn);
84
+ if ("error" in parsed) return { error: parsed.error };
85
+ resource_names.push(parsed.resource_name);
86
+ }
87
+ return {
88
+ customer_id,
89
+ resource_names,
90
+ confirm: r.confirm === true || r.confirm === "true"
91
+ };
92
+ }
93
+ function buildUpdateUrlsDryRun(args) {
94
+ return {
95
+ dry_run: true,
96
+ message: "DRY RUN. Nothing changed. Pass confirm: true to actually update.",
97
+ customer_id: args.customer_id ?? "",
98
+ updates: args.updates,
99
+ warning: "Updating an asset's final_urls affects EVERY campaign/ad group/customer link that uses this asset ID. Verify attachments before confirming."
100
+ };
101
+ }
102
+ function buildPauseLinksDryRun(args) {
103
+ const would = {
104
+ customer_asset: [],
105
+ campaign_asset: [],
106
+ ad_group_asset: []
107
+ };
108
+ for (const rn of args.resource_names) {
109
+ const parsed = parseAssetLinkResourceName(rn);
110
+ if (!("error" in parsed)) {
111
+ would[parsed.level].push(parsed.resource_name);
112
+ }
113
+ }
114
+ return {
115
+ dry_run: true,
116
+ message: "DRY RUN. Nothing paused. Pass confirm: true to actually pause.",
117
+ customer_id: args.customer_id ?? "",
118
+ would_pause: would
119
+ };
120
+ }
121
+ export {
122
+ buildPauseLinksDryRun,
123
+ buildUpdateUrlsDryRun,
124
+ normalizePauseAssetLinksArgs,
125
+ normalizeUpdateAssetUrlsArgs,
126
+ parseAssetLinkResourceName,
127
+ validateFinalUrls
128
+ };
129
+ //# sourceMappingURL=assetHelpers.js.map
package/dist/auth-cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "fs";
2
3
  import { GoogleAdsApi } from "google-ads-api";
3
4
  import http from "http";
4
5
  import promptsImport from "prompts";
5
- import { URL } from "url";
6
+ import { URL, fileURLToPath } from "url";
6
7
  import { writeStoredCredentials, credentialsFilePath, CREDENTIALS_FILE_VERSION } from "./credentials.js";
7
8
  import {
8
9
  EMBEDDED_CLIENT_ID,
@@ -369,7 +370,17 @@ function randomState() {
369
370
  globalThis.crypto.getRandomValues(bytes);
370
371
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
371
372
  }
372
- const isMain = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/auth-cli.js") || process.argv[1]?.endsWith("\\auth-cli.js");
373
+ function isMainModule() {
374
+ if (!process.argv[1]) return false;
375
+ try {
376
+ const scriptRealPath = realpathSync(process.argv[1]);
377
+ const moduleRealPath = realpathSync(fileURLToPath(import.meta.url));
378
+ return scriptRealPath === moduleRealPath;
379
+ } catch {
380
+ return false;
381
+ }
382
+ }
383
+ const isMain = isMainModule();
373
384
  if (isMain) {
374
385
  run().catch((err) => {
375
386
  const classified = classifyError(err);
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "fceb9f2",
3
- "builtAt": "2026-04-13T00:50:57.276Z",
2
+ "sha": "d3b40c7",
3
+ "builtAt": "2026-04-17T20:02:21.153Z",
4
4
  "embeddedSecrets": true
5
5
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pure builders for campaign creation. Produces the raw operation payloads
3
+ * that would be submitted via customer.campaigns.create / campaignBudgets.create
4
+ * / campaignCriteria.create. Kept as pure functions so they can be unit tested
5
+ * without needing a live Google Ads API client.
6
+ *
7
+ * Note: we return the payloads, not the operations, so callers can still use
8
+ * typed create(...) paths. For criteria, we return an array that may be empty.
9
+ */
10
+ export type ChannelType = "SEARCH" | "DEMAND_GEN";
11
+ export type BiddingStrategy = "MANUAL_CPC" | "MAXIMIZE_CLICKS" | "MAXIMIZE_CONVERSIONS" | "TARGET_CPA";
12
+ export interface CampaignCreateInput {
13
+ name: string;
14
+ budget_amount_micros: number;
15
+ channel_type?: ChannelType;
16
+ bidding_strategy?: BiddingStrategy;
17
+ target_cpa?: number;
18
+ target_cpc_cap?: number;
19
+ geo_target_ids?: string[];
20
+ language_id?: string;
21
+ start_date?: string;
22
+ end_date?: string;
23
+ }
24
+ export interface CampaignCreatePayload {
25
+ budget: Record<string, any>;
26
+ campaign: Record<string, any>;
27
+ /** Campaign-criterion payloads. `campaign` resource name is applied later
28
+ * (after the campaign exists). Here the shape is pre-resolution. */
29
+ criteria: Array<Record<string, any>>;
30
+ }
31
+ /**
32
+ * Build the operation payloads for a campaign creation. Does NOT attach
33
+ * resource_name references that only exist post-budget-create; the caller
34
+ * still has to call the budget first, then interpolate.
35
+ *
36
+ * Back-compat: calling with just {name, budget_amount_micros} produces the
37
+ * exact same SEARCH + manual_cpc + no-criteria shape as v1.1.
38
+ */
39
+ export declare function buildCampaignCreatePayload(input: CampaignCreateInput): CampaignCreatePayload;
@@ -0,0 +1,65 @@
1
+ import { enums } from "google-ads-api";
2
+ function buildCampaignCreatePayload(input) {
3
+ const budget = {
4
+ name: `${input.name} Budget`,
5
+ amount_micros: input.budget_amount_micros,
6
+ delivery_method: enums.BudgetDeliveryMethod.STANDARD,
7
+ // Google Ads API defaults to explicitly_shared=true when omitted, which
8
+ // makes auto-bidding strategies (MAXIMIZE_CONVERSIONS, TARGET_CPA, etc.)
9
+ // reject with "Bidding strategy type is incompatible with shared budget".
10
+ // Every MCP-created campaign has a 1:1 dedicated budget, so pin this
11
+ // explicitly to false.
12
+ explicitly_shared: false
13
+ };
14
+ const channelType = input.channel_type ?? "SEARCH";
15
+ const channelEnum = channelType === "DEMAND_GEN" ? enums.AdvertisingChannelType.DEMAND_GEN : enums.AdvertisingChannelType.SEARCH;
16
+ const strategy = input.bidding_strategy ?? (channelType === "DEMAND_GEN" ? "MAXIMIZE_CONVERSIONS" : "MANUAL_CPC");
17
+ const campaign = {
18
+ name: input.name,
19
+ status: enums.CampaignStatus.PAUSED,
20
+ advertising_channel_type: channelEnum
21
+ };
22
+ if (input.start_date) campaign.start_date = input.start_date;
23
+ if (input.end_date) campaign.end_date = input.end_date;
24
+ switch (strategy) {
25
+ case "MANUAL_CPC":
26
+ campaign.manual_cpc = {};
27
+ break;
28
+ case "MAXIMIZE_CONVERSIONS":
29
+ campaign.maximize_conversions = {};
30
+ break;
31
+ case "TARGET_CPA": {
32
+ if (typeof input.target_cpa !== "number") {
33
+ throw new Error("bidding_strategy=TARGET_CPA requires target_cpa (dollars)");
34
+ }
35
+ campaign.target_cpa = { target_cpa_micros: Math.round(input.target_cpa * 1e6) };
36
+ break;
37
+ }
38
+ case "MAXIMIZE_CLICKS": {
39
+ const cap = {};
40
+ if (typeof input.target_cpc_cap === "number") {
41
+ cap.cpc_bid_ceiling_micros = Math.round(input.target_cpc_cap * 1e6);
42
+ }
43
+ campaign.target_spend = cap;
44
+ break;
45
+ }
46
+ }
47
+ const criteria = [];
48
+ for (const geoId of input.geo_target_ids ?? []) {
49
+ criteria.push({ location: { geo_target_constant: `geoTargetConstants/${geoId}` } });
50
+ }
51
+ const hasGeo = (input.geo_target_ids?.length ?? 0) > 0;
52
+ if (hasGeo || input.language_id) {
53
+ const langId = input.language_id ?? "1000";
54
+ criteria.push({ language: { language_constant: `languageConstants/${langId}` } });
55
+ }
56
+ return {
57
+ budget,
58
+ campaign,
59
+ criteria
60
+ };
61
+ }
62
+ export {
63
+ buildCampaignCreatePayload
64
+ };
65
+ //# sourceMappingURL=campaignBuilder.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Pure helpers for google_ads_create_image_asset. Separated from the manager
3
+ * so validation logic (mime type, size caps, min dimensions) can be unit tested
4
+ * without touching disk or the Google Ads API.
5
+ */
6
+ export interface ImageInput {
7
+ name: string;
8
+ file_path?: string;
9
+ base64_data?: string;
10
+ }
11
+ export interface ImageValidationResult {
12
+ valid: boolean;
13
+ errors: string[];
14
+ }
15
+ export declare const MAX_IMAGE_BYTES = 5242880;
16
+ export declare const MIN_IMAGE_WIDTH = 600;
17
+ export declare const MIN_IMAGE_HEIGHT = 314;
18
+ export declare const ALLOWED_MIME_TYPES: readonly ["image/png", "image/jpeg", "image/gif"];
19
+ /**
20
+ * Detect image mime type from magic bytes. Returns null for anything that
21
+ * isn't PNG/JPEG/GIF. Google Ads asset uploads only accept these three formats
22
+ * for image assets.
23
+ */
24
+ export declare function detectMimeType(bytes: Buffer | Uint8Array): "image/png" | "image/jpeg" | "image/gif" | null;
25
+ /**
26
+ * Return {width, height} parsed from image headers for PNG / JPEG / GIF.
27
+ * Returns null if the header couldn't be parsed (too short, unexpected format).
28
+ * We only support these 3 formats — Google Ads image assets accept the same set.
29
+ */
30
+ export declare function getImageDimensions(bytes: Buffer, mime: "image/png" | "image/jpeg" | "image/gif"): {
31
+ width: number;
32
+ height: number;
33
+ } | null;
34
+ export declare function validateImageInput(input: ImageInput): ImageValidationResult;
35
+ export interface PreparedImage {
36
+ valid: boolean;
37
+ errors: string[];
38
+ /** Populated only when valid=true. */
39
+ bytes?: Buffer;
40
+ mime_type?: "image/png" | "image/jpeg" | "image/gif";
41
+ width?: number;
42
+ height?: number;
43
+ }
44
+ /**
45
+ * End-to-end validation + decode: given the tool inputs, read the bytes
46
+ * (from disk or base64), sniff the mime type, check size + dimensions, and
47
+ * return a PreparedImage ready to be handed to customer.assets.create.
48
+ *
49
+ * Does NOT hit the Google Ads API. All errors are returned in errors[].
50
+ */
51
+ export declare function prepareImageForUpload(input: ImageInput): PreparedImage;
@@ -0,0 +1,153 @@
1
+ import { readFileSync, existsSync, statSync } from "fs";
2
+ const MAX_IMAGE_BYTES = 5242880;
3
+ const MIN_IMAGE_WIDTH = 600;
4
+ const MIN_IMAGE_HEIGHT = 314;
5
+ const ALLOWED_MIME_TYPES = ["image/png", "image/jpeg", "image/gif"];
6
+ function detectMimeType(bytes) {
7
+ if (!bytes || bytes.length < 4) return null;
8
+ if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) {
9
+ return "image/png";
10
+ }
11
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
12
+ return "image/jpeg";
13
+ }
14
+ if (bytes.length >= 6 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56 && (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97) {
15
+ return "image/gif";
16
+ }
17
+ return null;
18
+ }
19
+ function getImageDimensions(bytes, mime) {
20
+ try {
21
+ if (mime === "image/png") {
22
+ if (bytes.length < 24) return null;
23
+ return {
24
+ width: bytes.readUInt32BE(16),
25
+ height: bytes.readUInt32BE(20)
26
+ };
27
+ }
28
+ if (mime === "image/gif") {
29
+ if (bytes.length < 10) return null;
30
+ return {
31
+ width: bytes.readUInt16LE(6),
32
+ height: bytes.readUInt16LE(8)
33
+ };
34
+ }
35
+ if (mime === "image/jpeg") {
36
+ let i = 2;
37
+ while (i < bytes.length - 9) {
38
+ if (bytes[i] !== 255) return null;
39
+ const marker = bytes[i + 1];
40
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
41
+ const height = bytes.readUInt16BE(i + 5);
42
+ const width = bytes.readUInt16BE(i + 7);
43
+ return { width, height };
44
+ }
45
+ const segmentLen = bytes.readUInt16BE(i + 2);
46
+ i += 2 + segmentLen;
47
+ }
48
+ return null;
49
+ }
50
+ return null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ function validateImageInput(input) {
56
+ const errors = [];
57
+ if (!input.name || !input.name.trim()) {
58
+ errors.push("name is required");
59
+ }
60
+ const hasPath = typeof input.file_path === "string" && input.file_path.trim().length > 0;
61
+ const hasData = typeof input.base64_data === "string" && input.base64_data.trim().length > 0;
62
+ if (!hasPath && !hasData) {
63
+ errors.push("Either file_path or base64_data must be provided");
64
+ }
65
+ if (hasPath && hasData) {
66
+ errors.push("Provide exactly one of file_path or base64_data, not both");
67
+ }
68
+ return { valid: errors.length === 0, errors };
69
+ }
70
+ function prepareImageForUpload(input) {
71
+ const inputValidation = validateImageInput(input);
72
+ if (!inputValidation.valid) {
73
+ return { valid: false, errors: inputValidation.errors };
74
+ }
75
+ let bytes;
76
+ try {
77
+ if (input.file_path) {
78
+ if (!existsSync(input.file_path)) {
79
+ return {
80
+ valid: false,
81
+ errors: [`File not found: ${input.file_path}`]
82
+ };
83
+ }
84
+ const stat = statSync(input.file_path);
85
+ if (!stat.isFile()) {
86
+ return {
87
+ valid: false,
88
+ errors: [`Path is not a regular file: ${input.file_path}`]
89
+ };
90
+ }
91
+ bytes = readFileSync(input.file_path);
92
+ } else {
93
+ bytes = Buffer.from(input.base64_data, "base64");
94
+ if (bytes.length === 0) {
95
+ return { valid: false, errors: ["base64_data decoded to zero bytes"] };
96
+ }
97
+ }
98
+ } catch (err) {
99
+ return { valid: false, errors: [`Failed to read image: ${err.message}`] };
100
+ }
101
+ const errors = [];
102
+ if (bytes.length > MAX_IMAGE_BYTES) {
103
+ errors.push(
104
+ `Image too large: ${bytes.length} bytes (max ${MAX_IMAGE_BYTES} = 5MB)`
105
+ );
106
+ }
107
+ const mime = detectMimeType(bytes);
108
+ if (!mime) {
109
+ errors.push(
110
+ `Unrecognized image format. Only PNG, JPEG, and GIF are accepted for Google Ads image assets.`
111
+ );
112
+ return { valid: false, errors };
113
+ }
114
+ const dims = getImageDimensions(bytes, mime);
115
+ if (!dims) {
116
+ errors.push(`Could not parse image dimensions from header (corrupt file?)`);
117
+ return { valid: false, errors };
118
+ }
119
+ if (dims.width < MIN_IMAGE_WIDTH || dims.height < MIN_IMAGE_HEIGHT) {
120
+ errors.push(
121
+ `Image dimensions ${dims.width}x${dims.height} are below the Demand Gen minimum ${MIN_IMAGE_WIDTH}x${MIN_IMAGE_HEIGHT}`
122
+ );
123
+ }
124
+ if (errors.length > 0) {
125
+ return {
126
+ valid: false,
127
+ errors,
128
+ bytes,
129
+ mime_type: mime,
130
+ width: dims.width,
131
+ height: dims.height
132
+ };
133
+ }
134
+ return {
135
+ valid: true,
136
+ errors: [],
137
+ bytes,
138
+ mime_type: mime,
139
+ width: dims.width,
140
+ height: dims.height
141
+ };
142
+ }
143
+ export {
144
+ ALLOWED_MIME_TYPES,
145
+ MAX_IMAGE_BYTES,
146
+ MIN_IMAGE_HEIGHT,
147
+ MIN_IMAGE_WIDTH,
148
+ detectMimeType,
149
+ getImageDimensions,
150
+ prepareImageForUpload,
151
+ validateImageInput
152
+ };
153
+ //# sourceMappingURL=imageAsset.js.map